feat: gate remote projects as a technical preview

Remote Host and managed SSH project entry points were always exposed while the signed artifact workflow remains preview-stage. Remote Projects now defaults off behind its own Platform Features tab, preserves saved Hosts, projects, and credentials, and returns active remote work to a local project when disabled.

Main IPC enforces the same setting while still allowing in-flight cancellation. Application settings migrate to version 9 with the preview disabled.

Release note: 新增“远程项目(技术预览)”独立页签和开关,默认关闭;启用后显示 SSH Host 与远程项目功能,关闭不会删除已有配置。
This commit is contained in:
mesalogo
2026-08-25 00:16:02 +08:00
parent 607bb33249
commit 6c74f057eb
23 changed files with 1267 additions and 115 deletions
+268 -15
View File
@@ -1086,7 +1086,9 @@ describe('registerIpcHandlers SSH hosts', () => {
undefined, // browserControl
undefined, // subagentService
undefined, // channelSettingsStore
undefined, // applicationSettingsStore
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never, // applicationSettingsStore
undefined, // versionChecker
undefined, // speechModelManager
undefined, // embeddingIndexCoordinator
@@ -1167,11 +1169,11 @@ describe('registerIpcHandlers SSH hosts', () => {
name: 'Build host'
}
})
expect(
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsDiscardCandidate
)?.(event, { candidateId })
).toBeUndefined()
).resolves.toBeUndefined()
expect(sshHostService.discardCandidate).toHaveBeenCalledWith(
candidateId
)
@@ -1184,12 +1186,12 @@ describe('registerIpcHandlers SSH hosts', () => {
listProjectIdsReferencingSshHost.mockReturnValue([
'00000000-0000-4000-8000-000000000107'
])
expect(() =>
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
event,
{ hostId }
)
).toThrow('SSH 主机仍被项目引用,无法删除')
).rejects.toThrow('SSH 主机仍被项目引用,无法删除')
expect(sshHostService.remove).toHaveBeenCalledOnce()
await expect(
@@ -1205,7 +1207,7 @@ describe('registerIpcHandlers SSH hosts', () => {
).rejects.toThrow()
expect(inspectRemoteEnvironment).toHaveBeenCalledOnce()
expect(() =>
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsValidateAndSave
)?.(event, {
@@ -1213,14 +1215,14 @@ describe('registerIpcHandlers SSH hosts', () => {
fingerprintSha256: fingerprint,
input: { ...createInput, command: 'whoami' }
})
).toThrow()
).rejects.toThrow()
expect(sshHostService.validateAndSave).toHaveBeenCalledOnce()
expect(() =>
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({
sender: {},
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(
electronMocks.handlers.get(
@@ -1274,11 +1276,11 @@ describe('registerIpcHandlers SSH hosts', () => {
await vi.waitFor(() =>
expect(browseSignal).toBeInstanceOf(AbortSignal)
)
expect(
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelDirectoryBrowse
)?.(event)
).toBeUndefined()
).resolves.toBeUndefined()
await expect(activeBrowse).rejects.toMatchObject({
name: 'AbortError'
})
@@ -1312,11 +1314,11 @@ describe('registerIpcHandlers SSH hosts', () => {
webContents,
remoteDraft
)
expect(
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectCancelCurrent
)?.(event)
).toBeUndefined()
).resolves.toBeUndefined()
expect(
remoteProjectSaveService.cancelCurrent
).toHaveBeenCalledWith(webContents)
@@ -1349,6 +1351,241 @@ describe('registerIpcHandlers SSH hosts', () => {
expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce()
})
it('blocks disabled and unavailable preview access before remote services', async () => {
const hostId = '00000000-0000-4000-8000-000000000105'
const candidateId = '00000000-0000-4000-8000-000000000106'
const projectId = '00000000-0000-4000-8000-000000000107'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const remoteProject = {
id: projectId,
name: 'Remote project',
description: '',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh' as const,
hostId,
remoteRootPath: '/srv/project'
},
defaultWorkMode: 'ask' as const,
kind: 'user' as const,
status: 'active' as const,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const applicationSettingsStore = {
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
}
const sshHostService = {
getSnapshot: vi.fn(),
remove: vi.fn(),
inspectDraftHostKey: vi.fn(),
discardCandidate: vi.fn(),
validateAndSave: vi.fn()
}
const remoteProjectSaveService = {
activate: vi.fn(),
save: vi.fn(),
cancelCurrent: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const sshHostDirectoryBrowser = {
listDirectories: vi.fn()
}
const sshHostRemoteEnvironmentInspector = {
inspect: vi.fn()
}
const executionSpaceResolver = {
resolveProject: vi.fn()
}
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
listProjectIdsReferencingSshHost: vi.fn(() => []),
getProject: vi.fn(() => remoteProject),
updateProject: vi.fn(),
setProjectArchived: vi.fn(),
deleteProject: vi.fn()
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
applicationSettingsStore as never,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
sshHostService as never,
executionSpaceResolver as never,
remoteProjectSaveService as never,
sshHostDirectoryBrowser as never,
sshHostRemoteEnvironmentInspector as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const createInput = {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
}
const remoteDraft = {
intent: 'create',
draft: {
name: remoteProject.name,
description: '',
runtimeSelection: { provider: 'opencode' },
hostId,
remoteRootPath: '/srv/project',
defaultWorkMode: 'ask'
}
}
const disabledRequests: Array<[string, unknown?]> = [
[ipcChannels.sshHostsGet],
[ipcChannels.sshHostsRemove, { hostId }],
[
ipcChannels.sshHostsInspectDraftKey,
{
hostId,
hostname: createInput.hostname,
port: createInput.port,
username: createInput.username
}
],
[ipcChannels.sshHostsDiscardCandidate, { candidateId }],
[
ipcChannels.sshHostsValidateAndSave,
{ candidateId, fingerprintSha256: fingerprint, input: createInput }
],
[ipcChannels.sshHostsRemoteEnvironment, { hostId }],
[
ipcChannels.sshHostsBrowseDirectories,
{ hostId, path: '/srv' }
],
[ipcChannels.remoteProjectActivate, projectId],
[ipcChannels.remoteProjectSave, remoteDraft]
]
for (const [channel, input] of disabledRequests) {
await expect(
electronMocks.handlers.get(channel)?.(event, input)
).rejects.toThrow('远程项目(技术预览)未启用')
}
await expect(
electronMocks.handlers.get(ipcChannels.projectsUpdate)?.(event, {
projectId,
input: {
name: remoteProject.name,
description: '',
rootPath: remoteProject.rootPath,
defaultWorkMode: 'ask'
}
})
).rejects.toThrow('远程项目(技术预览)未启用')
await expect(
electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.(
event,
{ projectId, archived: true }
)
).rejects.toThrow('远程项目(技术预览)未启用')
await expect(
electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, {
projectId,
confirmation: remoteProject.name
})
).rejects.toThrow('远程项目(技术预览)未启用')
await expect(
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(
event,
{ projectId, path: 'README.md' }
)
).rejects.toThrow('远程项目(技术预览)未启用')
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
expect(sshHostService.remove).not.toHaveBeenCalled()
expect(sshHostService.inspectDraftHostKey).not.toHaveBeenCalled()
expect(sshHostService.discardCandidate).not.toHaveBeenCalled()
expect(sshHostService.validateAndSave).not.toHaveBeenCalled()
expect(
sshHostRemoteEnvironmentInspector.inspect
).not.toHaveBeenCalled()
expect(
sshHostDirectoryBrowser.listDirectories
).not.toHaveBeenCalled()
expect(remoteProjectSaveService.activate).not.toHaveBeenCalled()
expect(remoteProjectSaveService.save).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelDirectoryBrowse
)?.(event)
).resolves.toBeUndefined()
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectCancelCurrent
)?.(event)
).resolves.toBeUndefined()
expect(remoteProjectSaveService.cancelCurrent).toHaveBeenCalledWith(
webContents
)
expect(
assistantDatabase.listProjectIdsReferencingSshHost
).not.toHaveBeenCalled()
expect(assistantDatabase.updateProject).not.toHaveBeenCalled()
expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled()
expect(assistantDatabase.deleteProject).not.toHaveBeenCalled()
expect(executionSpaceResolver.resolveProject).not.toHaveBeenCalled()
applicationSettingsStore.get.mockRejectedValueOnce(
new Error('settings unavailable')
)
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
).rejects.toThrow('远程项目(技术预览)未启用')
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
await dispose()
})
it('sends save progress only to a live owning WebContents', () => {
const owner = {
isDestroyed: vi.fn(() => false),
@@ -3096,7 +3333,16 @@ describe('registerIpcHandlers workspace files', () => {
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
{
get: vi.fn(async () => {
throw new Error('settings unavailable')
})
} as never
)
const event = {
sender: webContents,
@@ -3175,7 +3421,14 @@ describe('registerIpcHandlers workspace files', () => {
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never
)
const event = {
sender: webContents,