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:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user