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
+90 -4
View File
@@ -65,6 +65,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate',
@@ -74,16 +75,18 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 8,
version: 9,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate',
@@ -95,6 +98,29 @@ describe('ApplicationSettingsStore', () => {
).toEqual([])
})
it('persists the Remote Projects preference', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ remoteProjectsEnabled: true })
).resolves.toEqual({
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
await expect(
new ApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 9,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
})
it('creates the parent directory and can reload persisted settings', async () => {
const { directory } = await createStore()
const filePath = join(directory, 'nested', 'application-settings.json')
@@ -110,6 +136,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -134,6 +161,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -158,6 +186,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -182,6 +211,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
@@ -209,10 +239,11 @@ describe('ApplicationSettingsStore', () => {
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 8,
version: 9,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
@@ -240,6 +271,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
@@ -267,6 +299,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
@@ -279,8 +312,9 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 8,
version: 9,
modelDownloadSource: 'hugging-face',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
})
@@ -303,6 +337,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -313,7 +348,53 @@ describe('ApplicationSettingsStore', () => {
)
})
it('lazily migrates version 8 with Remote Projects disabled', async () => {
const { filePath, store } = await createStore()
const versionEight = {
version: 8,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
warnings: [{ code: 'application-settings-recovered' }],
lastSeenReleaseNotesVersion: '0.8.18'
}
await writeFile(filePath, JSON.stringify(versionEight), 'utf8')
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative'
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe(
'0.8.18'
)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
versionEight
)
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 9,
checkUpdatesOnStartup: true,
remoteProjectsEnabled: false
})
})
it('strictly rejects incomplete full settings', () => {
expect(
applicationSettingsSchema.safeParse(defaultApplicationSettings)
.success
).toBe(true)
for (const input of [
{},
{ checkUpdatesOnStartup: 'true' },
@@ -337,6 +418,7 @@ describe('ApplicationSettingsStore', () => {
{},
{ checkUpdatesOnStartup: 'true' },
{ modelDownloadSource: 'automatic' },
{ remoteProjectsEnabled: 'false' },
{ anotherSetting: true },
null
]) {
@@ -358,6 +440,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -473,16 +556,18 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 8,
version: 9,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -509,6 +594,7 @@ describe('ApplicationSettingsStore', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
+37 -2
View File
@@ -20,7 +20,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 8
const CURRENT_SETTINGS_VERSION = 9
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -50,6 +50,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
@@ -61,6 +62,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
updateSource: true,
modelDownloadSource: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
@@ -72,6 +74,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
@@ -82,13 +85,25 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSevenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ magicNotesShowIncompleteTodoCount: true })
.omit({
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
.extend({
version: z.literal(7),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionEightStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ remoteProjectsEnabled: true })
.extend({
version: z.literal(8),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -104,6 +119,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -161,12 +177,23 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionEightResult =
versionEightStoredApplicationSettingsSchema.safeParse(parsed)
if (versionEightResult.success) {
this.settings = {
...versionEightResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false
}
return this.settings
}
const versionSevenResult =
versionSevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSevenResult.success) {
this.settings = {
...versionSevenResult.data,
version: CURRENT_SETTINGS_VERSION,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
}
return this.settings
@@ -178,6 +205,7 @@ export class ApplicationSettingsStore {
...versionSixResult.data,
version: CURRENT_SETTINGS_VERSION,
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
}
return this.settings
@@ -190,6 +218,7 @@ export class ApplicationSettingsStore {
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
}
return this.settings
@@ -202,6 +231,7 @@ export class ApplicationSettingsStore {
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
lastSeenReleaseNotesVersion: null
}
@@ -215,6 +245,7 @@ export class ApplicationSettingsStore {
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
@@ -229,6 +260,7 @@ export class ApplicationSettingsStore {
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -245,6 +277,7 @@ export class ApplicationSettingsStore {
legacyResult.data.checkUpdatesOnStartup,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
@@ -287,6 +320,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
@@ -322,6 +356,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
remoteProjectsEnabled: next.remoteProjectsEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
next.magicNotesShowIncompleteTodoCount,
+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,
+78 -20
View File
@@ -1040,6 +1040,19 @@ export function registerIpcHandlers(
const trackExecution = executionTracker.track
const spaceResolver: ExecutionSpaceResolver =
executionSpaceResolver ?? new ExecutionSpaceResolver()
const requireRemoteProjectsEnabled = async (): Promise<void> => {
try {
if (
(await applicationSettingsStore?.get())
?.remoteProjectsEnabled === true
) {
return
}
} catch {
// Keep the feature closed when its settings cannot be read.
}
throw new Error('远程项目(技术预览)未启用')
}
const registerHandler = (
channel: Parameters<typeof ipcMain.handle>[0],
listener: Parameters<typeof ipcMain.handle>[1],
@@ -1063,10 +1076,14 @@ export function registerIpcHandlers(
followConfiguredAgentRuntime?: boolean
}
): Promise<AgentRuntime> => {
const executionSpace = request.projectId
? spaceResolver.resolveProject(
assistantDatabase.getProject(request.projectId)
)
const project = request.projectId
? assistantDatabase.getProject(request.projectId)
: undefined
if (project?.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = project
? spaceResolver.resolveProject(project)
: request.workspaceOverride?.trim()
? spaceResolver.resolveLocal(request.workspaceOverride.trim())
: undefined
@@ -3648,10 +3665,14 @@ export function registerIpcHandlers(
settings,
request.runtimeSelection
)
const executionSpace = request.projectId
? spaceResolver.resolveProject(
assistantDatabase.getProject(request.projectId)
)
const project = request.projectId
? assistantDatabase.getProject(request.projectId)
: undefined
if (project?.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = project
? spaceResolver.resolveProject(project)
: spaceResolver.resolveLocal(selected.settings.workspacePath)
if (executionSpace.kind === 'ssh') {
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
@@ -3816,10 +3837,14 @@ export function registerIpcHandlers(
? { profileId: request.profileId }
: {})
}
const executionSpace = request.projectId
? spaceResolver.resolveProject(
assistantDatabase.getProject(request.projectId)
)
const project = request.projectId
? assistantDatabase.getProject(request.projectId)
: undefined
if (project?.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = project
? spaceResolver.resolveProject(project)
: spaceResolver.resolveLocal(
(await settingsStore.getResolvedSettings()).workspacePath
)
@@ -4047,8 +4072,9 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.sshHostsGet, (event) => {
registerHandler(ipcChannels.sshHostsGet, async (event) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
@@ -4057,8 +4083,9 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.sshHostsRemove,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
@@ -4074,8 +4101,9 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.sshHostsInspectDraftKey,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
@@ -4087,8 +4115,9 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.sshHostsDiscardCandidate,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
@@ -4100,8 +4129,9 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.sshHostsValidateAndSave,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
@@ -4115,6 +4145,7 @@ export function registerIpcHandlers(
ipcChannels.sshHostsRemoteEnvironment,
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostRemoteEnvironmentInspector) {
throw new Error('SSH 远端运行环境服务不可用')
}
@@ -4129,6 +4160,7 @@ export function registerIpcHandlers(
ipcChannels.sshHostsBrowseDirectories,
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!sshHostDirectoryBrowser) {
throw new Error('SSH 远端目录浏览服务不可用')
}
@@ -4159,7 +4191,7 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.sshHostsCancelDirectoryBrowse,
(event) => {
async (event) => {
assertTrustedSender(event, window)
activeSshDirectoryBrowse?.abort(
new DOMException(
@@ -4972,6 +5004,7 @@ export function registerIpcHandlers(
ipcChannels.remoteProjectActivate,
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!remoteProjectSaveService) {
throw new Error('远程项目验证服务不可用')
}
@@ -4987,6 +5020,7 @@ export function registerIpcHandlers(
ipcChannels.remoteProjectSave,
async (event, input: unknown) => {
assertTrustedSender(event, window)
await requireRemoteProjectsEnabled()
if (!remoteProjectSaveService) {
throw new Error('远程项目验证服务不可用')
}
@@ -5000,7 +5034,7 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.remoteProjectCancelCurrent,
(event) => {
async (event) => {
assertTrustedSender(event, window)
if (!remoteProjectSaveService) {
throw new Error('远程项目验证服务不可用')
@@ -5014,6 +5048,10 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = projectUpdateRequestSchema.parse(input)
const current = assistantDatabase.getProject(value.projectId)
if (current.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const project = assistantDatabase.updateProject(
value.projectId,
value.input
@@ -5025,9 +5063,13 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.projectsSetArchived,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = projectArchiveRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
assistantDatabase.setProjectArchived(
value.projectId,
value.archived
@@ -5039,6 +5081,10 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = projectDeleteRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
assistantDatabase.deleteProject(
value.projectId,
value.confirmation
@@ -5281,6 +5327,9 @@ export function registerIpcHandlers(
const project = assistantDatabase.getProject(
assistantIdSchema.parse(input)
)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
try {
return await getWorkspaceChanges(
@@ -5297,6 +5346,9 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = workspaceDirectoryRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
try {
return await listWorkspaceDirectory(
@@ -5314,6 +5366,9 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = workspaceFileRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
try {
return await readWorkspaceFile(
@@ -5331,6 +5386,9 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = workspaceOpenPathRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
spaceResolver.assertLocal(executionSpace)
let targetPath: string