456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { DesktopApi } from '../shared/contracts'
|
|
import { ipcChannels } from '../shared/ipc-channels'
|
|
|
|
const electron = vi.hoisted(() => ({
|
|
contextBridge: { exposeInMainWorld: vi.fn() },
|
|
ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() },
|
|
webUtils: {}
|
|
}))
|
|
vi.mock('electron', () => electron)
|
|
|
|
describe('sandboxed preload', () => {
|
|
it('forwards status deltas without the Electron event and removes the exact subscription', async () => {
|
|
await import('./index')
|
|
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
|
const listener = vi.fn()
|
|
const unsubscribe = api.channels!.onStatusChanged(listener)
|
|
const [channel, handler] = electron.ipcRenderer.on.mock.calls.at(-1)!
|
|
expect(channel).toBe(ipcChannels.channelStatusChanged)
|
|
const change = { channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } }
|
|
handler({ sender: 'main' }, change)
|
|
expect(listener).toHaveBeenCalledExactlyOnceWith(change)
|
|
unsubscribe()
|
|
expect(electron.ipcRenderer.removeListener).toHaveBeenCalledExactlyOnceWith(channel, handler)
|
|
})
|
|
|
|
it('forwards Telegram settings and draft or saved connection tests through channel IPC', async () => {
|
|
await import('./index')
|
|
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
|
const settings = {
|
|
enabled: false, secret: { action: 'replace' as const, value: '456:test-token' },
|
|
allowedSenderIds: ['123'], allowGroupMessages: false as const
|
|
}
|
|
await api.channels!.getSnapshot()
|
|
await api.channels!.apply({ telegram: settings })
|
|
await api.channels!.testConnection('telegram', settings)
|
|
await api.channels!.testConnection('telegram')
|
|
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
|
|
[ipcChannels.channelSettingsGet],
|
|
[ipcChannels.channelSettingsApply, { telegram: settings }],
|
|
[ipcChannels.channelSettingsTest, { channel: 'telegram', settings }],
|
|
[ipcChannels.channelSettingsTest, { channel: 'telegram', settings: undefined }]
|
|
])
|
|
})
|
|
|
|
it('does not import Node built-ins unavailable in Electron sandbox', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).not.toMatch(/\bfrom\s+['"]node:/u)
|
|
expect(source).not.toMatch(/\brequire\(\s*['"]node:/u)
|
|
})
|
|
|
|
it('does not load runtime schema libraries in the Electron sandbox', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).not.toMatch(/\b\w+Schema\b/u)
|
|
expect(source).not.toMatch(/\bfrom\s+['"]zod['"]/u)
|
|
})
|
|
|
|
it('exposes only explicit clipboard text reading and writing', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
const clipboard =
|
|
source.match(
|
|
/clipboard: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}agent:/u
|
|
)?.groups?.body ?? ''
|
|
expect(clipboard).toContain('readText: ()')
|
|
expect(clipboard).toContain('ipcChannels.clipboardReadText')
|
|
expect(clipboard).toContain('writeText: async (text: string)')
|
|
expect(clipboard).toContain('ipcChannels.clipboardWriteText')
|
|
expect(clipboard).not.toMatch(/\b(?:image|html|rtf|bookmark)\b/iu)
|
|
})
|
|
|
|
it('exposes only narrow browser-control methods', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
const browser =
|
|
source.match(
|
|
/browser: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}terminal:/u
|
|
)?.groups?.body ?? ''
|
|
for (const method of [
|
|
'createTab:',
|
|
'listTabs:',
|
|
'closeTab:',
|
|
'navigate:',
|
|
'back:',
|
|
'reload:',
|
|
'stopLoading:',
|
|
'snapshot:',
|
|
'click:',
|
|
'type:',
|
|
'select:',
|
|
'screenshot:',
|
|
'setViewport:',
|
|
'stop:',
|
|
'onState:'
|
|
]) {
|
|
expect(browser).toContain(method)
|
|
}
|
|
for (const channel of [
|
|
'browserCreateTab',
|
|
'browserListTabs',
|
|
'browserCloseTab',
|
|
'browserSnapshot',
|
|
'browserClick',
|
|
'browserType',
|
|
'browserSelect',
|
|
'browserScreenshot',
|
|
'browserSetViewport'
|
|
]) {
|
|
expect(browser).toContain(`ipcChannels.${channel}`)
|
|
}
|
|
expect(browser).toContain('requestOrConversation: BrowserNavigateRequest')
|
|
expect(browser).toContain('setViewport: async (request: BrowserSetViewportRequest)')
|
|
expect(browser).not.toContain('leaseId')
|
|
expect(browser).not.toMatch(
|
|
/\b(?:webContents|debugger|session|partition|cookie)\b/iu
|
|
)
|
|
})
|
|
|
|
it('exposes bounded local tool environment operations without execution inputs', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
const localTools =
|
|
source.match(
|
|
/localToolEnvironment: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}feedback:/u
|
|
)?.groups?.body ?? ''
|
|
for (const method of [
|
|
'getSnapshot:',
|
|
'updateSettings:',
|
|
'refreshCandidates:',
|
|
'selectExecutable:',
|
|
'diagnose:',
|
|
'installPython:',
|
|
'cancelPython:',
|
|
'removePython:',
|
|
'onProgress:'
|
|
]) {
|
|
expect(localTools).toContain(method)
|
|
}
|
|
expect(localTools).not.toMatch(
|
|
/\b(?:command|env|url|hash|installDirectory|shim)\b/iu
|
|
)
|
|
expect(localTools).toContain('ipcRenderer.removeListener(')
|
|
})
|
|
|
|
it('exposes only explicit computer capability and managed profile IPC methods', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('setComputerCapabilityEnabled:')
|
|
expect(source).toContain('diagnoseComputerCapability:')
|
|
expect(source).toContain('createBrowserProfile:')
|
|
expect(source).toContain('renameBrowserProfile:')
|
|
expect(source).toContain('setDefaultBrowserProfile:')
|
|
expect(source).toContain('removeBrowserProfile:')
|
|
expect(source).not.toMatch(
|
|
/(?:setComputerCapability|BrowserProfile).{0,80}(?:executablePath|command|env|args)/su
|
|
)
|
|
})
|
|
|
|
it('exposes model ZIP dialogs without renderer-controlled paths', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('importArchive: (modelId: string)')
|
|
expect(source).toContain('exportArchive: (modelId: string)')
|
|
expect(source).toContain('importOcrModelArchive: (modelId: string)')
|
|
expect(source).toContain('exportOcrModelArchive: (modelId: string)')
|
|
expect(source).toContain('importModelArchive: (modelId: string)')
|
|
expect(source).toContain('ipcChannels.embeddingModelsImportArchive')
|
|
expect(source).not.toContain('importLocalDirectory:')
|
|
expect(source).not.toContain('importOcrModel:')
|
|
expect(source).not.toMatch(
|
|
/importModelArchive:\s*\([^)]*(?:path|directory|archivePath)/u
|
|
)
|
|
})
|
|
|
|
it('exposes explicit embedding settings operations without vectors or credentials', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
const embeddings =
|
|
source.match(
|
|
/embeddings: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}documentParsing:/u
|
|
)?.groups?.body ?? ''
|
|
expect(embeddings).toContain('getSnapshot:')
|
|
expect(embeddings).toContain('getModelProgress:')
|
|
expect(embeddings).toContain('diagnose: (connectionId: string)')
|
|
expect(embeddings).toContain('setCurrent: (connectionId: string)')
|
|
expect(embeddings).toContain('installModel:')
|
|
expect(embeddings).toContain('cancelModelOperation:')
|
|
expect(embeddings).toContain('removeModel:')
|
|
expect(embeddings).not.toMatch(/\b(?:apiKey|credential|vector)\b/iu)
|
|
})
|
|
|
|
it('passes an explicit parsing scenario to document diagnostics', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain(
|
|
'test: (purpose: DocumentParsingTestPurpose, operationId?: string)'
|
|
)
|
|
expect(source).toMatch(
|
|
/ipcChannels\.documentParsingTest,\r?\n {8}\{ purpose, \.\.\.\(operationId/u
|
|
)
|
|
})
|
|
|
|
it('exposes a narrow OCR operation progress snapshot', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('getOcrModelProgress:')
|
|
expect(source).toContain('ipcChannels.documentOcrModelsProgress')
|
|
})
|
|
|
|
it('exposes a removable attachment parsing progress listener', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('onFileSelectionProgress:')
|
|
expect(source).toContain('contextFileSelectionProgress')
|
|
expect(source).toContain('ipcRenderer.removeListener(')
|
|
})
|
|
|
|
it('exposes only bounded release-note actions', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('releaseNotes: {')
|
|
expect(source).toContain('getPending:')
|
|
expect(source).toContain('acknowledge: async (version: string)')
|
|
expect(source).toContain('ipcChannels.releaseNotesGetPending')
|
|
expect(source).toContain('ipcChannels.releaseNotesAcknowledge')
|
|
})
|
|
|
|
it('exposes only the narrow feedback submission method', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
const feedback =
|
|
source.match(
|
|
/feedback: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}shortcuts:/u
|
|
)?.groups?.body ?? ''
|
|
expect(feedback).toContain('submit: (input: FeedbackSubmitInput)')
|
|
expect(feedback).toContain('ipcChannels.feedbackSubmit')
|
|
expect(feedback).not.toMatch(
|
|
/\b(?:url|endpoint|header|cookie|authorization|path)\b/iu
|
|
)
|
|
})
|
|
|
|
it('exposes only get and validated-update shortcut settings methods', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('shortcuts: {')
|
|
expect(source).toContain('ipcChannels.shortcutSettingsGet')
|
|
expect(source).toContain('ipcChannels.shortcutSettingsUpdate')
|
|
expect(source).not.toContain('globalShortcut.')
|
|
})
|
|
|
|
it('exposes only explicit SSH host-management methods', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('sshHosts: {')
|
|
expect(source).toContain('getSnapshot:')
|
|
expect(source).toContain('onAgentConnectionStatus:')
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsAgentConnectionStatus'
|
|
)
|
|
expect(source).toContain(
|
|
'getAgentPackageInventory: (refresh = false, localOnly = false)'
|
|
)
|
|
expect(source).toContain('downloadAgentPackage:')
|
|
expect(source).toContain('importAgentPackage:')
|
|
expect(source).toContain('exportAgentPackage: async')
|
|
expect(source).toContain('onAgentPackageProgress:')
|
|
expect(source).toContain('inspectDraftHostKey:')
|
|
expect(source).toContain('discardCandidate:')
|
|
expect(source).toContain('validateAndSave:')
|
|
expect(source).toContain(
|
|
'browseDirectories: (hostId: string, path?: string)'
|
|
)
|
|
expect(source).toContain('cancelDirectoryBrowse: async ()')
|
|
expect(source).toContain('getRemoteEnvironment: (hostId: string)')
|
|
expect(source).toContain(
|
|
'input: RemoteEnvironmentUpdateRequest'
|
|
)
|
|
expect(source).toContain(
|
|
'cancelRemoteEnvironmentUpdate: async (hostId: string)'
|
|
)
|
|
expect(source).toContain(
|
|
'onRemoteEnvironmentUpdateProgress:'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsRemoteEnvironment'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsAgentPackageInventory'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsAgentPackageDownload'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsAgentPackageImport'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsAgentPackageExport'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsAgentPackageProgress'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsUpdateRemoteEnvironment'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsCancelRemoteEnvironmentUpdate'
|
|
)
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsRemoteEnvironmentUpdateProgress'
|
|
)
|
|
expect(source).toContain('ipcChannels.sshHostsBrowseDirectories')
|
|
expect(source).toContain(
|
|
'ipcChannels.sshHostsCancelDirectoryBrowse'
|
|
)
|
|
expect(source).not.toContain('sshHostsCreate')
|
|
expect(source).not.toContain('sshHostsAcceptKey')
|
|
expect(source).not.toContain('privateKey:')
|
|
expect(source).not.toContain('agentForward: true')
|
|
const sshHostsSource =
|
|
source.match(
|
|
/sshHosts: \{(?<body>[\s\S]*?)\n {2}\},\n {2}channels:/u
|
|
)
|
|
?.groups?.body ?? ''
|
|
expect(sshHostsSource).not.toMatch(
|
|
/\b(?:credential|password|revision|sftp|shell|command)\b/iu
|
|
)
|
|
expect(sshHostsSource).not.toMatch(
|
|
/\b(?:detail|phaseName|installationId)\b/iu
|
|
)
|
|
})
|
|
|
|
it('exposes only bounded terminal session operations', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
const terminal =
|
|
source.match(
|
|
/terminal: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}settings:/u
|
|
)?.groups?.body ?? ''
|
|
expect(terminal).toContain('create:')
|
|
expect(terminal).toContain('write:')
|
|
expect(terminal).toContain('resize:')
|
|
expect(terminal).toContain('close:')
|
|
expect(terminal).toContain('getSnapshot:')
|
|
expect(terminal).toContain('ack:')
|
|
expect(terminal).toContain('onEvent:')
|
|
expect(terminal).toContain('ipcRenderer.removeListener(')
|
|
expect(terminal).not.toMatch(
|
|
/\b(?:shell|cwd|command|environment|password|privateKey|pid)\b/u
|
|
)
|
|
})
|
|
|
|
it('exposes narrow awaited remote project activation and save operations', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('remote: {')
|
|
expect(source).toContain('remoteProjectSave')
|
|
expect(source).toContain('remoteProjectCancelCurrent')
|
|
expect(source).toContain('remoteProjectSaveProgress')
|
|
expect(source).toContain('RemoteProjectSaveRequest')
|
|
expect(source).not.toContain('commitToken')
|
|
expect(source).toContain('ipcRenderer.removeListener(')
|
|
})
|
|
|
|
it('exposes removable project recovery snapshot, retry, and progress APIs', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('getRecoverySnapshot:')
|
|
expect(source).toContain('retryRecovery: (projectId: string)')
|
|
expect(source).toContain('onRecoveryProgress:')
|
|
expect(source).toContain('ipcChannels.remoteProjectRecoveryGet')
|
|
expect(source).toContain('ipcChannels.remoteProjectRecoveryRetry')
|
|
expect(source).toContain(
|
|
'ipcChannels.remoteProjectRecoveryProgress'
|
|
)
|
|
expect(source).toMatch(
|
|
/remoteProjectRecoveryProgress,[\s\S]*?removeListener\([\s\S]*?remoteProjectRecoveryProgress/u
|
|
)
|
|
})
|
|
|
|
it('exposes bounded knowledge task actions', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('cancelTask: (taskId: string)')
|
|
expect(source).toContain('retryTask: async (taskId: string)')
|
|
expect(source).toContain('ipcChannels.knowledgeTaskCancel')
|
|
expect(source).toContain('ipcChannels.knowledgeTaskRetry')
|
|
})
|
|
|
|
it('exposes only explicit Conversation queue operations and listeners', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain('conversationQueue: {')
|
|
expect(source).toContain('ipcChannels.conversationQueueEnqueueUser')
|
|
expect(source).toContain(
|
|
'ipcChannels.conversationQueueInterruptAndRun'
|
|
)
|
|
expect(source).toContain('ipcChannels.conversationQueueDispatch')
|
|
expect(source).toContain('listener(conversationId)')
|
|
})
|
|
|
|
it('exposes a narrow local conversation branch operation', () => {
|
|
const source = readFileSync(
|
|
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
|
'utf8'
|
|
)
|
|
expect(source).toContain(
|
|
'branchLocal: (input: ConversationBranchInput)'
|
|
)
|
|
expect(source).toContain('ipcChannels.conversationsBranchLocal')
|
|
})
|
|
})
|