feat: add managed SSH execution spaces

Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI.

Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts.

Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
+8
View File
@@ -416,6 +416,10 @@ describe('ActivityPanel', () => {
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
@@ -465,6 +469,10 @@ describe('ActivityPanel', () => {
name: 'Renamed current project',
description: '',
rootPath: 'C:\\Renamed',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Renamed'
},
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
+660 -18
View File
@@ -118,6 +118,11 @@ let conversationQueueChangeListener:
DesktopApi['conversationQueue']['onChanged']
>[0]
| undefined
let remoteProjectSaveProgressListener:
| Parameters<
DesktopApi['projects']['remote']['onSaveProgress']
>[0]
| undefined
const removeMaximizedChangedListener = vi.fn()
const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001'
@@ -127,6 +132,10 @@ const project = {
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Users\\test',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\Users\\test'
},
defaultWorkMode: 'ask' as const,
kind: 'user' as const,
builtInDefault: true,
@@ -372,7 +381,22 @@ const api: DesktopApi = {
id: _projectId
})),
setArchived: vi.fn(async () => {}),
delete: vi.fn(async () => {})
delete: vi.fn(async () => {}),
remote: {
activate:
vi.fn<DesktopApi['projects']['remote']['activate']>(),
save: vi.fn<DesktopApi['projects']['remote']['save']>(),
cancelCurrent:
vi.fn<DesktopApi['projects']['remote']['cancelCurrent']>(),
onSaveProgress: vi.fn((listener) => {
remoteProjectSaveProgressListener = listener
return () => {
if (remoteProjectSaveProgressListener === listener) {
remoteProjectSaveProgressListener = undefined
}
}
})
}
},
conversations: {
list: vi.fn(async () => []),
@@ -864,12 +888,15 @@ function selectProjectOption(projectName: string): void {
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
let reject!: (reason: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve }
return { promise, resolve, reject }
}
describe('App', () => {
@@ -915,6 +942,21 @@ describe('App', () => {
vi.mocked(api.conversations.onChanged)
.mockReset()
.mockReturnValue(() => undefined)
vi.mocked(api.projects.remote.activate).mockReset()
vi.mocked(api.projects.remote.cancelCurrent)
.mockReset()
.mockResolvedValue()
remoteProjectSaveProgressListener = undefined
vi.mocked(api.projects.remote.onSaveProgress)
.mockReset()
.mockImplementation((listener) => {
remoteProjectSaveProgressListener = listener
return () => {
if (remoteProjectSaveProgressListener === listener) {
remoteProjectSaveProgressListener = undefined
}
}
})
conversationQueueChangeListener = undefined
conversationQueueDispatchListener = undefined
vi.mocked(api.conversationQueue.list)
@@ -1103,7 +1145,11 @@ describe('App', () => {
id: '00000000-0000-4000-8000-000000000102',
name: 'Second project',
description: 'Another workspace',
rootPath: 'C:\\Second'
rootPath: 'C:\\Second',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\Second'
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
@@ -5139,7 +5185,11 @@ describe('App', () => {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
rootPath: 'C:\\Second',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\Second'
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
@@ -5336,13 +5386,15 @@ describe('App', () => {
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const defaultMode = within(dialog).getByRole('combobox', {
name: '默认模式'
})
const defaultMode = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
candidate.classList.contains('segmented-control')
)!
expect(
within(defaultMode)
.getAllByRole('option')
.map((option) => option.textContent)
.getAllByRole('button')
.map((button) => button.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 完全权限'])
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
})
@@ -5438,12 +5490,16 @@ describe('App', () => {
)
})
it('restores and persists the last active project', async () => {
it('starts in the first local project and persists manual selection', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\Second'
},
defaultWorkMode: 'execute' as const
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
@@ -5459,14 +5515,121 @@ describe('App', () => {
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(secondProject.name)
).toHaveTextContent(project.name)
expect(
screen.getByRole('button', {
name: '工作模式:Execute · 完全权限'
name: '工作模式:Ask · 只读问答'
})
).toBeEnabled()
selectProjectOption(project.name)
selectProjectOption(secondProject.name)
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
).toBe(secondProject.id)
)
})
it('persists a new local conversation before queueing its first message', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000105',
name: '新本地项目',
rootPath: 'C:\\NewLocal',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\NewLocal'
}
}
const persistence = deferred<void>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
vi.mocked(api.conversations.saveLocal).mockImplementation(
async (batch) => {
if (
batch.some(
(conversation) =>
conversation.header.projectId === secondProject.id
)
) {
await persistence.promise
}
}
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
selectProjectOption(secondProject.name)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(secondProject.name)
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '首条消息必须先保存会话' }
})
await waitFor(() =>
expect(screen.getByLabelText('发送')).toBeEnabled()
)
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({
header: expect.objectContaining({
projectId: secondProject.id
})
})
])
)
expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled()
persistence.resolve(undefined)
await waitFor(() =>
expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce()
)
expect(
vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1)
).toBeLessThan(
vi.mocked(
api.conversationQueue.enqueueUser
).mock.invocationCallOrder[0]!
)
})
it('starts in the first local project instead of restoring an SSH project', async () => {
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000103',
name: '远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000203',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
remoteProject,
project
])
localStorage.setItem(
'goodbuddy.active-project.v1',
remoteProject.id
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
expect(api.projects.remote.activate).not.toHaveBeenCalled()
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
@@ -5474,6 +5637,464 @@ describe('App', () => {
)
})
it('keeps the current project active when remote activation fails', async () => {
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000104',
name: '失效远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000204',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockRejectedValueOnce(
new Error('Agent upload failed')
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
selectProjectOption(remoteProject.name)
expect(
await screen.findByText(
'远程项目更新失败:Agent upload failed。请检查 Host 后重试'
)
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
})
it('revalidates the current SSH project when it is selected again', async () => {
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000114',
name: '可重新连接的远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000214',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockResolvedValue(
remoteProject
)
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(1)
)
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(2)
)
expect(api.projects.remote.activate).toHaveBeenLastCalledWith(
remoteProject.id
)
})
it('keeps Host-stale projects blocked and preserves queued dispatches until reactivation succeeds', async () => {
const hostId = '00000000-0000-4000-8000-000000000215'
const candidateId =
'00000000-0000-4000-8000-000000000315'
const remoteConversationId =
'00000000-0000-4000-8000-000000000415'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const host = {
id: hostId,
name: 'Build host',
hostname: '192.168.0.23',
port: 22,
username: 'root',
authentication: 'system-agent' as const,
credentialConfigured: true,
credentialSource: 'system-agent' as const,
hostKey: {
state: 'verified' as const,
algorithm: 'ecdsa-sha2-nistp256',
fingerprintSha256: fingerprint,
generation: 1
},
lastValidatedAt: '2026-08-24T00:00:00.000Z',
createdAt: '2026-08-24T00:00:00.000Z',
updatedAt: '2026-08-24T00:00:00.000Z'
}
const updatedHost = {
...host,
hostname: '10.7.0.23',
hostKey: { ...host.hostKey, generation: 2 },
updatedAt: '2026-08-24T00:01:00.000Z'
}
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000115',
name: 'Host 一致性项目',
rootPath: '/root',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId,
remoteRootPath: '/root'
},
builtInDefault: false
}
const sshHosts: NonNullable<DesktopApi['sshHosts']> = {
getSnapshot: vi.fn(async () => ({
hosts: [host],
secureStorageAvailable: true
})),
remove: vi.fn(async () => undefined),
inspectDraftHostKey: vi.fn(async () => ({
candidateId,
hostId,
state: 'verified' as const,
algorithm: 'ecdsa-sha2-nistp256',
fingerprintSha256: fingerprint
})),
discardCandidate: vi.fn(async () => undefined),
validateAndSave: vi.fn(async () => ({
host: updatedHost,
connection: {
hostId,
connected: true as const,
latencyMs: 12,
platform: 'linux' as const,
architecture: 'x64' as const,
shell: '/bin/bash',
homeDirectory: '/root',
detail: 'SSH 已连接,远端系统为 linux/x64'
}
})),
getRemoteEnvironment: vi.fn(async () => ({
hostId,
checkedAt: '2026-08-24T00:00:00.000Z',
architecture: 'x64' as const,
agent: {
state: 'current' as const,
expected: {
version: '0.11.2-e2e.8',
architecture: 'x64' as const
},
installed: {
version: '0.11.2-e2e.8',
architecture: 'x64' as const
}
},
runtimes: [{
runtimeId: 'opencode' as const,
provider: 'opencode' as const,
state: 'current' as const,
expected: {
version: '1.18.9',
architecture: 'x64' as const
},
installed: {
version: '1.18.9',
architecture: 'x64' as const
}
}]
})),
browseDirectories: vi.fn(async () => ({
path: '/root',
homeDirectory: '/root',
parentPath: '/',
entries: [],
truncated: false
})),
cancelDirectoryBrowse: vi.fn(async () => undefined)
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: remoteConversationId,
projectId: remoteProject.id,
title: 'Host 一致性对话',
updatedAt: Date.now(),
messages: []
}
])
const hostReactivation = deferred<typeof remoteProject>()
vi.mocked(api.projects.remote.activate)
.mockResolvedValueOnce(remoteProject)
.mockReturnValueOnce(hostReactivation.promise)
.mockResolvedValueOnce(remoteProject)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: { ...api, sshHosts }
})
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(1)
)
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
fireEvent.click(
await screen.findByRole('button', {
name: '编辑 Build host'
})
)
fireEvent.change(screen.getByLabelText('主机地址'), {
target: { value: '10.7.0.23' }
})
fireEvent.click(
screen.getByRole('button', { name: '检查 Host Key' })
)
fireEvent.click(
await screen.findByRole('button', {
name: '确认身份并继续'
})
)
fireEvent.click(
screen.getByRole('button', { name: '验证并保存' })
)
await waitFor(() =>
expect(sshHosts.validateAndSave).toHaveBeenCalledOnce()
)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(2)
)
expect(api.projects.remote.activate).toHaveBeenLastCalledWith(
remoteProject.id
)
const queuedDispatch: ConversationQueueDispatch = {
item: {
id: '00000000-0000-4000-8000-000000000515',
conversationId: remoteConversationId,
source: 'user',
label: '等待 Host 恢复的消息',
createdAt: '2026-08-24T00:02:00.000Z'
},
input: {
conversationId: remoteConversationId,
projectId: remoteProject.id,
runtimeSelection: { provider: 'opencode' },
workMode: 'ask',
includeMemoryContext: true,
prompt: '等待 Host 恢复的消息',
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto'
}
}
act(() => {
conversationQueueDispatchListener?.(queuedDispatch)
})
await waitFor(() =>
expect(api.conversationQueue.releaseUser).toHaveBeenCalledWith(
queuedDispatch.item.id
)
)
expect(run).not.toHaveBeenCalled()
await act(async () => {
hostReactivation.reject(new Error('Host reconnect failed'))
await hostReactivation.promise.catch(() => undefined)
})
expect(
await screen.findByText(
'远程项目更新失败:Host reconnect failed。请检查 Host 后重试'
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '对话' }))
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
fireEvent.change(composer, {
target: { value: '前台消息也必须等待重新连接' }
})
const send = screen.getByLabelText('发送')
expect(send).toBeDisabled()
expect(send).toHaveAttribute(
'title',
'远程项目需要重新连接,请重新选择当前项目后再发送'
)
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(3)
)
await waitFor(() => expect(send).toBeEnabled())
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
remoteConversationId
)
)
act(() => {
conversationQueueDispatchListener?.(queuedDispatch)
})
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]![0]).toMatchObject({
queueItemId: queuedDispatch.item.id,
projectId: remoteProject.id,
prompt: queuedDispatch.input.prompt
})
expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce()
})
it('shows every direct remote activation phase and blocks conflicting project actions', async () => {
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000105',
name: '阶段远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000205',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
const activation = deferred<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
selectProjectOption(remoteProject.name)
const progress = await screen.findByRole('progressbar', {
name: '远程项目连接进度'
})
expect(progress).toHaveAttribute('max', '5')
expect(progress).toHaveAttribute('value', '1')
expect(
screen.getByText('正在连接远程项目“阶段远程项目”')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '当前项目' })
).toBeDisabled()
expect(screen.getByLabelText('新建项目')).toBeDisabled()
expect(screen.getByLabelText('项目设置')).toBeDisabled()
act(() => {
remoteProjectSaveProgressListener?.({ phase: 'workspace' })
})
expect(progress).toHaveAttribute('value', '3')
expect(
screen.getByText('当前阶段:远端工作区')
).toBeInTheDocument()
const stages = screen.getByRole('list', {
name: '远程项目连接阶段'
})
expect(within(stages).getAllByRole('listitem')).toHaveLength(5)
expect(
within(stages).getByText('远端工作区').closest('li')
).toHaveAttribute('aria-current', 'step')
await act(async () => {
activation.resolve(remoteProject)
await activation.promise
})
await waitFor(() =>
expect(
screen.queryByRole('progressbar', {
name: '远程项目连接进度'
})
).not.toBeInTheDocument()
)
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
})
it('cancels direct remote activation without switching projects', async () => {
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000106',
name: '可取消远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000206',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
const activation = deferred<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
selectProjectOption(remoteProject.name)
fireEvent.click(
await screen.findByRole('button', { name: '取消连接' })
)
expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce()
expect(
screen.getByRole('button', { name: '取消中…' })
).toBeDisabled()
await act(async () => {
activation.reject(
new DOMException('Remote project save cancelled', 'AbortError')
)
await activation.promise.catch(() => undefined)
})
expect(
await screen.findByText('已取消远程项目连接')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
expect(
screen.queryByRole('progressbar', {
name: '远程项目连接进度'
})
).not.toBeInTheDocument()
})
it('shows grouped project details and keeps the rich menu keyboard accessible', async () => {
const channelProject = {
...project,
@@ -7494,7 +8115,11 @@ describe('App', () => {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
rootPath: 'C:\\Second',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\Second'
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
@@ -7512,13 +8137,25 @@ describe('App', () => {
)
expect(
within(dialog).getByLabelText('新对话默认 Runtime')
).toHaveValue('model')
).toHaveValue(
agentRuntimeSelectionKey({
provider: 'model',
profileId: modelProfileId
})
)
fireEvent.change(within(dialog).getByLabelText('说明'), {
target: { value: '更新后的说明' }
})
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{ target: { value: 'continue' } }
{
target: {
value: agentRuntimeSelectionKey({
provider: 'continue',
profileId: modelProfileId
})
}
}
)
fireEvent.click(
within(dialog).getByRole('button', { name: '保存项目' })
@@ -8483,7 +9120,11 @@ describe('App', () => {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
rootPath: 'C:\\Second',
executionSpace: {
kind: 'local' as const,
rootPath: 'C:\\Second'
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
@@ -8798,6 +9439,7 @@ describe('App', () => {
delete api.updates
}
})
it('hides Magic Notes when the platform feature is disabled', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
+415 -32
View File
@@ -89,6 +89,9 @@ import {
agentRuntimeSelectionSchema,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type {
RemoteProjectSavePhase
} from '../../shared/remote-project-candidate-contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
@@ -1154,14 +1157,6 @@ function hasConversationMigrationStorage(): boolean {
}
}
function loadActiveProjectId(): string | undefined {
try {
return localStorage.getItem(activeProjectStorageKey) || undefined
} catch {
return undefined
}
}
function isConversation(value: unknown): value is Conversation {
if (!value || typeof value !== 'object') {
return false
@@ -1974,6 +1969,25 @@ function App(): React.JSX.Element {
const [selectedExpertId, setSelectedExpertId] = useState('')
const [activeProjectId, setActiveProjectId] = useState('')
const activeProjectIdRef = useRef(activeProjectId)
const remoteProjectActivationRequestRef = useRef(0)
const remoteProjectActivationControlRef = useRef<{
requestId: number
projectId: string
cancelRequested: boolean
} | undefined>(undefined)
const [remoteProjectActivation, setRemoteProjectActivation] =
useState<{
projectId: string
phase: RemoteProjectSavePhase
cancelling: boolean
}>()
const remoteProjectsRequiringActivationRef = useRef(
new Set<string>()
)
const [
remoteProjectsRequiringActivation,
setRemoteProjectsRequiringActivation
] = useState<Set<string>>(() => new Set())
const workspaceChangesRequestRef = useRef(0)
const runtimeStatusRequestRef = useRef(0)
const runtimeSetupPromptedRef = useRef(false)
@@ -3345,6 +3359,8 @@ function App(): React.JSX.Element {
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const activeRemoteProjectRequiresActivation =
remoteProjectsRequiringActivation.has(activeProjectId)
const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name
: undefined
@@ -4820,9 +4836,262 @@ function App(): React.JSX.Element {
[]
)
const activateRemoteProject = useCallback(
async (
project: AssistantProject
): Promise<{
project: AssistantProject
current: boolean
}> => {
const requestId = remoteProjectActivationRequestRef.current + 1
remoteProjectActivationRequestRef.current = requestId
const control = {
requestId,
projectId: project.id,
cancelRequested: false
}
remoteProjectActivationControlRef.current = control
setRemoteProjectActivation({
projectId: project.id,
phase: 'host',
cancelling: false
})
const removeProgress =
window.goodbuddy.projects.remote.onSaveProgress(
(progress) => {
if (
remoteProjectActivationControlRef.current !==
control
) {
return
}
setRemoteProjectActivation({
projectId: project.id,
phase: progress.phase,
cancelling: control.cancelRequested
})
}
)
try {
const activated =
await window.goodbuddy.projects.remote.activate(project.id)
setProjects((current) =>
current.map((candidate) =>
candidate.id === activated.id ? activated : candidate
)
)
const current =
remoteProjectActivationRequestRef.current === requestId &&
!control.cancelRequested
if (current) {
notify({
tone: 'success',
message: tRef.current('notices.remoteProjectActivated'),
dedupeKey: `remote-project-activated:${project.id}`
})
} else if (
remoteProjectActivationRequestRef.current === requestId &&
control.cancelRequested
) {
notify({
tone: 'info',
message: tRef.current(
'notices.remoteProjectActivationCancelled'
),
dedupeKey: `remote-project-cancelled:${project.id}`
})
}
return { project: activated, current }
} catch (reason) {
if (
remoteProjectActivationRequestRef.current !== requestId
) {
return { project, current: false }
}
if (control.cancelRequested) {
notify({
tone: 'info',
message: tRef.current(
'notices.remoteProjectActivationCancelled'
),
dedupeKey: `remote-project-cancelled:${project.id}`
})
return { project, current: false }
}
const message =
reason instanceof Error && reason.message
? reason.message
: tRef.current('notices.remoteProjectActivationUnknownError')
throw new Error(
tRef.current('notices.remoteProjectActivationFailed', {
message
}),
{ cause: reason }
)
} finally {
removeProgress()
if (
remoteProjectActivationControlRef.current === control
) {
remoteProjectActivationControlRef.current = undefined
setRemoteProjectActivation(undefined)
}
}
},
[notify]
)
const cancelRemoteProjectActivation = useCallback((): void => {
const control = remoteProjectActivationControlRef.current
if (!control || control.cancelRequested) {
return
}
control.cancelRequested = true
setRemoteProjectActivation((current) =>
current && current.projectId === control.projectId
? { ...current, cancelling: true }
: current
)
void window.goodbuddy.projects.remote.cancelCurrent().catch(
(reason: unknown) => {
if (
remoteProjectActivationControlRef.current !== control
) {
return
}
control.cancelRequested = false
setRemoteProjectActivation((current) =>
current && current.projectId === control.projectId
? { ...current, cancelling: false }
: current
)
notify({
tone: 'error',
message:
reason instanceof Error && reason.message
? tRef.current(
'notices.remoteProjectActivationCancelFailed',
{ message: reason.message }
)
: tRef.current(
'notices.remoteProjectActivationCancelUnknownError'
)
})
}
)
}, [notify])
const markRemoteProjectsRequiringActivation = useCallback(
(projectIds: string[]): void => {
const next = new Set(remoteProjectsRequiringActivationRef.current)
let changed = false
for (const projectId of projectIds) {
if (!next.has(projectId)) {
next.add(projectId)
changed = true
}
}
if (!changed) {
return
}
remoteProjectsRequiringActivationRef.current = next
setRemoteProjectsRequiringActivation(next)
},
[]
)
const clearRemoteProjectActivationRequirement = useCallback(
(projectId: string): void => {
if (!remoteProjectsRequiringActivationRef.current.has(projectId)) {
return
}
const next = new Set(remoteProjectsRequiringActivationRef.current)
next.delete(projectId)
remoteProjectsRequiringActivationRef.current = next
setRemoteProjectsRequiringActivation(next)
},
[]
)
const resumeProjectConversationQueues = useCallback(
(projectId: string): void => {
const conversationIds = conversationsRef.current
.filter(
(conversation) =>
conversation.projectId === projectId &&
!conversation.remote
)
.map((conversation) => conversation.id)
if (conversationIds.length === 0) {
return
}
void Promise.all(
conversationIds.map((conversationId) =>
window.goodbuddy.conversationQueue.ready(conversationId)
)
).catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationQueueResumeFailed'
),
dedupeKey: `conversation-queue-resume:${projectId}`
})
})
},
[notify]
)
const handleSshHostUpdated = useCallback(
(hostId: string): void => {
const affectedProjects = projects.filter(
(candidate) =>
candidate.executionSpace.kind === 'ssh' &&
candidate.executionSpace.hostId === hostId
)
markRemoteProjectsRequiringActivation(
affectedProjects.map((project) => project.id)
)
const project = affectedProjects.find(
(candidate) => candidate.id === activeProjectIdRef.current
)
if (!project) {
return
}
void activateRemoteProject(project).then(
(activated) => {
if (!activated.current) {
return
}
clearRemoteProjectActivationRequirement(activated.project.id)
resumeProjectConversationQueues(activated.project.id)
},
(reason: unknown) => {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: tRef.current(
'notices.remoteProjectActivationUnknownError'
)
})
}
)
},
[
activateRemoteProject,
clearRemoteProjectActivationRequirement,
markRemoteProjectsRequiringActivation,
notify,
projects,
resumeProjectConversationQueues
]
)
useEffect(() => {
let active = true
void Promise.all([
const initialization = Promise.all([
window.goodbuddy.projects.list(false),
window.goodbuddy.conversations.list()
])
@@ -4830,12 +5099,16 @@ function App(): React.JSX.Element {
if (!active || value.length === 0) {
return
}
const lastActiveProjectId = loadActiveProjectId()
const project =
value.find(
(candidate) => candidate.id === lastActiveProjectId
) ?? value[0]!
setProjects(value)
const project = value.find(
(candidate) =>
candidate.kind === 'user' &&
candidate.executionSpace.kind === 'local'
)
if (!project) {
throw new Error('没有可用的本地项目')
}
remoteProjectActivationRequestRef.current += 1
setActiveProjectId(project.id)
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
@@ -4952,7 +5225,11 @@ function App(): React.JSX.Element {
}
setConversationStoreReady(true)
})
.catch((reason: unknown) => {
conversationPersistenceQueueRef.current = initialization.then(
() => undefined,
() => undefined
)
void initialization.catch((reason: unknown) => {
if (active) {
notify({
tone: 'error',
@@ -4966,7 +5243,7 @@ function App(): React.JSX.Element {
return () => {
active = false
}
}, [setActiveId])
}, [notify, setActiveId])
useEffect(() => {
if (!activeProjectId) {
@@ -5485,27 +5762,31 @@ function App(): React.JSX.Element {
)
}, [startNewConversation])
const selectProject = (projectId: string): void => {
const project = projects.find((candidate) => candidate.id === projectId)
if (!project) {
return
}
setActiveProjectId(projectId)
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
const conversation = conversations.find(
const commitProjectSelection = (selected: AssistantProject): void => {
setActiveProjectId(selected.id)
setWorkMode(
normalizeInteractiveWorkMode(selected.defaultWorkMode)
)
const conversation = conversationsRef.current.find(
(candidate) =>
candidate.projectId === projectId &&
(project.kind !== 'channel' || candidate.remote !== undefined)
candidate.projectId === selected.id &&
(
selected.kind !== 'channel' ||
candidate.remote !== undefined
)
)
if (conversation) {
setActiveId(conversation.id)
} else if (project.kind === 'channel') {
} else if (selected.kind === 'channel') {
setActiveId('')
} else {
const created = createConversation(
projectId,
selected.id,
runtimeSettings
? getProjectDefaultRuntimeSelection(project, runtimeSettings)
? getProjectDefaultRuntimeSelection(
selected,
runtimeSettings
)
: undefined,
t('conversation.greeting')
)
@@ -5515,10 +5796,41 @@ function App(): React.JSX.Element {
setView('chat')
}
const selectProject = (projectId: string): void => {
const project = projects.find((candidate) => candidate.id === projectId)
if (!project) {
return
}
if (project.executionSpace.kind !== 'ssh') {
remoteProjectActivationRequestRef.current += 1
commitProjectSelection(project)
return
}
void activateRemoteProject(project).then(
(activated) => {
if (activated.current) {
clearRemoteProjectActivationRequirement(activated.project.id)
commitProjectSelection(activated.project)
resumeProjectConversationQueues(activated.project.id)
}
},
(reason: unknown) => {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: t('notices.remoteProjectActivationUnknownError')
})
}
)
}
const createProject = async (
input: ProjectCreateInput
): Promise<AssistantProject> => {
const project = await window.goodbuddy.projects.create(input)
remoteProjectActivationRequestRef.current += 1
setProjects((current) => [project, ...current])
setActiveProjectId(project.id)
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
@@ -5556,8 +5868,25 @@ function App(): React.JSX.Element {
return project
}
const loadCommittedRemoteProject = async (
project: AssistantProject
): Promise<void> => {
setProjects((current) =>
current.some((candidate) => candidate.id === project.id)
? current.map((candidate) =>
candidate.id === project.id ? project : candidate
)
: [project, ...current]
)
remoteProjectActivationRequestRef.current += 1
commitProjectSelection(project)
clearRemoteProjectActivationRequirement(project.id)
resumeProjectConversationQueues(project.id)
}
const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true)
clearRemoteProjectActivationRequirement(projectId)
const remaining = projects.filter((project) => project.id !== projectId)
setProjects(remaining)
const next = remaining[0]
@@ -5571,6 +5900,7 @@ function App(): React.JSX.Element {
confirmation: string
): Promise<void> => {
await window.goodbuddy.projects.delete(projectId, confirmation)
clearRemoteProjectActivationRequirement(projectId)
const remainingProjects = projects.filter(
(project) => project.id !== projectId
)
@@ -6155,6 +6485,39 @@ function App(): React.JSX.Element {
await releaseQueuedItem()
return
}
const dispatchProjectId =
queuedInput?.projectId ?? conversationSnapshot.projectId
const dispatchProjectReactivating =
Boolean(
dispatchProjectId &&
remoteProjectActivationControlRef.current?.projectId ===
dispatchProjectId
)
const dispatchProjectRequiresActivation =
Boolean(
dispatchProjectId &&
remoteProjectsRequiringActivationRef.current.has(
dispatchProjectId
)
)
if (
dispatchProjectReactivating ||
dispatchProjectRequiresActivation
) {
if (queuedInput) {
await releaseQueuedItem()
} else {
notify({
tone: 'info',
message: t(
dispatchProjectRequiresActivation
? 'notices.remoteProjectReconnectRequired'
: 'notices.remoteProjectReactivating'
)
})
}
return
}
if (!queuedInput && selectingContextFilesRef.current) {
notify({
tone: 'info',
@@ -6293,6 +6656,17 @@ function App(): React.JSX.Element {
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot
}
try {
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
if (
!persistedLocalConversationsRef.current.has(
conversationSnapshot.id
)
) {
throw new Error(
t('notices.conversationPersistenceFailed')
)
}
await window.goodbuddy.conversationQueue.enqueueUser(queueInput)
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(false)
@@ -7452,16 +7826,19 @@ function App(): React.JSX.Element {
<ProjectSwitcher
activeProjectId={activeProjectId}
onCancelRemoteActivation={cancelRemoteProjectActivation}
runtimeSettings={runtimeSettings}
onArchive={archiveProject}
onCreate={createProject}
onDelete={deleteProject}
onRemoteCommitted={loadCommittedRemoteProject}
onSelect={selectProject}
onSelectRoot={() =>
window.goodbuddy.settings.selectWorkspace()
}
onUpdate={updateProject}
projects={projects}
remoteActivation={remoteProjectActivation}
/>
{activeProject?.kind !== 'channel' && (
@@ -8981,15 +9358,20 @@ function App(): React.JSX.Element {
)
)) ||
selectingContextFiles ||
remoteProjectActivation?.projectId ===
activeProjectId ||
activeRemoteProjectRequiresActivation ||
!runtime?.available ||
runtimeSwitching ||
runtimeStatusKey !== activeRuntimeSelectionKey
}
onClick={() => void submit()}
title={
conversationExecutionRunning
? t('composer.queueMessageTitle')
: t('composer.sendTitle')
activeRemoteProjectRequiresActivation
? t('notices.remoteProjectReconnectRequired')
: conversationExecutionRunning
? t('composer.queueMessageTitle')
: t('composer.sendTitle')
}
>
<Send aria-hidden="true" size={17} />
@@ -9667,6 +10049,7 @@ function App(): React.JSX.Element {
}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSshHostUpdated={handleSshHostUpdated}
onSaved={(settings) => {
setRuntimeSettings(settings)
}}
+32 -188
View File
@@ -1,61 +1,12 @@
import { FolderOpen } from 'lucide-react'
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import type { ProjectCreateInput } from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
isChannelModelProfileUsable,
repairChannelRuntimeSelection,
type AgentRuntimeSelection
repairChannelRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
function configuredRuntimeSelection(
provider: 'opencode' | 'continue' | 'deepseek-harness'
): AgentRuntimeSelection {
return { provider }
}
function runtimeSelectionDescription(
selection: AgentRuntimeSelection,
settings: RuntimeSettings,
t: TFunction<'integrations'>
): string {
if (selection.provider === 'model') {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
if (!profile) {
return t('channels.project.missingSelection')
}
if (profile.protocol === 'openai-images-generations') {
return t('channels.project.imageOnlySelection')
}
if (
profile.authentication === 'api-key' &&
!profile.apiKeyConfigured
) {
return t('channels.project.missingCredential')
}
return t('channels.project.directDescription', {
name: profile.name,
modelName: profile.modelName
})
}
if (selection.provider === 'auto') {
return t('channels.project.automaticDescription')
}
const runtimeLabel =
selection.provider === 'opencode'
? 'OpenCode'
: selection.provider === 'continue'
? 'Continue'
: 'DeepSeek Harness'
return t('channels.project.runtimeDescription', {
runtime: runtimeLabel
})
}
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
export function channelProjectDraft(
project: ProjectCreateInput,
@@ -91,45 +42,6 @@ export function ChannelProjectSettingsFields({
variant?: 'card' | 'dialog'
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const runtimeSelection = repairChannelRuntimeSelection(
value.runtimeSelection ?? { provider: 'auto' },
runtimeSettings
)
const directProfiles = runtimeSettings.modelProfiles.filter(
isChannelModelProfileUsable
)
const selectedDirectProfileId =
runtimeSelection.provider === 'model'
? runtimeSelection.profileId
: undefined
const selectedDirectProfile = runtimeSettings.modelProfiles.find(
(profile) => profile.id === selectedDirectProfileId
)
const selectedDirectUnavailable =
selectedDirectProfileId !== undefined &&
!directProfiles.some(
(profile) => profile.id === selectedDirectProfileId
)
const openCodeSelection = configuredRuntimeSelection('opencode')
const continueSelection = configuredRuntimeSelection('continue')
const deepseekHarnessSelection =
configuredRuntimeSelection('deepseek-harness')
const selections: AgentRuntimeSelection[] = [
...directProfiles.map((profile) => ({
provider: 'model' as const,
profileId: profile.id
})),
openCodeSelection,
continueSelection,
deepseekHarnessSelection
]
const selectionByKey = new Map(
selections.map((selection) => [
agentRuntimeSelectionKey(selection),
selection
])
)
return (
<section
aria-label={t('channels.project.sectionAriaLabel', {
@@ -182,103 +94,35 @@ export function ChannelProjectSettingsFields({
</div>
<small>{t('channels.project.rootHelp')}</small>
</label>
<label className="field">
<span>{t('channels.project.backendLabel')}</span>
<select
aria-label={t('channels.project.backendAriaLabel', {
name: value.name
})}
disabled={disabled}
onChange={(event) => {
const nextSelection = selectionByKey.get(event.target.value)
if (nextSelection) {
onChange({
...value,
runtimeSelection: nextSelection
})
}
}}
value={agentRuntimeSelectionKey(runtimeSelection)}
>
<optgroup label={t('channels.project.directModels')}>
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{selectedDirectProfile
? t('channels.project.unavailableProfile', {
name: selectedDirectProfile.name,
modelName: selectedDirectProfile.modelName
})
: t('channels.project.missingProfile')}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
{t('channels.project.noTextModels')}
</option>
)}
{directProfiles.map((profile) => {
const selection = {
provider: 'model' as const,
profileId: profile.id
}
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(selection)}
>
{profile.name} · {profile.modelName}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
<option value={agentRuntimeSelectionKey(openCodeSelection)}>
OpenCode
</option>
<option value={agentRuntimeSelectionKey(continueSelection)}>
Continue
</option>
<option
value={agentRuntimeSelectionKey(
deepseekHarnessSelection
)}
>
{t('channels.project.deepseekHarnessOption')}
</option>
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
runtimeSelection,
runtimeSettings,
t
)}
</small>
</label>
<fieldset className="channel-work-mode">
<legend>{t('channels.project.defaultMode')}</legend>
<SegmentedControl
ariaLabel={t('channels.project.defaultModeAriaLabel', {
name: value.name
})}
disabled={disabled}
onChange={(defaultWorkMode) =>
onChange({ ...value, defaultWorkMode })
}
options={[
{ value: 'ask', label: t('channels.project.modes.ask') },
{
value: 'execute',
label: t('channels.project.modes.execute')
}
]}
value={value.defaultWorkMode}
/>
<small>{t('channels.project.overrideHelp')}</small>
</fieldset>
<ProjectRuntimeSelector
ariaLabel={t('channels.project.backendAriaLabel', {
name: value.name
})}
disabled={disabled}
label={t('channels.project.backendLabel')}
onChange={(runtimeSelection) =>
onChange({ ...value, runtimeSelection })
}
runtimeSettings={runtimeSettings}
selection={value.runtimeSelection}
selectionMode="channel"
/>
<ProjectWorkModeFields
ariaLabel={t('channels.project.defaultModeAriaLabel', {
name: value.name
})}
disabled={disabled}
help={t('channels.project.overrideHelp')}
labels={{
ask: t('channels.project.modes.ask'),
execute: t('channels.project.modes.execute')
}}
legend={t('channels.project.defaultMode')}
onChange={(defaultWorkMode) =>
onChange({ ...value, defaultWorkMode })
}
value={value.defaultWorkMode}
/>
<p className="channel-project-settings__risk">
{value.defaultWorkMode === 'execute'
? t('channels.project.executeRisk')
@@ -109,6 +109,10 @@ const projects: AssistantProject[] = [
name: name!,
description: `${name}通道项目`,
rootPath: 'C:\\Users\\tester',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Users\\tester'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'auto' },
kind: 'channel',
@@ -117,6 +117,10 @@ function createProps(
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
+192
View File
@@ -0,0 +1,192 @@
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import type { RuntimeSettings } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider,
isChannelModelProfileUsable,
repairChannelRuntimeSelection,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
type ProjectRuntimeSelectorProps = {
ariaLabel: string
disabled?: boolean
label: string
onChange: (selection: AgentRuntimeSelection) => void
runtimeSettings: RuntimeSettings
selection?: AgentRuntimeSelection
selectionMode?: 'configured' | 'channel'
}
function runtimeSelectionDescription(
selection: AgentRuntimeSelection,
settings: RuntimeSettings,
t: TFunction<'integrations'>
): string {
if (selection.provider === 'model') {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
if (!profile) {
return t('channels.project.missingSelection')
}
if (profile.protocol === 'openai-images-generations') {
return t('channels.project.imageOnlySelection')
}
if (
profile.authentication === 'api-key' &&
!profile.apiKeyConfigured
) {
return t('channels.project.missingCredential')
}
return t('channels.project.directDescription', {
name: profile.name,
modelName: profile.modelName
})
}
if (selection.provider === 'auto') {
return t('channels.project.automaticDescription')
}
const runtimeLabel =
selection.provider === 'opencode'
? 'OpenCode'
: selection.provider === 'continue'
? 'Continue'
: 'DeepSeek Harness'
return t('channels.project.runtimeDescription', {
runtime: runtimeLabel
})
}
export function ProjectRuntimeSelector({
ariaLabel,
disabled = false,
label,
onChange,
runtimeSettings,
selection,
selectionMode = 'configured'
}: ProjectRuntimeSelectorProps): React.JSX.Element {
const { t } = useTranslation('integrations')
const initialSelection =
selection ?? getDefaultRuntimeSelection(runtimeSettings)
const runtimeSelection =
selectionMode === 'channel'
? repairChannelRuntimeSelection(initialSelection, runtimeSettings)
: initialSelection.provider === 'auto'
? getDefaultRuntimeSelection(runtimeSettings)
: initialSelection
const directProfiles = runtimeSettings.modelProfiles.filter(
isChannelModelProfileUsable
)
const selectedDirectProfileId =
runtimeSelection.provider === 'model'
? runtimeSelection.profileId
: undefined
const selectedDirectProfile = runtimeSettings.modelProfiles.find(
(profile) => profile.id === selectedDirectProfileId
)
const selectedDirectUnavailable =
selectedDirectProfileId !== undefined &&
!directProfiles.some(
(profile) => profile.id === selectedDirectProfileId
)
const runtimeProviders = [
'opencode',
'continue',
'deepseek-harness'
] as const
const runtimeSelections = runtimeProviders.map((provider) => {
if (runtimeSelection.provider === provider) {
return runtimeSelection
}
return selectionMode === 'channel'
? ({ provider } as AgentRuntimeSelection)
: getRuntimeSelectionForProvider(provider, runtimeSettings)
})
const directSelections = directProfiles.map((profile) => ({
provider: 'model' as const,
profileId: profile.id
}))
const selections = [...directSelections, ...runtimeSelections]
const selectionByKey = new Map(
selections.map((candidate) => [
agentRuntimeSelectionKey(candidate),
candidate
])
)
return (
<label className="field project-runtime-selector">
<span>{label}</span>
<select
aria-label={ariaLabel}
disabled={disabled}
onChange={(event) => {
const nextSelection = selectionByKey.get(event.target.value)
if (nextSelection) {
onChange(nextSelection)
}
}}
value={agentRuntimeSelectionKey(runtimeSelection)}
>
<optgroup label={t('channels.project.directModels')}>
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{selectedDirectProfile
? t('channels.project.unavailableProfile', {
name: selectedDirectProfile.name,
modelName: selectedDirectProfile.modelName
})
: t('channels.project.missingProfile')}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
{t('channels.project.noTextModels')}
</option>
)}
{directSelections.map((candidate) => {
const profile = directProfiles.find(
(item) => item.id === candidate.profileId
)!
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(candidate)}
>
{profile.name} · {profile.modelName}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
{runtimeSelections.map((candidate) => (
<option
key={candidate.provider}
value={agentRuntimeSelectionKey(candidate)}
>
{candidate.provider === 'opencode'
? 'OpenCode'
: candidate.provider === 'continue'
? 'Continue'
: t('channels.project.deepseekHarnessOption')}
</option>
))}
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
runtimeSelection,
runtimeSettings,
t
)}
</small>
</label>
)
}
+780
View File
@@ -0,0 +1,780 @@
import {
cleanup,
fireEvent,
render,
screen,
within,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import {
defaultRuntimeSettings,
type RuntimeSettings
} from '../../shared/contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import type { SshDirectoryBrowseResult } from '../../shared/ssh-host-contracts'
import i18n from './i18n'
import { ProjectSwitcher } from './ProjectSwitcher'
const profileId = '00000000-0000-4000-8000-000000000011'
const runtimeSettings: RuntimeSettings = {
...defaultRuntimeSettings,
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
workspacePath: 'C:\\Workspace',
apiKeyConfigured: true,
credentialSource: 'encrypted',
modelProfiles: [
{
id: profileId,
name: 'Text profile',
baseUrl: 'https://example.com',
modelName: 'text-model',
protocol: 'openai-responses',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
],
defaultModelProfileId: profileId,
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
deepseekHarnessModelSource: { kind: 'platform' },
secureStorageAvailable: true
}
const project: AssistantProject = {
id: '00000000-0000-4000-8000-000000000101',
name: 'Local project',
description: 'Local description',
rootPath: 'C:\\Workspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'model', profileId },
kind: 'user',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
afterEach(async () => {
cleanup()
await i18n.changeLanguage('zh-CN')
vi.restoreAllMocks()
})
function renderSwitcher(
currentProject: AssistantProject = project
): {
onCreate: ReturnType<typeof vi.fn>
onRemoteCommitted: ReturnType<typeof vi.fn>
onSelectRoot: ReturnType<typeof vi.fn>
onUpdate: ReturnType<typeof vi.fn>
} {
const onCreate = vi.fn(async (input) => ({
...currentProject,
...input
}))
const onUpdate = vi.fn(async (_projectId, input) => ({
...currentProject,
...input
}))
const onRemoteCommitted = vi.fn(async () => undefined)
const onSelectRoot = vi.fn(async () => undefined)
render(
<ProjectSwitcher
activeProjectId={currentProject.id}
onArchive={vi.fn(async () => undefined)}
onCreate={onCreate}
onDelete={vi.fn(async () => undefined)}
onRemoteCommitted={onRemoteCommitted}
onSelect={vi.fn()}
onSelectRoot={onSelectRoot}
onUpdate={onUpdate}
projects={[currentProject]}
runtimeSettings={runtimeSettings}
/>
)
return {
onCreate,
onRemoteCommitted,
onSelectRoot,
onUpdate
}
}
const hostId = '00000000-0000-4000-8000-000000000201'
const remoteProjectId = '00000000-0000-4000-8000-000000000203'
function directoryResult(
path: string,
entries: SshDirectoryBrowseResult['entries'] = []
): SshDirectoryBrowseResult {
const separator = path.lastIndexOf('/')
return {
path,
homeDirectory: '/home/builder',
parentPath:
path === '/' ? null : separator === 0 ? '/' : path.slice(0, separator),
entries,
truncated: false
}
}
function installRemoteApi() {
let progress: ((value: { phase: 'host' | 'agent' }) => void) | undefined
const savedProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' }
}
const save = vi.fn(async () => savedProject)
const cancelCurrent = vi.fn(async () => undefined)
const browseDirectories = vi.fn(async (_hostId, path?: string) =>
directoryResult(path ?? '/home/builder', [
{
name: 'projects',
path:
path === '/srv'
? '/srv/projects'
: '/home/builder/projects'
}
])
)
const cancelDirectoryBrowse = vi.fn(async () => undefined)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
sshHosts: {
browseDirectories,
cancelDirectoryBrowse,
getSnapshot: vi.fn(async () => ({
hosts: [
{
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
credentialConfigured: true,
credentialSource: 'system-agent',
hostKey: { state: 'verified', generation: 1 },
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
],
secureStorageAvailable: true
}))
},
projects: {
remote: {
save,
cancelCurrent,
onSaveProgress: vi.fn((listener) => {
progress = listener
return vi.fn()
})
}
}
}
})
return {
save,
cancelCurrent,
browseDirectories,
cancelDirectoryBrowse,
emit: (phase: 'host' | 'agent') => progress?.({ phase })
}
}
describe('ProjectSwitcher runtime fields', () => {
it('creates an ordinary project with DeepSeek Harness', async () => {
const { onCreate } = renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Harness project' }
})
const runtime = within(dialog).getByLabelText(
'新对话默认 Runtime'
)
expect(
within(runtime).getByRole('option', {
name: 'DeepSeek Harness(预览 · OpenAI 兼容)'
})
).toBeInTheDocument()
fireEvent.change(runtime, {
target: {
value: agentRuntimeSelectionKey({
provider: 'deepseek-harness'
})
}
})
fireEvent.click(
within(dialog).getByRole('button', { name: '创建' })
)
await waitFor(() =>
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Harness project',
runtimeSelection: { provider: 'deepseek-harness' }
})
)
)
})
it('edits an ordinary project to use DeepSeek Harness and switches work mode by keyboard', async () => {
const { onUpdate } = renderSwitcher()
fireEvent.click(screen.getByLabelText('项目设置'))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{
target: {
value: agentRuntimeSelectionKey({
provider: 'deepseek-harness'
})
}
}
)
const modeGroup = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
candidate.classList.contains('segmented-control')
)!
const ask = within(modeGroup).getByRole('button', {
name: 'Ask · 只读问答'
})
const execute = within(modeGroup).getByRole('button', {
name: 'Execute · 完全权限'
})
fireEvent.keyDown(ask, { key: 'ArrowRight' })
expect(execute).toHaveFocus()
expect(execute).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(
within(dialog).getByRole('button', { name: '保存项目' })
)
await waitFor(() =>
expect(onUpdate).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'deepseek-harness' }
})
)
)
})
it('localizes shared runtime and work mode fields in English', async () => {
await i18n.changeLanguage('en-US')
renderSwitcher()
fireEvent.click(screen.getByLabelText('New project'))
const dialog = screen.getByRole('dialog', { name: 'New project' })
expect(
within(dialog).getByLabelText(
'Default Runtime for new conversations'
)
).toBeInTheDocument()
expect(
within(dialog)
.getAllByRole('group', { name: 'Default mode' })
.find((candidate) =>
candidate.classList.contains('segmented-control')
)
).toBeInTheDocument()
expect(
within(dialog).getByRole('option', {
name: 'DeepSeek Harness (Preview · OpenAI-compatible)'
})
).toBeInTheDocument()
})
})
describe('ProjectSwitcher managed SSH projects', () => {
it('uses remote work directory copy and opens from a valid typed path', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi()
const { onSelectRoot } = renderSwitcher()
fireEvent.click(screen.getByLabelText('New project'))
const dialog = screen.getByRole('dialog', { name: 'New project' })
fireEvent.click(
within(dialog).getByRole('button', { name: 'Managed SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
const input = within(dialog).getByLabelText(
'Remote work directory'
)
fireEvent.change(input, {
target: { value: '/srv' }
})
const trigger = within(dialog).getByRole('button', {
name: 'Browse remote work directory'
})
expect(trigger).toHaveAttribute(
'title',
'Browse remote work directory'
)
fireEvent.click(trigger)
expect(
await screen.findByRole('dialog', {
name: 'Select remote work directory'
})
).toBeInTheDocument()
expect(api.browseDirectories).toHaveBeenCalledWith(hostId, '/srv')
expect(onSelectRoot).not.toHaveBeenCalled()
expect(
within(dialog).queryByLabelText('Remote root')
).not.toBeInTheDocument()
})
it('starts from the home directory when the typed path is invalid', async () => {
const api = installRemoteApi()
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: 'relative/path' }
})
fireEvent.click(
within(dialog).getByRole('button', {
name: '浏览远端工作目录'
})
)
await waitFor(() =>
expect(api.browseDirectories).toHaveBeenCalledWith(
hostId,
undefined
)
)
})
it('navigates child and parent directories and selects only the draft path', async () => {
const api = installRemoteApi()
api.browseDirectories.mockImplementation(
async (_requestedHostId, path?: string) => {
if (path === '/srv/projects') {
return directoryResult(path, [
{ name: 'goodbuddy', path: '/srv/projects/goodbuddy' }
])
}
return directoryResult(path ?? '/home/builder', [
{ name: 'projects', path: '/srv/projects' }
])
}
)
const { onSelectRoot } = renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv' }
})
fireEvent.click(
within(dialog).getByRole('button', {
name: '浏览远端工作目录'
})
)
const picker = await screen.findByRole('dialog', {
name: '选择远端工作目录'
})
fireEvent.click(
await within(picker).findByRole('button', {
name: '打开目录 projects'
})
)
await waitFor(() =>
expect(api.browseDirectories).toHaveBeenLastCalledWith(
hostId,
'/srv/projects'
)
)
expect(
within(picker).getByText('/srv/projects')
).toBeInTheDocument()
fireEvent.click(
within(picker).getByRole('button', {
name: '返回上级目录'
})
)
await waitFor(() =>
expect(api.browseDirectories).toHaveBeenLastCalledWith(
hostId,
'/srv'
)
)
fireEvent.click(
within(picker).getByRole('button', {
name: '选择此目录'
})
)
await waitFor(() =>
expect(within(dialog).getByLabelText('远端工作目录')).toHaveValue(
'/srv'
)
)
expect(onSelectRoot).not.toHaveBeenCalled()
})
it('keeps the outer dialog open and restores trigger focus after cancel and Escape', async () => {
const api = installRemoteApi()
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
const input = within(dialog).getByLabelText('远端工作目录')
fireEvent.change(input, { target: { value: '/keep/me' } })
const trigger = within(dialog).getByRole('button', {
name: '浏览远端工作目录'
})
fireEvent.click(trigger)
let picker = await screen.findByRole('dialog', {
name: '选择远端工作目录'
})
fireEvent.click(
within(picker).getByRole('button', { name: '取消' })
)
await waitFor(() => expect(trigger).toHaveFocus())
expect(dialog).toBeInTheDocument()
expect(input).toHaveValue('/keep/me')
fireEvent.click(trigger)
picker = await screen.findByRole('dialog', {
name: '选择远端工作目录'
})
fireEvent.click(
within(picker).getByRole('button', {
name: '关闭远端目录选择器'
})
)
await waitFor(() => expect(trigger).toHaveFocus())
expect(dialog).toBeInTheDocument()
expect(input).toHaveValue('/keep/me')
fireEvent.click(trigger)
picker = await screen.findByRole('dialog', {
name: '选择远端工作目录'
})
fireEvent.keyDown(picker, { key: 'Escape' })
await waitFor(() =>
expect(
screen.queryByRole('dialog', {
name: '选择远端工作目录'
})
).not.toBeInTheDocument()
)
expect(dialog).toBeInTheDocument()
expect(input).toHaveValue('/keep/me')
await waitFor(() => expect(trigger).toHaveFocus())
expect(api.cancelDirectoryBrowse).toHaveBeenCalledTimes(3)
})
it('closes the new-project dialog through its top-right button', () => {
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const close = within(dialog).getByRole('button', {
name: '关闭新建项目'
})
expect(close).toHaveClass('project-create-card__close')
fireEvent.click(close)
expect(
screen.queryByRole('dialog', { name: '新建项目' })
).not.toBeInTheDocument()
})
it('ignores stale browse results and preserves the typed path after an error', async () => {
const api = installRemoteApi()
let finishFirst!: (value: SshDirectoryBrowseResult) => void
api.browseDirectories
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirst = resolve
})
)
.mockRejectedValueOnce(new Error('Permission denied'))
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
const input = within(dialog).getByLabelText('远端工作目录')
fireEvent.change(input, { target: { value: '/typed/path' } })
const trigger = within(dialog).getByRole('button', {
name: '浏览远端工作目录'
})
fireEvent.click(trigger)
const firstPicker = await screen.findByRole('dialog', {
name: '选择远端工作目录'
})
fireEvent.click(
within(firstPicker).getByRole('button', { name: '取消' })
)
await waitFor(() => expect(trigger).toHaveFocus())
fireEvent.click(trigger)
const secondPicker = await screen.findByRole('dialog', {
name: '选择远端工作目录'
})
expect(
await within(secondPicker).findByRole('alert')
).toHaveTextContent('Permission denied')
finishFirst(
directoryResult('/stale', [
{ name: 'result', path: '/stale/result' }
])
)
await Promise.resolve()
expect(within(secondPicker).queryByText('/stale')).not.toBeInTheDocument()
expect(input).toHaveValue('/typed/path')
})
it('saves Execute in one request without extra confirmation checklists', async () => {
const api = installRemoteApi()
const { onRemoteCommitted } = renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Remote project' }
})
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
const modeGroup = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
candidate.classList.contains('segmented-control')
)!
fireEvent.click(
within(modeGroup).getByRole('button', {
name: 'Execute · 完全权限'
})
)
const save = within(dialog).getByRole('button', {
name: '保存远程项目'
})
expect(save).toBeEnabled()
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
fireEvent.click(save)
await waitFor(() => expect(api.save).toHaveBeenCalledOnce())
expect(api.save).toHaveBeenCalledWith({
intent: 'create',
draft: expect.objectContaining({
defaultWorkMode: 'execute',
hostId,
remoteRootPath: '/srv/project'
})
})
await waitFor(() =>
expect(onRemoteCommitted).toHaveBeenCalledWith(
expect.objectContaining({ id: remoteProjectId })
)
)
})
it('shows request-scoped progress while the awaited save is pending', async () => {
const api = installRemoteApi()
let finish!: (value: AssistantProject) => void
api.save.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Remote project' }
})
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.click(
within(dialog).getByRole('button', {
name: '保存远程项目'
})
)
await waitFor(() => expect(api.save).toHaveBeenCalledOnce())
api.emit('agent')
expect(
await within(dialog).findByText('当前阶段:远端 Agent')
).toBeInTheDocument()
finish({
...project,
id: remoteProjectId
})
})
it('preserves fields and the actionable error after a failed save', async () => {
const api = installRemoteApi()
api.save.mockRejectedValueOnce(new Error('Runtime unavailable'))
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Keep this draft' }
})
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.click(
within(dialog).getByRole('button', {
name: '保存远程项目'
})
)
expect(
await within(dialog).findByText('Runtime unavailable')
).toBeInTheDocument()
expect(within(dialog).getByLabelText('名称')).toHaveValue(
'Keep this draft'
)
})
it('cancels the current save when the dialog closes', async () => {
const api = installRemoteApi()
api.save.mockImplementationOnce(() => new Promise(() => undefined))
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Keep this draft' }
})
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.click(
within(dialog).getByRole('button', { name: '保存远程项目' })
)
await waitFor(() => expect(api.save).toHaveBeenCalledOnce())
fireEvent.click(
within(dialog).getByRole('button', { name: '取消' })
)
await waitFor(() => expect(api.cancelCurrent).toHaveBeenCalledOnce())
})
it('updates an existing SSH project without using the local update path', async () => {
const api = installRemoteApi()
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' }
}
const { onUpdate } = renderSwitcher(remoteProject)
fireEvent.click(screen.getByLabelText('项目设置'))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
await waitFor(() =>
expect(
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
expect(within(dialog).getByLabelText('SSH 主机')).toBeDisabled()
expect(within(dialog).getByLabelText('远端工作目录')).toBeDisabled()
fireEvent.click(
within(dialog).getByRole('button', {
name: '保存远程项目'
})
)
await waitFor(() =>
expect(api.save).toHaveBeenCalledWith({
intent: 'update',
draft: expect.objectContaining({
projectId: remoteProjectId,
hostId,
remoteRootPath: '/srv/project'
})
})
)
expect(onUpdate).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import type { WorkMode } from '../../shared/assistant-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
export function ProjectWorkModeFields({
ariaLabel,
disabled = false,
help,
labels,
legend,
onChange,
value
}: {
ariaLabel: string
disabled?: boolean
help?: string
labels: { ask: string; execute: string }
legend: string
onChange: (value: WorkMode) => void
value: WorkMode
}): React.JSX.Element {
return (
<fieldset className="project-work-mode">
<legend>{legend}</legend>
<SegmentedControl
ariaLabel={ariaLabel}
disabled={disabled}
onChange={onChange}
options={[
{ value: 'ask', label: labels.ask },
{ value: 'execute', label: labels.execute }
]}
value={value}
/>
{help && <small>{help}</small>}
</fieldset>
)
}
+70 -6
View File
@@ -733,6 +733,18 @@ describe('SettingsPanel runtime files', () => {
testModelConnection,
testRuntime
},
sshHosts: {
getSnapshot: vi.fn(async () => ({
hosts: [],
secureStorageAvailable: true
})),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(async () => undefined),
inspectHostKey: vi.fn(),
acceptHostKey: vi.fn(),
testConnection: vi.fn()
},
capabilities: {
getSnapshot: getCapabilitySnapshot,
importSkill,
@@ -1834,6 +1846,58 @@ describe('SettingsPanel runtime files', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('protects unsaved SSH host drafts across settings navigation', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="ssh-hosts"
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('尚未配置 SSH 主机')
fireEvent.click(
screen.getAllByRole('button', { name: '添加主机' })[0]!
)
fireEvent.change(screen.getByLabelText('主机名称'), {
target: { value: '尚未保存的主机' }
})
const appearanceTab = screen.getByRole('tab', { name: '外观' })
fireEvent.click(appearanceTab)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
expect(screen.getByLabelText('主机名称')).toHaveValue(
'尚未保存的主机'
)
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(appearanceTab)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并切换' })
)
expect(appearanceTab).toHaveAttribute('aria-selected', 'true')
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
await screen.findByText('尚未配置 SSH 主机')
expect(screen.queryByLabelText('主机名称')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({
ok: false,
@@ -1982,15 +2046,15 @@ describe('SettingsPanel runtime files', () => {
runtimeTab.focus()
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
const securityTab = screen.getByRole('tab', {
name: '安全与数据'
const sshHostsTab = screen.getByRole('tab', {
name: '主机与远程执行'
})
expect(securityTab).toHaveFocus()
expect(securityTab).toHaveAttribute('aria-selected', 'true')
expect(securityTab).toHaveAttribute('tabindex', '0')
expect(sshHostsTab).toHaveFocus()
expect(sshHostsTab).toHaveAttribute('aria-selected', 'true')
expect(sshHostsTab).toHaveAttribute('tabindex', '0')
expect(runtimeTab).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(securityTab, { key: 'End' })
fireEvent.keyDown(sshHostsTab, { key: 'End' })
const aboutTab = screen.getByRole('tab', { name: '关于与更新' })
expect(aboutTab).toHaveFocus()
expect(aboutTab).toHaveAttribute('aria-selected', 'true')
+22 -3
View File
@@ -49,6 +49,7 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
@@ -204,6 +205,7 @@ type SettingsPanelProps = {
initialCategory?: SettingsCategoryId
initialChannel?: ProjectChannel
onClose: () => void
onSshHostUpdated?: (hostId: string) => void
onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: (
@@ -573,6 +575,7 @@ export function SettingsPanel({
initialCategory,
initialChannel,
onClose,
onSshHostUpdated = () => {},
onSaved,
onNotify = () => {},
onUpdateProject,
@@ -724,6 +727,7 @@ export function SettingsPanel({
useState(false)
const [documentParsingDirty, setDocumentParsingDirty] =
useState(false)
const [sshHostsDirty, setSshHostsDirty] = useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId }
@@ -805,6 +809,7 @@ export function SettingsPanel({
const categoryRendersOwnHeader =
activeTab === 'platform-features' ||
activeTab === 'document-parsing' ||
activeTab === 'ssh-hosts' ||
activeTab === 'channels' ||
activeTab === 'skills' ||
activeTab === 'mcp' ||
@@ -908,17 +913,20 @@ export function SettingsPanel({
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
documentParsingDirty ||
sshHostsDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
documentParsingDirty ||
sshHostsDirty
const unsavedCloseMessage =
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!platformFeaturesDirty &&
!documentParsingDirty
!documentParsingDirty &&
!sshHostsDirty
? t('runtime.customization.unsavedClose')
: t('unsaved.close')
@@ -999,6 +1007,7 @@ export function SettingsPanel({
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined)
setAgentRuntimeType('opencode')
hydrateSettings(value)
@@ -1132,6 +1141,7 @@ export function SettingsPanel({
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined)
setError(undefined)
}
@@ -1156,6 +1166,7 @@ export function SettingsPanel({
setRuntimeCustomizationDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
const leave = pendingLeave
setPendingLeave(undefined)
setError(undefined)
@@ -3573,6 +3584,14 @@ export function SettingsPanel({
/>
)}
{activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onHostUpdated={onSshHostUpdated}
onNotify={onNotify}
/>
)}
{activeTab === 'security' && (
<>
<div className="settings-section">
+854
View File
@@ -0,0 +1,854 @@
import {
CheckCircle2,
KeyRound,
LoaderCircle,
Server,
ShieldCheck,
Wifi,
X
} from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { SSH_HOST_LIMITS } from '../../shared/ssh-host-contracts'
import type {
SshAuthenticationKind,
SshHost,
SshHostKeyInspection,
SshHostUpdateInput,
SshHostValidationResult
} from '../../shared/ssh-host-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { sshHostErrorMessage } from './ssh-host-ui'
type HostDraft = {
name: string
hostname: string
port: string
username: string
authentication: SshAuthenticationKind
}
type DialogStage = 'details' | 'host-key' | 'authentication' | 'success'
type SshHostDialogProps = {
host?: SshHost
secureStorageAvailable: boolean
onClose: () => void
onDirtyChange?: (dirty: boolean) => void
onSaved: (result: SshHostValidationResult) => void
}
function draftFromHost(
host: SshHost | undefined,
secureStorageAvailable: boolean
): HostDraft {
return host
? {
name: host.name,
hostname: host.hostname,
port: String(host.port),
username: host.username,
authentication: host.authentication
}
: {
name: '',
hostname: '',
port: '22',
username: '',
authentication: secureStorageAvailable
? 'password'
: 'system-agent'
}
}
function draftsEqual(left: HostDraft, right: HostDraft): boolean {
return (
left.name === right.name &&
left.hostname === right.hostname &&
left.port === right.port &&
left.username === right.username &&
left.authentication === right.authentication
)
}
function targetMatchesHost(draft: HostDraft, host?: SshHost): boolean {
return (
host !== undefined &&
draft.hostname.trim() === host.hostname &&
Number(draft.port) === host.port &&
draft.username.trim() === host.username &&
draft.authentication === host.authentication
)
}
export function SshHostDialog({
host,
secureStorageAvailable,
onClose,
onDirtyChange,
onSaved
}: SshHostDialogProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const initialDraft = useMemo(
() => draftFromHost(host, secureStorageAvailable),
[host, secureStorageAvailable]
)
const [draft, setDraft] = useState(initialDraft)
const [stage, setStage] = useState<DialogStage>('details')
const [inspection, setInspection] =
useState<SshHostKeyInspection>()
const [fingerprintConfirmed, setFingerprintConfirmed] =
useState(false)
const [passwordAction, setPasswordAction] = useState<
'keep' | 'replace'
>(
host?.authentication === 'password' &&
host.credentialConfigured
? 'keep'
: 'replace'
)
const [password, setPassword] = useState('')
const [error, setError] = useState<string>()
const [busy, setBusy] = useState<'inspect' | 'validate'>()
const [result, setResult] = useState<SshHostValidationResult>()
const dialogRef = useRef<HTMLElement>(null)
const nameRef = useRef<HTMLInputElement>(null)
const stageHeadingRef = useRef<HTMLHeadingElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const doneRef = useRef<HTMLButtonElement>(null)
const candidateIdRef = useRef<string | undefined>(undefined)
const titleId = useId()
const descriptionId = useId()
const formErrorId = useId()
const api = window.goodbuddy.sshHosts
const targetUnchanged = targetMatchesHost(draft, host)
const canKeepPassword =
host?.authentication === 'password' &&
host.credentialConfigured &&
targetUnchanged
const dirty =
stage !== 'success' &&
(stage !== 'details' ||
!draftsEqual(draft, initialDraft) ||
password.length > 0)
useEffect(
() => activateModalFocus(() => nameRef.current),
[]
)
useEffect(() => {
onDirtyChange?.(dirty)
}, [dirty, onDirtyChange])
useEffect(() => {
if (stage === 'host-key') {
stageHeadingRef.current?.focus()
} else if (stage === 'authentication') {
if (
draft.authentication === 'password' &&
passwordAction === 'replace'
) {
passwordRef.current?.focus()
} else {
stageHeadingRef.current?.focus()
}
} else if (stage === 'success') {
doneRef.current?.focus()
}
}, [draft.authentication, passwordAction, stage])
useEffect(() => {
return () => {
const candidateId = candidateIdRef.current
if (candidateId && api) {
void api.discardCandidate(candidateId).catch(() => undefined)
}
onDirtyChange?.(false)
}
}, [api, onDirtyChange])
const discardInspection = (): void => {
const candidateId = candidateIdRef.current
candidateIdRef.current = undefined
setInspection(undefined)
setFingerprintConfirmed(false)
if (candidateId && api) {
void api.discardCandidate(candidateId).catch(() => undefined)
}
}
const close = (): void => {
if (busy) {
return
}
discardInspection()
onClose()
}
const updateDraft = (update: Partial<HostDraft>): void => {
setDraft((current) => ({ ...current, ...update }))
setError(undefined)
}
const inspect = async (): Promise<void> => {
if (!api) {
setError(t('sshHosts.errors.unavailable'))
return
}
const port = Number(draft.port)
if (!draft.name.trim()) {
setError(t('sshHosts.validation.nameRequired'))
nameRef.current?.focus()
return
}
if (!draft.hostname.trim()) {
setError(t('sshHosts.validation.hostnameRequired'))
return
}
if (
!Number.isInteger(port) ||
port < 1 ||
port > SSH_HOST_LIMITS.maximumPort
) {
setError(t('sshHosts.validation.portInvalid'))
return
}
if (!draft.username.trim()) {
setError(t('sshHosts.validation.usernameRequired'))
return
}
if (
draft.authentication === 'password' &&
!secureStorageAvailable &&
!canKeepPassword
) {
setError(t('sshHosts.validation.passwordStorageRequired'))
return
}
setBusy('inspect')
setError(undefined)
discardInspection()
try {
const nextInspection = await api.inspectDraftHostKey({
...(host ? { hostId: host.id } : {}),
hostname: draft.hostname,
port,
username: draft.username
})
candidateIdRef.current = nextInspection.candidateId
setDraft((current) => ({
...current,
name: current.name.trim(),
hostname: current.hostname.trim(),
port: String(port),
username: current.username.trim()
}))
setInspection(nextInspection)
setFingerprintConfirmed(nextInspection.state === 'verified')
if (!canKeepPassword) {
setPasswordAction('replace')
}
setStage('host-key')
} catch (reason) {
setError(
sshHostErrorMessage(
reason,
t('sshHosts.errors.inspectFailed')
)
)
} finally {
setBusy(undefined)
}
}
const returnToDetails = (): void => {
discardInspection()
setError(undefined)
setStage('details')
}
const continueToAuthentication = (): void => {
if (!inspection) {
return
}
if (
inspection.state !== 'verified' &&
!fingerprintConfirmed
) {
setError(t('sshHosts.validation.confirmFingerprint'))
return
}
if (!canKeepPassword) {
setPasswordAction('replace')
}
setError(undefined)
setStage('authentication')
}
const validateAndSave = async (): Promise<void> => {
if (!api || !inspection) {
return
}
if (
draft.authentication === 'password' &&
passwordAction === 'replace' &&
!password
) {
setError(t('sshHosts.validation.passwordRequired'))
passwordRef.current?.focus()
return
}
const input: SshHostUpdateInput = {
name: draft.name,
hostname: draft.hostname,
port: Number(draft.port),
username: draft.username,
authentication: draft.authentication,
password:
draft.authentication === 'system-agent'
? { action: 'clear' }
: passwordAction === 'keep' && canKeepPassword
? { action: 'keep' }
: { action: 'replace', value: password }
}
setBusy('validate')
setError(undefined)
let nextResult: SshHostValidationResult
try {
nextResult = await api.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: inspection.fingerprintSha256,
input
})
} catch (reason) {
setError(
sshHostErrorMessage(
reason,
t('sshHosts.errors.validationFailed')
)
)
return
} finally {
setBusy(undefined)
}
candidateIdRef.current = undefined
setPassword('')
setResult(nextResult)
onSaved(nextResult)
setStage('success')
}
const currentStep =
stage === 'details'
? 1
: stage === 'host-key'
? 2
: stage === 'authentication'
? 3
: 4
return createPortal(
<div
className="custom-task-dialog ssh-host-dialog"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
close()
}
}}
>
<section
aria-describedby={`${descriptionId}${error ? ` ${formErrorId}` : ''}`}
aria-labelledby={titleId}
aria-modal="true"
className="custom-task-dialog__surface ssh-host-dialog__surface"
onKeyDown={(event) => {
if (event.key === 'Escape' && !busy) {
event.preventDefault()
close()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<header className="custom-task-dialog__header">
<div>
<span className="custom-task-dialog__eyebrow">
<Server aria-hidden="true" size={14} />
{t('sshHosts.wizard.eyebrow')}
</span>
<h2 id={titleId}>
{host
? t('sshHosts.wizard.editTitle')
: t('sshHosts.wizard.createTitle')}
</h2>
<p id={descriptionId}>
{t('sshHosts.wizard.description')}
</p>
</div>
<button
aria-label={t('sshHosts.actions.closeDialog')}
className="icon-button"
disabled={Boolean(busy)}
onClick={close}
type="button"
>
<X aria-hidden="true" size={17} />
</button>
</header>
<div className="ssh-host-dialog__progress">
<span>
{t('sshHosts.wizard.progress', {
current: currentStep,
total: 4
})}
</span>
<ol aria-label={t('sshHosts.wizard.stepsLabel')}>
{(
[
'details',
'hostKey',
'authentication',
'success'
] as const
).map((step, index) => (
<li
aria-current={
currentStep === index + 1 ? 'step' : undefined
}
className={
currentStep >= index + 1
? 'ssh-host-dialog__step--active'
: undefined
}
key={step}
>
<span>{index + 1}</span>
{t(`sshHosts.wizard.steps.${step}`)}
</li>
))}
</ol>
</div>
<div className="custom-task-dialog__content ssh-host-dialog__content">
{stage === 'details' && (
<div className="ssh-host-dialog__stage">
<div className="ssh-host-dialog__stage-heading">
<div>
<h3>{t('sshHosts.wizard.details.title')}</h3>
<p>{t('sshHosts.wizard.details.description')}</p>
</div>
</div>
<div className="ssh-host-dialog__details-grid">
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.name')}</span>
<input
maxLength={SSH_HOST_LIMITS.maximumNameLength}
onChange={(event) =>
updateDraft({ name: event.target.value })
}
ref={nameRef}
value={draft.name}
/>
</label>
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.hostname')}</span>
<input
maxLength={SSH_HOST_LIMITS.maximumHostnameLength}
onChange={(event) =>
updateDraft({ hostname: event.target.value })
}
value={draft.hostname}
/>
</label>
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.port')}</span>
<input
max={SSH_HOST_LIMITS.maximumPort}
min={1}
onChange={(event) =>
updateDraft({ port: event.target.value })
}
type="number"
value={draft.port}
/>
</label>
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.username')}</span>
<input
maxLength={SSH_HOST_LIMITS.maximumUsernameLength}
onChange={(event) =>
updateDraft({ username: event.target.value })
}
value={draft.username}
/>
</label>
</div>
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.authentication')}</span>
<select
onChange={(event) => {
const authentication = event.target
.value as SshAuthenticationKind
updateDraft({ authentication })
setPassword('')
setPasswordAction(
authentication === 'password' &&
host?.authentication === 'password' &&
host.credentialConfigured
? 'keep'
: 'replace'
)
}}
value={draft.authentication}
>
<option
disabled={!secureStorageAvailable}
value="password"
>
{t('sshHosts.authentication.password')}
</option>
<option value="system-agent">
{t('sshHosts.authentication.system-agent')}
</option>
</select>
{!secureStorageAvailable && (
<small>
{t('sshHosts.wizard.details.passwordUnavailable')}
</small>
)}
</label>
</div>
)}
{stage === 'host-key' && inspection && (
<div className="ssh-host-dialog__stage">
<div
aria-live={
inspection.state === 'changed'
? 'assertive'
: 'polite'
}
className={[
'ssh-host-dialog__identity',
inspection.state === 'changed' &&
'ssh-host-dialog__identity--danger'
]
.filter(Boolean)
.join(' ')}
>
<div className="ssh-host-dialog__stage-heading">
<KeyRound aria-hidden="true" size={20} />
<div>
<h3 ref={stageHeadingRef} tabIndex={-1}>
{t('sshHosts.hostKey.title')}
</h3>
<p>
{inspection.state === 'changed'
? t('sshHosts.hostKey.changed')
: inspection.state === 'verified'
? t('sshHosts.hostKey.matches')
: t('sshHosts.hostKey.firstUse')}
</p>
</div>
</div>
{inspection.previousHostKey && (
<div className="ssh-host-key-comparison">
<span>
{t('sshHosts.hostKey.previousFingerprint')}
</span>
<code className="ssh-host-fingerprint">
{inspection.previousHostKey.algorithm}{' '}
{inspection.previousHostKey.fingerprintSha256}
</code>
</div>
)}
<div className="ssh-host-key-comparison">
<span>
{t('sshHosts.hostKey.observedFingerprint')}
</span>
<code className="ssh-host-fingerprint">
{inspection.algorithm}{' '}
{inspection.fingerprintSha256}
</code>
</div>
<p>{t('sshHosts.hostKey.verifyOutOfBand')}</p>
{inspection.state !== 'verified' && (
<label className="ssh-host-dialog__confirmation">
<input
checked={fingerprintConfirmed}
onChange={(event) => {
setFingerprintConfirmed(event.target.checked)
setError(undefined)
}}
type="checkbox"
/>
<span>
{t('sshHosts.hostKey.confirmedOutOfBand')}
</span>
</label>
)}
</div>
</div>
)}
{stage === 'authentication' && inspection && (
<div className="ssh-host-dialog__stage">
<div className="ssh-host-dialog__stage-heading">
<ShieldCheck aria-hidden="true" size={20} />
<div>
<h3 ref={stageHeadingRef} tabIndex={-1}>
{t('sshHosts.wizard.authentication.title')}
</h3>
<p>
{t('sshHosts.wizard.authentication.description')}
</p>
</div>
</div>
<div className="ssh-host-dialog__target">
<Server aria-hidden="true" size={16} />
<span>
{draft.username}@{draft.hostname}:{draft.port}
</span>
<small>
{inspection.algorithm}{' '}
{inspection.fingerprintSha256}
</small>
</div>
{draft.authentication === 'password' ? (
<>
{canKeepPassword && (
<label className="custom-task-dialog__field">
<span>
{t('sshHosts.fields.passwordAction')}
</span>
<select
onChange={(event) => {
setPasswordAction(
event.target.value as
| 'keep'
| 'replace'
)
setPassword('')
setError(undefined)
}}
value={passwordAction}
>
<option value="keep">
{t('sshHosts.passwordActions.keep')}
</option>
<option value="replace">
{t('sshHosts.passwordActions.replace')}
</option>
</select>
</label>
)}
{(!canKeepPassword ||
passwordAction === 'replace') && (
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.password')}</span>
<input
autoComplete="new-password"
maxLength={
SSH_HOST_LIMITS.maximumPasswordLength
}
onChange={(event) => {
setPassword(event.target.value)
setError(undefined)
}}
ref={passwordRef}
type="password"
value={password}
/>
<small>{t('sshHosts.passwordHelp')}</small>
</label>
)}
</>
) : (
<div className="settings-notice">
<KeyRound aria-hidden="true" size={16} />
<span>
{t('sshHosts.wizard.authentication.agentHelp')}
</span>
</div>
)}
{busy === 'validate' && (
<div
aria-live="polite"
className="ssh-host-dialog__working"
role="status"
>
<LoaderCircle aria-hidden="true" size={18} />
<div>
<strong>
{t(
'sshHosts.wizard.authentication.testingTitle'
)}
</strong>
<span>
{t(
'sshHosts.wizard.authentication.testingDescription'
)}
</span>
</div>
</div>
)}
</div>
)}
{stage === 'success' && result && (
<div className="ssh-host-dialog__success">
<CheckCircle2 aria-hidden="true" size={34} />
<div>
<h3>{t('sshHosts.wizard.success.title')}</h3>
<p>
{t('sshHosts.wizard.success.description', {
name: result.host.name
})}
</p>
</div>
<dl>
<div>
<dt>{t('sshHosts.wizard.success.system')}</dt>
<dd>
{result.connection.platform}/
{result.connection.architecture}
</dd>
</div>
<div>
<dt>{t('sshHosts.wizard.success.latency')}</dt>
<dd>{result.connection.latencyMs} ms</dd>
</div>
<div>
<dt>{t('sshHosts.wizard.success.shell')}</dt>
<dd>{result.connection.shell}</dd>
</div>
<div>
<dt>{t('sshHosts.wizard.success.home')}</dt>
<dd>{result.connection.homeDirectory}</dd>
</div>
</dl>
</div>
)}
{error && (
<p
className="custom-task-dialog__form-error"
id={formErrorId}
role="alert"
>
{error}
</p>
)}
</div>
<footer className="custom-task-dialog__actions">
{stage === 'details' && (
<>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={close}
type="button"
>
{t('sshHosts.actions.cancel')}
</button>
<button
className="primary-button"
disabled={Boolean(busy)}
onClick={() => void inspect()}
type="button"
>
{busy === 'inspect' ? (
<LoaderCircle aria-hidden="true" size={15} />
) : (
<KeyRound aria-hidden="true" size={15} />
)}
{busy === 'inspect'
? t('sshHosts.actions.inspecting')
: t('sshHosts.actions.inspectAndContinue')}
</button>
</>
)}
{stage === 'host-key' && inspection && (
<>
<button
className="secondary-button"
onClick={returnToDetails}
type="button"
>
{t('sshHosts.actions.back')}
</button>
<button
className={
inspection.state === 'changed'
? 'danger-button'
: 'primary-button'
}
disabled={
inspection.state !== 'verified' &&
!fingerprintConfirmed
}
onClick={continueToAuthentication}
type="button"
>
{inspection.state === 'changed'
? t('sshHosts.actions.trustChangedAndContinue')
: t('sshHosts.actions.continueToAuthentication')}
</button>
</>
)}
{stage === 'authentication' && (
<>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() => {
setError(undefined)
setStage('host-key')
}}
type="button"
>
{t('sshHosts.actions.back')}
</button>
<button
className="primary-button"
disabled={Boolean(busy)}
onClick={() => void validateAndSave()}
type="button"
>
{busy === 'validate' ? (
<LoaderCircle aria-hidden="true" size={15} />
) : (
<Wifi aria-hidden="true" size={15} />
)}
{busy === 'validate'
? t('sshHosts.actions.validating')
: t('sshHosts.actions.validateAndSave')}
</button>
</>
)}
{stage === 'success' && (
<button
className="primary-button"
onClick={close}
ref={doneRef}
type="button"
>
{t('sshHosts.actions.done')}
</button>
)}
</footer>
</section>
</div>,
document.body
)
}
@@ -0,0 +1,577 @@
import {
cleanup,
fireEvent,
render,
screen,
within,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
SshHost,
SshHostRemoteEnvironment,
SshHostValidationResult,
SshHostsSnapshot
} from '../../shared/ssh-host-contracts'
import { changeUiLocale } from './i18n'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
const hostId = '00000000-0000-4000-8000-000000000104'
const candidateId = '00000000-0000-4000-8000-000000000105'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const verifiedHost: SshHost = {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
credentialConfigured: true,
credentialSource: 'encrypted',
hostKey: {
state: 'verified',
algorithm: 'ssh-ed25519',
fingerprintSha256: fingerprint,
generation: 1
},
lastValidatedAt: '2026-08-01T00:00:01.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const legacyUnverifiedHost: SshHost = {
...verifiedHost
}
delete legacyUnverifiedHost.lastValidatedAt
const emptySnapshot: SshHostsSnapshot = {
hosts: [],
secureStorageAvailable: true
}
const verifiedSnapshot: SshHostsSnapshot = {
hosts: [verifiedHost],
secureStorageAvailable: true
}
const validationResult: SshHostValidationResult = {
host: verifiedHost,
connection: {
hostId,
connected: true,
latencyMs: 18,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
}
}
const getSnapshot = vi.fn<() => Promise<SshHostsSnapshot>>()
const remove = vi.fn()
const inspectDraftHostKey = vi.fn()
const discardCandidate = vi.fn()
const validateAndSave = vi.fn()
const getRemoteEnvironment =
vi.fn<(hostId: string) => Promise<SshHostRemoteEnvironment>>()
const remoteEnvironment: SshHostRemoteEnvironment = {
hostId,
checkedAt: '2030-01-01T00:00:00.000Z',
architecture: 'x64',
agent: {
state: 'update-available',
expected: {
version: '0.11.1',
architecture: 'x64'
},
installed: {
version: '0.10.4',
architecture: 'x64'
}
},
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
state: 'current',
expected: {
version: '1.18.9',
architecture: 'x64'
},
installed: {
version: '1.18.9',
architecture: 'x64'
}
}]
}
function openNewHostDialog(): void {
fireEvent.click(
screen.getAllByRole('button', { name: '添加主机' })[0]!
)
}
function fillConnectionDetails(): void {
fireEvent.change(screen.getByLabelText('主机名称'), {
target: { value: 'Build host' }
})
fireEvent.change(screen.getByLabelText('主机地址'), {
target: { value: 'build.example.com' }
})
fireEvent.change(screen.getByLabelText('用户名'), {
target: { value: 'builder' }
})
}
async function inspectAndConfirmFirstKey(): Promise<void> {
fireEvent.click(
screen.getByRole('button', { name: '检查 Host Key' })
)
expect(
await screen.findByText('这是首次看到该主机密钥。')
).toBeInTheDocument()
const continueButton = screen.getByRole('button', {
name: '确认身份并继续'
})
expect(continueButton).toBeDisabled()
fireEvent.click(
screen.getByRole('checkbox', {
name: '我已通过可信渠道核对并确认本次指纹'
})
)
expect(continueButton).toBeEnabled()
fireEvent.click(continueButton)
}
describe('SshHostsSettingsSection', () => {
beforeEach(async () => {
await changeUiLocale('zh-CN')
vi.clearAllMocks()
getSnapshot.mockResolvedValue(emptySnapshot)
remove.mockResolvedValue(undefined)
inspectDraftHostKey.mockResolvedValue({
candidateId,
state: 'unverified',
algorithm: 'ssh-ed25519',
fingerprintSha256: fingerprint
})
discardCandidate.mockResolvedValue(undefined)
validateAndSave.mockResolvedValue(validationResult)
getRemoteEnvironment.mockResolvedValue(remoteEnvironment)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
sshHosts: {
getSnapshot,
remove,
inspectDraftHostKey,
discardCandidate,
validateAndSave,
getRemoteEnvironment
}
}
})
})
afterEach(() => cleanup())
it('validates identity and authentication before persisting a new host', async () => {
getSnapshot
.mockResolvedValueOnce(emptySnapshot)
.mockResolvedValue(verifiedSnapshot)
const onNotify = vi.fn()
const onDirtyChange = vi.fn()
const onHostUpdated = vi.fn()
render(
<SshHostsSettingsSection
onDirtyChange={onDirtyChange}
onHostUpdated={onHostUpdated}
onNotify={onNotify}
/>
)
await screen.findByText('尚未配置 SSH 主机')
openNewHostDialog()
expect(
screen.getByRole('dialog', {
name: '添加并验证 SSH 主机'
})
).toBeInTheDocument()
expect(screen.getByLabelText('主机名称')).toHaveFocus()
fillConnectionDetails()
await inspectAndConfirmFirstKey()
expect(inspectDraftHostKey).toHaveBeenCalledWith({
hostname: 'build.example.com',
port: 22,
username: 'builder'
})
expect(validateAndSave).not.toHaveBeenCalled()
expect(screen.getByText(new RegExp(fingerprint))).toBeInTheDocument()
fireEvent.change(screen.getByLabelText(/^SSH 密码/u), {
target: { value: 'private password' }
})
fireEvent.click(
screen.getByRole('button', { name: '验证并保存' })
)
await waitFor(() =>
expect(validateAndSave).toHaveBeenCalledWith({
candidateId,
fingerprintSha256: fingerprint,
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'private password'
}
}
})
)
expect(
await screen.findByText('主机已验证并保存')
).toBeInTheDocument()
expect(
screen.queryByDisplayValue('private password')
).not.toBeInTheDocument()
expect(onNotify).not.toHaveBeenCalled()
expect(onHostUpdated).not.toHaveBeenCalled()
await waitFor(() =>
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
)
fireEvent.click(screen.getByRole('button', { name: '完成' }))
expect(
screen.getByRole('region', { name: 'Build host' })
).toBeInTheDocument()
expect(
screen.getByText('已连接 · linux/x64 · 18 毫秒')
).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledOnce()
})
it('keeps failed authentication editable and retries without saving a failed host', async () => {
getSnapshot
.mockResolvedValueOnce(emptySnapshot)
.mockResolvedValue(verifiedSnapshot)
validateAndSave
.mockRejectedValueOnce(
new Error('SSH 认证失败,请检查用户名和认证凭据')
)
.mockResolvedValueOnce(validationResult)
render(<SshHostsSettingsSection />)
await screen.findByText('尚未配置 SSH 主机')
openNewHostDialog()
fillConnectionDetails()
await inspectAndConfirmFirstKey()
const password = screen.getByLabelText(/^SSH 密码/u)
fireEvent.change(password, {
target: { value: 'wrong password' }
})
fireEvent.click(
screen.getByRole('button', { name: '验证并保存' })
)
expect(
await screen.findByText('SSH 认证失败,请检查用户名和认证凭据')
).toBeInTheDocument()
expect(screen.getByDisplayValue('wrong password')).toBe(password)
expect(
screen.getByRole('dialog', {
name: '添加并验证 SSH 主机'
})
).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledOnce()
fireEvent.change(password, {
target: { value: 'correct password' }
})
fireEvent.click(
screen.getByRole('button', { name: '验证并保存' })
)
expect(
await screen.findByText('主机已验证并保存')
).toBeInTheDocument()
expect(validateAndSave).toHaveBeenCalledTimes(2)
expect(validateAndSave).toHaveBeenLastCalledWith(
expect.objectContaining({
candidateId,
input: expect.objectContaining({
password: {
action: 'replace',
value: 'correct password'
}
})
})
)
})
it('discards an inspected candidate when the add dialog is cancelled', async () => {
render(<SshHostsSettingsSection />)
await screen.findByText('尚未配置 SSH 主机')
openNewHostDialog()
fillConnectionDetails()
fireEvent.click(
screen.getByRole('button', { name: '检查 Host Key' })
)
await screen.findByText('这是首次看到该主机密钥。')
fireEvent.keyDown(
screen.getByRole('dialog', {
name: '添加并验证 SSH 主机'
}),
{ key: 'Escape' }
)
await waitFor(() =>
expect(discardCandidate).toHaveBeenCalledWith(candidateId)
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(validateAndSave).not.toHaveBeenCalled()
expect(getSnapshot).toHaveBeenCalledOnce()
})
it('shows old and new fingerprints in the changed-key dialog', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const changedFingerprint = `SHA256:${'B'.repeat(43)}`
inspectDraftHostKey.mockResolvedValue({
candidateId,
hostId,
state: 'changed',
algorithm: 'ssh-ed25519',
fingerprintSha256: changedFingerprint,
previousHostKey: {
algorithm: 'ssh-ed25519',
fingerprintSha256: fingerprint
}
})
render(<SshHostsSettingsSection />)
fireEvent.click(
await screen.findByRole('button', {
name: '编辑 Build host'
})
)
fireEvent.click(
screen.getByRole('button', { name: '检查 Host Key' })
)
expect(await screen.findByText(/可能是中间人攻击/u)).toBeInTheDocument()
expect(screen.getByText('此前固定的指纹')).toBeInTheDocument()
expect(screen.getByText('本次看到的指纹')).toBeInTheDocument()
const dialog = screen.getByRole('dialog', {
name: '编辑并重新验证 SSH 主机'
})
expect(
within(dialog).getByText(new RegExp(fingerprint))
).toBeInTheDocument()
expect(
within(dialog).getByText(new RegExp(changedFingerprint))
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '确认替换并继续' })
).toBeDisabled()
expect(inspectDraftHostKey).toHaveBeenCalledWith({
hostId,
hostname: 'build.example.com',
port: 22,
username: 'builder'
})
})
it('reports a saved Host address edit after validation succeeds', async () => {
const otherHostId = '00000000-0000-4000-8000-000000000106'
const systemAgentHost: SshHost = {
...verifiedHost,
authentication: 'system-agent',
credentialSource: 'system-agent'
}
const otherHost: SshHost = {
...systemAgentHost,
id: otherHostId,
name: 'Other host',
hostname: 'other.example.com'
}
const updatedHost: SshHost = {
...systemAgentHost,
hostname: '10.7.0.23',
hostKey: {
...systemAgentHost.hostKey,
generation: 2
},
updatedAt: '2026-08-01T00:01:00.000Z'
}
getSnapshot.mockResolvedValue({
hosts: [systemAgentHost, otherHost],
secureStorageAvailable: true
})
getRemoteEnvironment.mockImplementation(
async (requestedHostId) => ({
...remoteEnvironment,
hostId: requestedHostId
})
)
inspectDraftHostKey.mockResolvedValue({
candidateId,
hostId,
state: 'verified',
algorithm: 'ssh-ed25519',
fingerprintSha256: fingerprint
})
validateAndSave.mockResolvedValue({
...validationResult,
host: updatedHost
})
const onHostUpdated = vi.fn()
render(
<SshHostsSettingsSection onHostUpdated={onHostUpdated} />
)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
getRemoteEnvironment.mockClear()
fireEvent.click(
await screen.findByRole('button', {
name: '编辑 Build host'
})
)
fireEvent.change(screen.getByLabelText('主机地址'), {
target: { value: '10.7.0.23' }
})
fireEvent.click(
screen.getByRole('button', { name: '检查 Host Key' })
)
fireEvent.click(
await screen.findByRole('button', {
name: '确认身份并继续'
})
)
fireEvent.click(
screen.getByRole('button', { name: '验证并保存' })
)
await waitFor(() =>
expect(validateAndSave).toHaveBeenCalledWith({
candidateId,
fingerprintSha256: fingerprint,
input: {
name: 'Build host',
hostname: '10.7.0.23',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
}
})
)
expect(onHostUpdated).toHaveBeenCalledOnce()
expect(onHostUpdated).toHaveBeenCalledWith(hostId)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledOnce()
)
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
})
it('restores focus to the add trigger after Escape closes the dialog', async () => {
render(<SshHostsSettingsSection />)
await screen.findByText('尚未配置 SSH 主机')
const addButton = screen.getAllByRole('button', {
name: '添加主机'
})[0]!
addButton.focus()
fireEvent.click(addButton)
const dialog = screen.getByRole('dialog', {
name: '添加并验证 SSH 主机'
})
expect(screen.getByLabelText('主机名称')).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(addButton).toHaveFocus()
})
it('traps Tab focus inside the guided dialog', async () => {
render(<SshHostsSettingsSection />)
await screen.findByText('尚未配置 SSH 主机')
openNewHostDialog()
const closeButton = screen.getByRole('button', {
name: '关闭 SSH 主机验证'
})
const inspectButton = screen.getByRole('button', {
name: '检查 Host Key'
})
inspectButton.focus()
fireEvent.keyDown(inspectButton, { key: 'Tab' })
expect(closeButton).toHaveFocus()
fireEvent.keyDown(closeButton, {
key: 'Tab',
shiftKey: true
})
expect(inspectButton).toHaveFocus()
})
it('offers existing unverified hosts a validate action', async () => {
getSnapshot.mockResolvedValue({
hosts: [legacyUnverifiedHost],
secureStorageAvailable: true
})
render(<SshHostsSettingsSection />)
expect(
await screen.findByRole('button', {
name: '验证 Build host'
})
).toHaveTextContent('验证并保存')
expect(screen.getByText('需要重新验证')).toBeInTheDocument()
expect(getRemoteEnvironment).not.toHaveBeenCalled()
})
it('shows installed and expected Agent and Runtime versions in a balanced grid', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
render(<SshHostsSettingsSection />)
expect(
await screen.findByText('远程运行环境')
).toBeInTheDocument()
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
)
const host = screen.getByRole('region', { name: 'Build host' })
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
expect(within(host).getByText('OpenCode Runtime')).toBeInTheDocument()
expect(within(host).getByText('待更新')).toBeInTheDocument()
expect(within(host).getByText('当前版本')).toBeInTheDocument()
expect(within(host).getByText('0.10.4')).toBeInTheDocument()
expect(within(host).getByText('0.11.1')).toBeInTheDocument()
expect(within(host).getAllByText('1.18.9')).toHaveLength(2)
})
it('keeps Host details visible and retries a failed version refresh', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment
.mockRejectedValueOnce(new Error('Host unreachable'))
.mockResolvedValueOnce(remoteEnvironment)
render(<SshHostsSettingsSection />)
expect(
await screen.findByText('Host unreachable')
).toBeInTheDocument()
expect(screen.getByText('Build host')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '刷新 Build host 的远程运行环境版本'
})
)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
})
})
@@ -0,0 +1,654 @@
import {
Bot,
Boxes,
CheckCircle2,
LoaderCircle,
Pencil,
Plus,
RefreshCw,
Server,
ShieldCheck
} from 'lucide-react'
import {
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
SshHost,
SshHostConnectionTestResult,
SshHostRemoteEnvironment,
SshHostsSnapshot,
SshHostValidationResult
} from '../../shared/ssh-host-contracts'
import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { sshHostErrorMessage } from './ssh-host-ui'
import { SshHostDialog } from './SshHostDialog'
import {
DestructiveConfirmActions,
EmptyState
} from './WorkspacePrimitives'
type SshHostsSettingsSectionProps = {
onDirtyChange?: (dirty: boolean) => void
onHostUpdated?: (hostId: string) => void
onNotify?: (notification: AppNotificationInput) => void
}
type RemoteEnvironmentLoadState = {
loading: boolean
value?: SshHostRemoteEnvironment
error?: string
}
function credentialDescription(
host: SshHost,
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
): string {
return t(`sshHosts.credentialSources.${host.credentialSource}`)
}
function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
const index = hosts.findIndex((candidate) => candidate.id === host.id)
if (index === -1) {
return [...hosts, host]
}
const next = [...hosts]
next[index] = host
return next
}
export function SshHostsSettingsSection({
onDirtyChange,
onHostUpdated,
onNotify
}: SshHostsSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [hosts, setHosts] = useState<SshHost[]>([])
const [secureStorageAvailable, setSecureStorageAvailable] =
useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string>()
const [editingId, setEditingId] = useState<string | 'new'>()
const [busyHostId, setBusyHostId] = useState<string>()
const [confirmingDeleteId, setConfirmingDeleteId] =
useState<string>()
const [testResults, setTestResults] = useState<
Record<string, SshHostConnectionTestResult>
>({})
const [remoteEnvironments, setRemoteEnvironments] = useState<
Record<string, RemoteEnvironmentLoadState>
>({})
const remoteEnvironmentRequests = useRef(new Map<string, number>())
const api = window.goodbuddy.sshHosts
const requestSnapshot = useCallback((): Promise<SshHostsSnapshot> => {
if (!api) {
return Promise.reject(
new Error(t('sshHosts.errors.unavailable'))
)
}
return api.getSnapshot()
}, [api, t])
const applySnapshot = useCallback(
(snapshot: SshHostsSnapshot): void => {
setHosts(snapshot.hosts)
setSecureStorageAvailable(snapshot.secureStorageAvailable)
setError(undefined)
},
[]
)
useEffect(() => {
let active = true
void requestSnapshot()
.then((snapshot) => {
if (active) {
applySnapshot(snapshot)
}
})
.catch((reason: unknown) => {
if (active) {
setError(
sshHostErrorMessage(
reason,
t('sshHosts.errors.readFailed')
)
)
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [applySnapshot, requestSnapshot, t])
const requestRemoteEnvironment = useCallback(
async (hostId: string): Promise<void> => {
const requestId =
(remoteEnvironmentRequests.current.get(hostId) ?? 0) + 1
remoteEnvironmentRequests.current.set(hostId, requestId)
setRemoteEnvironments((current) => ({
...current,
[hostId]: {
...current[hostId],
loading: true,
error: undefined
}
}))
try {
if (!api?.getRemoteEnvironment) {
throw new Error(t('sshHosts.errors.environmentUnavailable'))
}
const value = await api.getRemoteEnvironment(hostId)
if (
remoteEnvironmentRequests.current.get(hostId) !== requestId
) {
return
}
setRemoteEnvironments((current) => ({
...current,
[hostId]: { loading: false, value }
}))
} catch (reason) {
if (
remoteEnvironmentRequests.current.get(hostId) !== requestId
) {
return
}
setRemoteEnvironments((current) => ({
...current,
[hostId]: {
loading: false,
value: current[hostId]?.value,
error: sshHostErrorMessage(
reason,
t('sshHosts.errors.environmentReadFailed')
)
}
}))
}
},
[api, t]
)
useEffect(
() => () => {
for (const [hostId, requestId] of
remoteEnvironmentRequests.current) {
remoteEnvironmentRequests.current.set(hostId, requestId + 1)
}
},
[]
)
useEffect(() => {
if (loading) {
return
}
const hostIds = hosts
.filter(
(host) =>
host.lastValidatedAt &&
!remoteEnvironmentRequests.current.has(host.id)
)
.map((host) => host.id)
if (hostIds.length === 0) {
return
}
let active = true
let nextIndex = 0
const worker = async (): Promise<void> => {
while (active) {
const hostId = hostIds[nextIndex]
nextIndex += 1
if (!hostId) {
return
}
await requestRemoteEnvironment(hostId)
}
}
void Promise.all(
Array.from(
{ length: Math.min(3, hostIds.length) },
() => worker()
)
)
return () => {
active = false
}
}, [hosts, loading, requestRemoteEnvironment])
const notify = (
dedupeKey: string,
message: string,
tone: AppNotificationInput['tone'] = 'success'
): void => {
onNotify?.({ dedupeKey, message, tone })
}
const handleSaved = (result: SshHostValidationResult): void => {
const edited = hosts.some((host) => host.id === result.host.id)
setHosts((current) => upsertHost(current, result.host))
setTestResults((current) => ({
...current,
[result.host.id]: result.connection
}))
setError(undefined)
if (result.host.lastValidatedAt) {
void requestRemoteEnvironment(result.host.id)
}
if (edited) {
onHostUpdated?.(result.host.id)
}
}
const remove = async (host: SshHost): Promise<void> => {
if (!api) {
return
}
setBusyHostId(host.id)
setError(undefined)
try {
await api.remove(host.id)
setConfirmingDeleteId(undefined)
setHosts((current) =>
current.filter((candidate) => candidate.id !== host.id)
)
setTestResults((current) => {
if (!(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
setRemoteEnvironments((current) => {
if (!(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
remoteEnvironmentRequests.current.delete(host.id)
notify(
`ssh-host-removed:${host.id}`,
t('sshHosts.notifications.removed', { name: host.name }),
'info'
)
} catch (reason) {
setError(
sshHostErrorMessage(
reason,
t('sshHosts.errors.removeFailed')
)
)
} finally {
setBusyHostId(undefined)
}
}
const editingHost =
editingId && editingId !== 'new'
? hosts.find((host) => host.id === editingId)
: undefined
return (
<div className="ssh-hosts-settings">
<SettingsCategoryHeader
actions={
<button
className="primary-button"
disabled={loading || Boolean(editingId) || !api}
onClick={() => {
setEditingId('new')
setError(undefined)
}}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('sshHosts.actions.add')}
</button>
}
category="ssh-hosts"
error={error}
headingId="ssh-hosts-heading"
/>
<div className="settings-notice">
<ShieldCheck aria-hidden="true" size={16} />
<span>{t('sshHosts.securityNotice')}</span>
</div>
{!secureStorageAvailable && !loading && (
<div className="settings-warning" role="alert">
{t('sshHosts.secureStorageUnavailable')}
</div>
)}
{loading ? (
<div className="settings-empty">{t('sshHosts.loading')}</div>
) : hosts.length === 0 ? (
<EmptyState
action={
<button
className="primary-button"
disabled={!api}
onClick={() => setEditingId('new')}
type="button"
>
{t('sshHosts.actions.add')}
</button>
}
description={t('sshHosts.empty.description')}
icon={<Server size={22} />}
title={t('sshHosts.empty.title')}
/>
) : (
<div
aria-label={t('sshHosts.listLabel')}
className="ssh-hosts-list"
>
{hosts.map((host) => {
const result = testResults[host.id]
const remoteEnvironment = remoteEnvironments[host.id]
const deleting = busyHostId === host.id
return (
<section
aria-label={host.name}
className="settings-section ssh-host-card"
key={host.id}
>
<div className="settings-section__title settings-section__title--actions">
<Server aria-hidden="true" size={17} />
<div>
<strong>{host.name}</strong>
<small>
{host.username}@{host.hostname}:{host.port}
</small>
</div>
<button
aria-label={t(
host.lastValidatedAt
? 'sshHosts.actions.editNamed'
: 'sshHosts.actions.validateNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={Boolean(busyHostId) || Boolean(editingId)}
onClick={() => {
setEditingId(host.id)
setError(undefined)
}}
type="button"
>
<Pencil aria-hidden="true" size={13} />
{host.lastValidatedAt
? t('sshHosts.actions.edit')
: t('sshHosts.actions.validate')}
</button>
</div>
<dl className="ssh-host-card__facts">
<div>
<dt>{t('sshHosts.fields.authentication')}</dt>
<dd>
{t(
`sshHosts.authentication.${host.authentication}`
)}
</dd>
</div>
<div>
<dt>{t('sshHosts.status.credential')}</dt>
<dd>{credentialDescription(host, t)}</dd>
</div>
<div>
<dt>{t('sshHosts.status.validation')}</dt>
<dd>
{host.lastValidatedAt ? (
<span className="ssh-host-card__verified">
<CheckCircle2
aria-hidden="true"
size={14}
/>
{t('sshHosts.status.validated')}
</span>
) : (
t('sshHosts.status.needsValidation')
)}
</dd>
</div>
</dl>
{host.hostKey.fingerprintSha256 && (
<code className="ssh-host-fingerprint">
{host.hostKey.algorithm}{' '}
{host.hostKey.fingerprintSha256}
</code>
)}
{result && (
<div className="ssh-host-test-result" role="status">
<CheckCircle2 aria-hidden="true" size={15} />
<span>
{t('sshHosts.testResult', {
platform: result.platform,
architecture: result.architecture,
latency: result.latencyMs
})}
</span>
</div>
)}
{host.lastValidatedAt && (
<div className="ssh-host-environment">
<div className="ssh-host-environment__header">
<div>
<span>
<Boxes aria-hidden="true" size={16} />
<strong>
{t('sshHosts.environment.title')}
</strong>
</span>
<small>
{t('sshHosts.environment.description')}
</small>
</div>
<button
aria-label={t(
'sshHosts.environment.refreshNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={remoteEnvironment?.loading}
onClick={() =>
void requestRemoteEnvironment(host.id)
}
type="button"
>
{remoteEnvironment?.loading ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{t('sshHosts.environment.refresh')}
</button>
</div>
{remoteEnvironment?.error && (
<div
className="ssh-host-environment__error"
role="alert"
>
{remoteEnvironment.error}
</div>
)}
{remoteEnvironment?.value ? (
<div className="ssh-host-environment__grid">
<EnvironmentVersionCard
icon={<Bot aria-hidden="true" size={17} />}
installed={
remoteEnvironment.value.agent.installed
}
label="GoodBuddy Agent"
state={remoteEnvironment.value.agent.state}
t={t}
expected={
remoteEnvironment.value.agent.expected
}
/>
{remoteEnvironment.value.runtimes.map(
(runtime) => (
<EnvironmentVersionCard
icon={
<Server aria-hidden="true" size={17} />
}
installed={runtime.installed}
key={runtime.runtimeId}
label={
runtime.runtimeId === 'opencode'
? 'OpenCode Runtime'
: runtime.runtimeId
}
state={runtime.state}
t={t}
expected={runtime.expected}
/>
)
)}
</div>
) : remoteEnvironment?.loading ? (
<div
className="ssh-host-environment__loading"
role="status"
>
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={16}
/>
{t('sshHosts.environment.loading')}
</div>
) : null}
</div>
)}
<div className="ssh-host-card__actions">
<DestructiveConfirmActions
confirmLabel={t(
'sshHosts.actions.confirmRemove'
)}
confirming={confirmingDeleteId === host.id}
disabled={deleting}
message={t('sshHosts.removeMessage', {
name: host.name
})}
onCancel={() =>
setConfirmingDeleteId(undefined)
}
onConfirm={() => void remove(host)}
onRequestConfirm={() =>
setConfirmingDeleteId(host.id)
}
triggerLabel={t('sshHosts.actions.remove')}
/>
</div>
</section>
)
})}
</div>
)}
{editingId && (editingId === 'new' || editingHost) && (
<SshHostDialog
host={editingHost}
onClose={() => {
setEditingId(undefined)
onDirtyChange?.(false)
}}
onDirtyChange={onDirtyChange}
onSaved={handleSaved}
secureStorageAvailable={secureStorageAvailable}
/>
)}
</div>
)
}
function EnvironmentVersionCard({
expected,
icon,
installed,
label,
state,
t
}: {
expected: SshHostRemoteEnvironment['agent']['expected']
icon: React.ReactNode
installed:
| SshHostRemoteEnvironment['agent']['installed']
| SshHostRemoteEnvironment['runtimes'][number]['installed']
label: string
state: SshHostRemoteEnvironment['agent']['state']
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
}): React.JSX.Element {
return (
<article className="ssh-host-environment__runtime">
<div className="ssh-host-environment__runtime-header">
<span className="ssh-host-environment__runtime-icon">
{icon}
</span>
<strong>{label}</strong>
<span
className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`}
>
{t(`sshHosts.environment.states.${state}`)}
</span>
</div>
<dl>
<div>
<dt>{t('sshHosts.environment.installed')}</dt>
<dd>
{installed ? (
<>
<strong>{installed.version}</strong>
<small>
{t('sshHosts.environment.versionDetail', {
architecture: installed.architecture
})}
</small>
</>
) : (
<span>{t('sshHosts.environment.notInstalled')}</span>
)}
</dd>
</div>
<div>
<dt>{t('sshHosts.environment.expected')}</dt>
<dd>
<strong>{expected.version}</strong>
<small>
{t('sshHosts.environment.versionDetail', {
architecture: expected.architecture
})}
</small>
</dd>
</div>
</dl>
</article>
)
}
@@ -100,6 +100,12 @@ describe('WorkspacePrimitives', () => {
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps whole-surface dialog scrollbars outside close-button hitboxes', () => {
expect(stylesheet).toMatch(
/:is\(\s*\.project-create-card,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
)
})
it('uses bundled variable fonts and readable shared type tokens', () => {
expect(rendererEntry).toContain(
"@fontsource-variable/noto-sans-sc/wght.css"
@@ -217,7 +223,9 @@ describe('WorkspacePrimitives', () => {
expect(stylesheet).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*var\(--assistant-sidebar-width,\s*30%\);[^}]*flex-basis:\s*var\(--assistant-sidebar-width,\s*30%\);/u
)
expect(stylesheet).not.toContain('@media (max-width: 719px)')
expect(stylesheet).not.toMatch(
/@media \(max-width: 719px\)\s*\{(?:[^{}]|\{[^{}]*\})*?\.assistant-sidebar/u
)
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*max\(var\(--page-gutter\),\s*calc\(\(100% - var\(--content-reading\)\) \/ 2\)\)\s*var\(--space-2\);/u
)
@@ -566,6 +566,21 @@ export const app = {
notices: {
updateAvailable:
'GoodBuddy {{version}} is available. View it in About & Updates.',
remoteProjectActivated:
'The remote project Agent and Runtime are ready',
remoteProjectActivationFailed:
'Remote project update failed: {{message}}. Check the Host and try again.',
remoteProjectActivationUnknownError: 'Unknown error',
remoteProjectReactivating:
'The remote project is reconnecting. Try again shortly.',
remoteProjectReconnectRequired:
'The remote project must reconnect. Select the current project again before sending.',
remoteProjectActivationCancelled:
'Remote project connection cancelled',
remoteProjectActivationCancelFailed:
'Could not cancel the remote project connection: {{message}}',
remoteProjectActivationCancelUnknownError:
'Could not cancel the remote project connection. Try again.',
channelConversationAutomatic:
'Channel conversations are created automatically when the client receives a new message',
deleteConversationCancelFailed:
@@ -50,6 +50,13 @@ export const settings = {
description:
'Configure Agent Runtimes, the default workspace, and native capabilities'
},
sshHosts: {
label: 'Hosts and remote execution',
navigationDescription:
'SSH hosts, identity verification, and connection probes',
description:
'Manage SSH hosts, pin host keys, and verify remote execution environments'
},
security: {
label: 'Security and data',
navigationDescription: 'Tool policies and local privacy',
@@ -8,6 +8,172 @@ export const settingsSections = {
modelscope: 'ModelScope',
'hugging-face': 'Hugging Face'
},
sshHosts: {
loading: 'Loading SSH hosts…',
listLabel: 'SSH host list',
securityNotice:
'When a host is added or edited, GoodBuddy inspects its host key before sending credentials and running a bounded system probe. The host and encrypted password are saved only after every step succeeds.',
secureStorageUnavailable:
'System secure storage is unavailable, so SSH passwords cannot be added or replaced. System SSH Agent authentication remains available.',
empty: {
title: 'No SSH hosts configured',
description:
'Use the guided flow to verify the host identity, authenticate, and probe the remote system. Cancelled or failed hosts are not retained.'
},
fields: {
name: 'Host name',
hostname: 'Host address',
port: 'Port',
username: 'Username',
authentication: 'Authentication',
passwordAction: 'Password handling',
password: 'SSH password'
},
authentication: {
password: 'Password',
'system-agent': 'System SSH Agent'
},
passwordActions: {
keep: 'Keep saved password',
replace: 'Enter a new password'
},
passwordHelp:
'The password is used only for this authentication attempt. After validation succeeds, the Main process writes it to system secure storage. It is never written to command-line arguments, logs, or regular settings files.',
credentialSources: {
none: 'Not configured',
encrypted: 'Encrypted by system secure storage',
'system-agent': 'Using the system SSH Agent',
unreadable: 'Saved, but currently unreadable'
},
status: {
credential: 'Credential',
validation: 'Validation',
validated: 'Validated and saved',
needsValidation: 'Revalidation required'
},
hostKey: {
title: 'SSH host key',
verified: 'Pinned',
unverified: 'Not verified',
firstUse: 'This is the first host key observed for this host.',
matches: 'The observed host key matches the pinned key.',
changed:
'The host key changed. The server may have been reinstalled, or this may be a machine-in-the-middle attack.',
previousFingerprint: 'Previously pinned fingerprint',
observedFingerprint: 'Observed fingerprint',
verifyOutOfBand:
'Verify the SHA-256 fingerprint with an administrator or another trusted channel. Do not accept it solely because the address looks correct.',
confirmedOutOfBand:
'I verified this fingerprint through a trusted channel'
},
testResult:
'Connected · {{platform}}/{{architecture}} · {{latency}} ms',
environment: {
title: 'Remote environment',
description:
'Read-only view of versions registered on this Host. Installation and updates happen only when a remote project is opened.',
loading: 'Loading Agent and Runtime versions…',
refresh: 'Refresh versions',
refreshNamed: 'Refresh remote environment versions for {{name}}',
installed: 'Installed on Host',
expected: 'Required by GoodBuddy',
notInstalled: 'Not installed',
versionDetail: 'Linux · {{architecture}}',
states: {
current: 'Current',
'update-available': 'Update available',
'not-installed': 'Not installed'
}
},
wizard: {
eyebrow: 'SSH host validation',
createTitle: 'Add and validate SSH host',
editTitle: 'Edit and revalidate SSH host',
description:
'Confirm connection details, host identity, and authentication in order. This flow runs only a fixed system probe and does not install a remote Agent.',
progress: 'Step {{current}} of {{total}}',
stepsLabel: 'SSH host validation steps',
steps: {
details: 'Connection',
hostKey: 'Host identity',
authentication: 'Authentication',
success: 'Complete'
},
details: {
title: 'Enter connection details',
description:
'The next step performs only a pre-authentication SSH handshake to inspect the host key. It does not send a password or request an SSH Agent signature.',
passwordUnavailable:
'System secure storage is unavailable, so only System SSH Agent authentication can be selected.'
},
authentication: {
title: 'Verify authentication and the remote system',
description:
'GoodBuddy will trust only the host key you just reviewed and run a fixed system probe with timeout and output limits.',
agentHelp:
'The current system SSH Agent will be used. GoodBuddy does not enable Agent Forwarding or forward the Agent to the remote host.',
testingTitle: 'Authenticating and probing the remote system…',
testingDescription:
'The host has not been saved. If authentication fails, you can correct the credential and retry.'
},
success: {
title: 'Host validated and saved',
description:
'The host key, credential, and connection details for “{{name}}” were saved atomically.',
system: 'Remote system',
latency: 'Latency',
shell: 'Shell',
home: 'Home directory'
}
},
removeMessage:
'Deleting “{{name}}” clears the locally saved connection information and encrypted credential. Remote files are not deleted.',
actions: {
add: 'Add host',
edit: 'Edit and revalidate',
validate: 'Validate and save',
editNamed: 'Edit {{name}}',
validateNamed: 'Validate {{name}}',
inspecting: 'Inspecting…',
remove: 'Delete',
confirmRemove: 'Confirm delete',
cancel: 'Cancel',
closeDialog: 'Close SSH host validation',
inspectAndContinue: 'Inspect host key',
back: 'Back',
continueToAuthentication: 'Confirm identity and continue',
trustChangedAndContinue: 'Confirm replacement and continue',
validateAndSave: 'Validate and save',
validating: 'Validating…',
done: 'Done'
},
validation: {
nameRequired: 'Enter a host name',
hostnameRequired: 'Enter a host address',
portInvalid: 'Port must be an integer from 1 through 65535',
usernameRequired: 'Enter a username',
confirmFingerprint:
'Verify and confirm the host-key fingerprint through a trusted channel first',
passwordRequired: 'Enter the SSH password',
passwordStorageRequired:
'System secure storage is unavailable, so an SSH password cannot be saved for this configuration. Select System SSH Agent instead.'
},
errors: {
unavailable: 'SSH host settings are unavailable in this build',
readFailed: 'Could not load SSH hosts',
inspectFailed: 'Could not inspect the SSH host key',
validationFailed:
'SSH authentication or the remote system probe failed',
removeFailed: 'Could not delete the SSH host',
environmentUnavailable:
'Remote environment version service is unavailable',
environmentReadFailed:
'Could not read remote environment versions'
},
notifications: {
removed: 'SSH host “{{name}}” deleted'
}
},
speech: {
title: 'Speech models',
description:
@@ -14,9 +14,11 @@ export const workspace = {
selector: {
ariaLabel: 'Current project',
userProjects: 'Local projects',
remoteProjects: 'Remote projects',
channelProjects: 'Remote channels',
empty: 'No project selected',
localDetail: 'Local folder · {{path}}',
managedSshDetail: 'Managed SSH · {{path}}',
remoteDetail: '{{channel}} · Remote channel · {{path}}',
remoteChannel: 'Remote channel',
create: 'New project',
@@ -31,6 +33,7 @@ export const workspace = {
name: 'Name',
description: 'Description',
rootPath: 'Root folder',
executionSpace: 'Execution space',
defaultMode: 'Default mode',
defaultRuntime: 'Default Runtime for new conversations'
},
@@ -66,6 +69,71 @@ export const workspace = {
selectRoot: 'Could not select the project root folder',
archive: 'Could not archive the project',
delete: 'Could not delete the project'
},
remote: {
executionSpaces: {
local: 'Local',
ssh: 'Managed SSH'
},
executionSpaceFixed:
'Existing projects cannot switch directly between local and managed SSH execution.',
fields: {
host: 'SSH host',
root: 'Remote work directory'
},
loadingHosts: 'Loading saved hosts…',
noHosts: 'No saved SSH hosts',
hostHelp:
'Host credentials remain centrally managed in Settings and are not copied into the project.',
rootHelp:
'Enter or choose a canonical absolute path beginning with /.',
directoryPicker: {
browse: 'Browse remote work directory',
title: 'Select remote work directory',
close: 'Close remote directory picker',
currentPath: 'Current directory',
parent: 'Go to parent directory',
refresh: 'Refresh current directory',
directory: 'Open directory {{name}}',
loading: 'Loading remote directories…',
empty: 'This directory has no subdirectories.',
loadError: 'Could not load remote directories: {{message}}',
unknownError: 'Check the SSH host connection and try again.',
cancel: 'Cancel',
select: 'Select this directory'
},
runtimeHelp:
'Managed SSH projects use OpenCode Runtime only. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and Runtime.',
actions: {
save: 'Save remote project',
saving: 'Saving remote project…'
},
activation: {
title: 'Connecting to remote project “{{project}}”',
progressLabel: 'Remote project connection progress',
stepsLabel: 'Remote project connection stages',
cancel: 'Cancel connection',
cancelling: 'Cancelling the remote connection…',
cancellingAction: 'Cancelling…'
},
phaseStatus: 'Current phase: {{phase}}',
phases: {
host: 'SSH host',
agent: 'Remote Agent',
workspace: 'Remote workspace',
runtime: 'OpenCode Runtime',
saving: 'Project settings'
},
validation: {
host: 'Select a saved SSH host.',
root:
'The remote work directory must be an absolute path beginning with /.'
},
errors: {
hostsUnavailable: 'The SSH host service is unavailable.',
loadHosts: 'Could not load SSH hosts.',
save: 'Could not save the remote project.'
}
}
},
sidebar: {
@@ -531,6 +531,18 @@ export const app = {
},
notices: {
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
remoteProjectActivationFailed:
'远程项目更新失败:{{message}}。请检查 Host 后重试',
remoteProjectActivationUnknownError: '未知错误',
remoteProjectReactivating: '远程项目正在重新连接,请稍候',
remoteProjectReconnectRequired:
'远程项目需要重新连接,请重新选择当前项目后再发送',
remoteProjectActivationCancelled: '已取消远程项目连接',
remoteProjectActivationCancelFailed:
'取消远程项目连接失败:{{message}}',
remoteProjectActivationCancelUnknownError:
'取消远程项目连接失败,请重试',
channelConversationAutomatic:
'通道项目的会话由客户端收到新消息后自动创建',
deleteConversationCancelFailed:
@@ -41,6 +41,11 @@ export const settings = {
navigationDescription: '配置 Agent Runtime、默认工作区与原生能力',
description: '配置 Agent Runtime、默认工作区与原生能力'
},
sshHosts: {
label: '主机与远程执行',
navigationDescription: 'SSH 主机、身份验证与连接探测',
description: '管理 SSH 主机、固定主机密钥并验证远端执行环境'
},
security: {
label: '安全与数据',
navigationDescription: '工具策略与本地隐私',
@@ -3,6 +3,166 @@ export const settingsSections = {
modelscope: 'ModelScope',
'hugging-face': 'Hugging Face'
},
sshHosts: {
loading: '正在读取 SSH 主机…',
listLabel: 'SSH 主机列表',
securityNotice:
'添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。',
secureStorageUnavailable:
'系统安全存储当前不可用,不能新增或替换 SSH 密码。系统 SSH Agent 认证仍可使用。',
empty: {
title: '尚未配置 SSH 主机',
description:
'通过引导流程核对主机身份、验证认证并探测远端系统。取消或验证失败不会保留新主机。'
},
fields: {
name: '主机名称',
hostname: '主机地址',
port: '端口',
username: '用户名',
authentication: '认证方式',
passwordAction: '密码处理',
password: 'SSH 密码'
},
authentication: {
password: '密码',
'system-agent': '系统 SSH Agent'
},
passwordActions: {
keep: '保留已保存密码',
replace: '输入新密码'
},
passwordHelp:
'密码仅用于本次认证,并在验证成功后由 Main 进程写入系统安全存储;不会写入命令行、日志或普通设置文件。',
credentialSources: {
none: '尚未配置',
encrypted: '已由系统安全存储加密',
'system-agent': '使用系统 SSH Agent',
unreadable: '已保存,但当前无法读取'
},
status: {
credential: '认证凭据',
validation: '验证状态',
validated: '已验证并保存',
needsValidation: '需要重新验证'
},
hostKey: {
title: 'SSH 主机密钥',
verified: '已固定',
unverified: '尚未验证',
firstUse: '这是首次看到该主机密钥。',
matches: '本次看到的主机密钥与已固定密钥一致。',
changed: '主机密钥已变化,可能是服务器重装,也可能是中间人攻击。',
previousFingerprint: '此前固定的指纹',
observedFingerprint: '本次看到的指纹',
verifyOutOfBand:
'请通过管理员或其他可信渠道核对 SHA-256 指纹。不要仅因为地址看起来正确就接受。',
confirmedOutOfBand: '我已通过可信渠道核对并确认本次指纹'
},
testResult:
'已连接 · {{platform}}/{{architecture}} · {{latency}} 毫秒',
environment: {
title: '远程运行环境',
description:
'只读检查当前 Host 已登记版本;打开远程项目时才会安装或更新。',
loading: '正在读取 Agent 和 Runtime 版本…',
refresh: '刷新版本',
refreshNamed: '刷新 {{name}} 的远程运行环境版本',
installed: 'Host 已安装',
expected: 'GoodBuddy 所需',
notInstalled: '尚未安装',
versionDetail: 'Linux · {{architecture}}',
states: {
current: '当前版本',
'update-available': '待更新',
'not-installed': '未安装'
}
},
wizard: {
eyebrow: 'SSH 主机验证',
createTitle: '添加并验证 SSH 主机',
editTitle: '编辑并重新验证 SSH 主机',
description:
'依次确认连接信息、主机身份和认证。此流程只执行固定系统探针,不会安装远程 Agent。',
progress: '第 {{current}} / {{total}} 步',
stepsLabel: 'SSH 主机验证步骤',
steps: {
details: '连接信息',
hostKey: '主机身份',
authentication: '认证探测',
success: '完成'
},
details: {
title: '填写连接信息',
description:
'下一步只建立认证前 SSH 握手并读取 Host Key,不会发送密码或使用 SSH Agent 签名。',
passwordUnavailable:
'系统安全存储不可用,因此当前只能选择系统 SSH Agent。'
},
authentication: {
title: '验证认证和远端系统',
description:
'GoodBuddy 将只信任刚刚核对的 Host Key,并运行固定、有超时和输出上限的系统探针。',
agentHelp:
'将使用当前系统 SSH Agent。GoodBuddy 不启用 Agent Forwarding,也不会把 Agent 转发到远端。',
testingTitle: '正在认证并探测远端系统…',
testingDescription:
'主机尚未保存。认证失败后可修改凭据并重试。'
},
success: {
title: '主机已验证并保存',
description:
'“{{name}}”的 Host Key、认证凭据和连接信息已原子保存。',
system: '远端系统',
latency: '连接耗时',
shell: 'Shell',
home: 'Home 目录'
}
},
removeMessage:
'删除“{{name}}”会清除本机保存的连接信息和加密凭据,不会删除远端文件。',
actions: {
add: '添加主机',
edit: '编辑',
validate: '验证并保存',
editNamed: '编辑 {{name}}',
validateNamed: '验证 {{name}}',
inspecting: '正在检查…',
remove: '删除',
confirmRemove: '确认删除',
cancel: '取消',
closeDialog: '关闭 SSH 主机验证',
inspectAndContinue: '检查 Host Key',
back: '上一步',
continueToAuthentication: '确认身份并继续',
trustChangedAndContinue: '确认替换并继续',
validateAndSave: '验证并保存',
validating: '正在验证…',
done: '完成'
},
validation: {
nameRequired: '请输入主机名称',
hostnameRequired: '请输入主机地址',
portInvalid: '端口必须是 1 到 65535 之间的整数',
usernameRequired: '请输入用户名',
confirmFingerprint: '请先通过可信渠道核对并确认 Host Key 指纹',
passwordRequired: '请输入 SSH 密码',
passwordStorageRequired:
'系统安全存储不可用,无法为此配置保存 SSH 密码;请选择系统 SSH Agent'
},
errors: {
unavailable: '当前版本未提供 SSH 主机设置服务',
readFailed: '读取 SSH 主机失败',
inspectFailed: '检查 SSH 主机密钥失败',
validationFailed: 'SSH 认证或远端系统探测失败',
removeFailed: '删除 SSH 主机失败',
environmentUnavailable: '远程运行环境版本服务不可用',
environmentReadFailed: '读取远程运行环境版本失败'
},
notifications: {
removed: 'SSH 主机“{{name}}”已删除'
}
},
speech: {
title: '语音模型',
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
@@ -11,9 +11,11 @@ export const workspace = {
selector: {
ariaLabel: '当前项目',
userProjects: '本地项目',
remoteProjects: '远程项目',
channelProjects: '远程通道',
empty: '未选择项目',
localDetail: '本地目录 · {{path}}',
managedSshDetail: '托管 SSH · {{path}}',
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
remoteChannel: '远程通道',
create: '新建项目',
@@ -28,6 +30,7 @@ export const workspace = {
name: '名称',
description: '说明',
rootPath: '根目录',
executionSpace: '执行空间',
defaultMode: '默认模式',
defaultRuntime: '新对话默认 Runtime'
},
@@ -63,6 +66,68 @@ export const workspace = {
selectRoot: '选择项目根目录失败',
archive: '归档项目失败',
delete: '删除项目失败'
},
remote: {
executionSpaces: {
local: '本机',
ssh: '托管 SSH'
},
executionSpaceFixed:
'已有项目不能在本机与托管 SSH 之间直接切换。',
fields: {
host: 'SSH 主机',
root: '远端工作目录'
},
loadingHosts: '正在读取已保存主机…',
noHosts: '没有已保存的 SSH 主机',
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。',
rootHelp: '请输入或选择以 / 开头的规范绝对路径。',
directoryPicker: {
browse: '浏览远端工作目录',
title: '选择远端工作目录',
close: '关闭远端目录选择器',
currentPath: '当前目录',
parent: '返回上级目录',
refresh: '刷新当前目录',
directory: '打开目录 {{name}}',
loading: '正在读取远端目录…',
empty: '当前目录中没有子目录。',
loadError: '读取远端目录失败:{{message}}',
unknownError: '请检查 SSH 主机连接后重试。',
cancel: '取消',
select: '选择此目录'
},
runtimeHelp:
'托管 SSH 项目仅使用 OpenCode Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和 Runtime。',
actions: {
save: '保存远程项目',
saving: '正在保存远程项目…'
},
activation: {
title: '正在连接远程项目“{{project}}”',
progressLabel: '远程项目连接进度',
stepsLabel: '远程项目连接阶段',
cancel: '取消连接',
cancelling: '正在取消远程连接…',
cancellingAction: '取消中…'
},
phaseStatus: '当前阶段:{{phase}}',
phases: {
host: 'SSH 主机',
agent: '远端 Agent',
workspace: '远端工作区',
runtime: 'OpenCode Runtime',
saving: '项目设置'
},
validation: {
host: '请选择一个已保存的 SSH 主机。',
root: '远端工作目录必须是以 / 开头的绝对路径。'
},
errors: {
hostsUnavailable: 'SSH 主机服务不可用。',
loadHosts: '读取 SSH 主机失败。',
save: '保存远程项目失败。'
}
}
},
sidebar: {
+4
View File
@@ -11,6 +11,10 @@ const project: AssistantProject = {
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
+4
View File
@@ -25,6 +25,10 @@ export const settingsCategoryList = [
id: 'runtime',
translationKey: 'runtime'
},
{
id: 'ssh-hosts',
translationKey: 'sshHosts'
},
{
id: 'security',
translationKey: 'security'
+8
View File
@@ -0,0 +1,8 @@
export function sshHostErrorMessage(
reason: unknown,
fallback: string
): string {
return reason instanceof Error && reason.message.trim()
? reason.message
: fallback
}
File diff suppressed because it is too large Load Diff