From a4ef04ede39636e3d6c416e38e78d69e20619a2b Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 12 Jun 2026 07:34:46 +0800 Subject: [PATCH 01/28] feat(settings): add SSH host management --- .../test/webui/web-settings.test.mjs | 132 +++ crates/agent-gateway/web/src/App.tsx | 11 +- .../web/src/components/icons.tsx | 4 + .../web/src/components/ui/input.tsx | 2 +- .../web/src/components/ui/textarea.tsx | 2 +- crates/agent-gateway/web/src/i18n/config.ts | 125 +- crates/agent-gateway/web/src/index.css | 51 + .../web/src/lib/settings/index.ts | 257 +++-- .../web/src/lib/settings/storage.ts | 10 + .../web/src/lib/settings/sync.ts | 156 ++- crates/agent-gateway/web/src/lib/ssh/scan.ts | 268 +++++ .../web/src/pages/SettingsPage.tsx | 20 +- .../web/src/pages/settings/SshSection.tsx | 1019 +++++++++++++++++ .../web/src/pages/settings/types.ts | 1 + crates/agent-gui/src-tauri/src/commands/fs.rs | 186 +++ .../src-tauri/src/commands/settings.rs | 676 +++++++++++ crates/agent-gui/src-tauri/src/lib.rs | 3 + .../src-tauri/src/services/gateway.rs | 6 +- .../src-tauri/src/services/gateway_bridge.rs | 159 +-- crates/agent-gui/src/App.tsx | 53 +- crates/agent-gui/src/components/icons.tsx | 4 + crates/agent-gui/src/components/ui/input.tsx | 2 +- .../agent-gui/src/components/ui/textarea.tsx | 2 +- crates/agent-gui/src/i18n/config.ts | 122 ++ crates/agent-gui/src/index.css | 118 ++ crates/agent-gui/src/lib/settings/index.ts | 138 ++- crates/agent-gui/src/lib/settings/storage.ts | 10 + crates/agent-gui/src/lib/settings/sync.ts | 131 +++ crates/agent-gui/src/lib/ssh/scan.ts | 268 +++++ crates/agent-gui/src/pages/SettingsPage.tsx | 9 + .../src/pages/settings/SshSection.tsx | 1019 +++++++++++++++++ crates/agent-gui/src/pages/settings/types.ts | 1 + 32 files changed, 4718 insertions(+), 247 deletions(-) create mode 100644 crates/agent-gateway/web/src/lib/ssh/scan.ts create mode 100644 crates/agent-gateway/web/src/pages/settings/SshSection.tsx create mode 100644 crates/agent-gui/src/lib/ssh/scan.ts create mode 100644 crates/agent-gui/src/pages/settings/SshSection.tsx diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 60708b2e0..7fa3e37cd 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -259,6 +259,138 @@ test("gateway settings sync keeps remote connection local and syncs web terminal assert.deepEqual(payload.chatRuntimeControls, synced.chatRuntimeControls); }); +test("ssh settings sync redacts stored secrets and carries one-shot secret updates", () => { + installWindow(); + const source = settings.normalizeSettings({ + ssh: { + hosts: [ + { + id: "ssh-prod", + name: "Prod", + description: "Production jump host", + host: "prod.example.com", + port: 2222, + username: "deploy", + authType: "privateKey", + password: "ssh-password", + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----", + privateKeyPath: "~/.ssh/prod", + proxy: { + type: "http", + url: "http://127.0.0.1", + port: 1080, + username: "proxy-user", + password: "proxy-password", + }, + }, + ], + }, + }); + + const redacted = settingsSync.redactSettingsForWebStorage(source); + assert.equal(redacted.ssh.hosts[0].password, ""); + assert.equal(redacted.ssh.hosts[0].privateKey, ""); + assert.equal(redacted.ssh.hosts[0].proxy.type, "http"); + assert.equal(redacted.ssh.hosts[0].proxy.password, ""); + assert.equal(redacted.ssh.hosts[0].passwordConfigured, true); + assert.equal(redacted.ssh.hosts[0].privateKeyConfigured, true); + assert.equal(redacted.ssh.hosts[0].proxy.passwordConfigured, true); + + const publicPayload = settingsSync.buildGatewaySettingsSyncPayload(source); + assert.equal(publicPayload.ssh.hosts[0].password, ""); + assert.equal(publicPayload.ssh.hosts[0].privateKey, ""); + assert.equal(publicPayload.ssh.hosts[0].proxy.type, "http"); + assert.equal(publicPayload.ssh.hosts[0].proxy.password, ""); + assert.equal(publicPayload.ssh.hosts[0].passwordConfigured, true); + assert.equal(publicPayload.ssh.hosts[0].privateKeyConfigured, true); + assert.equal(publicPayload.ssh.hosts[0].proxy.passwordConfigured, true); + assert.equal(Object.hasOwn(publicPayload, "sshSecretUpdates"), false); + + const privatePayload = settingsSync.buildGatewaySettingsSyncPayload(source, { + includeProviderApiKeyUpdates: true, + }); + assert.equal(privatePayload.ssh.hosts[0].password, ""); + assert.equal(privatePayload.ssh.hosts[0].privateKey, ""); + assert.equal(privatePayload.ssh.hosts[0].proxy.type, "http"); + assert.equal(privatePayload.ssh.hosts[0].proxy.password, ""); + assert.deepEqual(privatePayload.sshSecretUpdates, { + "ssh-prod": { + password: "ssh-password", + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----", + proxyPassword: "proxy-password", + }, + }); +}); + +test("ssh settings sync merges one-shot secret updates into existing hosts", () => { + installWindow(); + const current = settings.normalizeSettings({ + ssh: { + hosts: [ + { + id: "ssh-prod", + name: "Prod", + host: "prod.example.com", + username: "deploy", + authType: "password", + password: "old-password", + privateKey: "old-key", + proxy: { + type: "socks5", + url: "socks5://127.0.0.1", + port: 1080, + username: "proxy-user", + password: "old-proxy-password", + }, + }, + ], + }, + }); + + const synced = settingsSync.applyGatewaySettingsSyncPayload(current, { + ssh: { + hosts: [ + { + id: "ssh-prod", + name: "Prod", + host: "prod.example.com", + username: "deploy", + authType: "privateKey", + password: "", + passwordConfigured: true, + privateKey: "", + privateKeyPath: "~/.ssh/prod", + privateKeyConfigured: true, + proxy: { + type: "http", + url: "http://127.0.0.1", + port: 1080, + username: "proxy-user", + password: "", + passwordConfigured: true, + }, + }, + ], + }, + sshSecretUpdates: { + "ssh-prod": { + password: "new-password", + privateKey: "new-key", + proxyPassword: "new-proxy-password", + }, + }, + }); + + assert.equal(synced.ssh.hosts[0].authType, "privateKey"); + assert.equal(synced.ssh.hosts[0].password, "new-password"); + assert.equal(synced.ssh.hosts[0].privateKey, "new-key"); + assert.equal(synced.ssh.hosts[0].proxy.type, "http"); + assert.equal(synced.ssh.hosts[0].proxy.password, "new-proxy-password"); + assert.equal(synced.ssh.hosts[0].passwordConfigured, true); + assert.equal(synced.ssh.hosts[0].privateKeyConfigured, true); + assert.equal(synced.ssh.hosts[0].proxy.passwordConfigured, true); +}); + test("workspace project selection stays out of synced system workdir", () => { installWindow(); const resolvedSystem = settings.resolveWorkspaceProjects( diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index 973d607b5..422bb202a 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -598,8 +598,13 @@ function hasSettingsSyncChanged(prev: AppSettings, next: AppSettings) { ); } -function hasProviderApiKeyUpdates(settings: AppSettings) { - return settings.customProviders.some((provider) => provider.apiKey.trim().length > 0); +function hasSensitiveSettingsUpdates(settings: AppSettings) { + return ( + settings.customProviders.some((provider) => provider.apiKey.trim().length > 0) || + settings.ssh.hosts.some( + (host) => host.password.trim().length > 0 || host.privateKey.trim().length > 0, + ) + ); } function resolveAppWorkspaceProjects(settings: AppSettings): AppSettings { @@ -2166,7 +2171,7 @@ export default function App() { queueSettingsSave( rawNext, "保存 WebUI 设置失败。", - hasSettingsSyncChanged(prev, next) || hasProviderApiKeyUpdates(rawNext), + hasSettingsSyncChanged(prev, next) || hasSensitiveSettingsUpdates(rawNext), ); return next; }); diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index 59c087d5b..cb151b3d6 100644 --- a/crates/agent-gateway/web/src/components/icons.tsx +++ b/crates/agent-gateway/web/src/components/icons.tsx @@ -44,8 +44,10 @@ import HomeSource from "~icons/lucide/house"; import ImageIconSource from "~icons/lucide/image"; import ImageOffSource from "~icons/lucide/image-off"; import KeySource from "~icons/lucide/key"; +import LayoutGridSource from "~icons/lucide/layout-grid"; import Link2Source from "~icons/lucide/link-2"; import LightbulbSource from "~icons/lucide/lightbulb"; +import ListSource from "~icons/lucide/list"; import Loader2Source from "~icons/lucide/loader-circle"; import LockSource from "~icons/lucide/lock"; import LogOutSource from "~icons/lucide/log-out"; @@ -176,8 +178,10 @@ export const Home = createIcon(HomeSource); export const ImageIcon = createIcon(ImageIconSource); export const ImageOff = createIcon(ImageOffSource); export const Key = createIcon(KeySource); +export const LayoutGrid = createIcon(LayoutGridSource); export const Link2 = createIcon(Link2Source); export const Lightbulb = createIcon(LightbulbSource); +export const List = createIcon(ListSource); export const Loader2 = createIcon(Loader2Source); export const Lock = createIcon(LockSource); export const LogOut = createIcon(LogOutSource); diff --git a/crates/agent-gateway/web/src/components/ui/input.tsx b/crates/agent-gateway/web/src/components/ui/input.tsx index 480494415..028196072 100644 --- a/crates/agent-gateway/web/src/components/ui/input.tsx +++ b/crates/agent-gateway/web/src/components/ui/input.tsx @@ -10,7 +10,7 @@ export const Input = React.forwardRef( ( return (