Skip to content

Commit ddb9ca6

Browse files
committed
feat(mothership): simplify model controls and add native desktop files
1 parent d15d96a commit ddb9ca6

62 files changed

Lines changed: 1981 additions & 392 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test'
7+
import type { DesktopLocalFileRequest, SimDesktopApi } from '@sim/desktop-bridge'
8+
9+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
10+
11+
test('native file tools read and import through the installed preload without Sim folder grants', async () => {
12+
const root = mkdtempSync(join(tmpdir(), 'sim-native-files-e2e-'))
13+
const source = join(root, 'Reports')
14+
mkdirSync(join(source, 'empty'), { recursive: true })
15+
writeFileSync(join(source, 'report.txt'), 'native file contents')
16+
const png =
17+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII='
18+
writeFileSync(join(source, 'image.png'), Buffer.from(png, 'base64'))
19+
let claimed = false
20+
const calls: Record<string, { toolName: string; args: Record<string, unknown> }> = {
21+
text: { toolName: 'read_local_file', args: { path: join(source, 'report.txt') } },
22+
image: { toolName: 'read_local_file', args: { path: join(source, 'image.png') } },
23+
import: {
24+
toolName: 'import_local_files',
25+
args: { path: source, targetWorkspaceId: 'target-workspace' },
26+
},
27+
}
28+
let server: Server | undefined
29+
let app: ElectronApplication | undefined
30+
try {
31+
server = createServer(async (request, response) => {
32+
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
33+
if (path === '/api/auth/get-session') {
34+
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
35+
JSON.stringify({
36+
user: { id: 'local-file-user' },
37+
session: { id: 'local-file-session' },
38+
})
39+
)
40+
return
41+
}
42+
if (path === '/api/desktop/tool/authorize') {
43+
let body = ''
44+
for await (const chunk of request) body += chunk.toString()
45+
const input = JSON.parse(body)
46+
const call = calls[input.toolCallId]
47+
if (!call || (input.claim && claimed)) {
48+
response.writeHead(call ? 409 : 403, { 'Content-Type': 'application/json' }).end('{}')
49+
return
50+
}
51+
if (input.claim) claimed = true
52+
response
53+
.writeHead(200, { 'Content-Type': 'application/json' })
54+
.end(JSON.stringify({ ...call, chatId: 'org-chat' }))
55+
return
56+
}
57+
response
58+
.writeHead(200, {
59+
'Content-Type': 'text/html',
60+
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
61+
})
62+
.end('<!doctype html><title>Local file fixture</title><h1>Local files</h1>')
63+
})
64+
await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
65+
const address = server.address()
66+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
67+
app = await electron.launch({
68+
args: ['.'],
69+
cwd: DESKTOP_DIR,
70+
env: {
71+
...process.env,
72+
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
73+
SIM_DESKTOP_USER_DATA: join(root, 'profile'),
74+
},
75+
})
76+
const window = await app.firstWindow()
77+
await expect(window.getByRole('heading')).toHaveText('Local files')
78+
const invoke = (input: DesktopLocalFileRequest) =>
79+
window.evaluate(async (request) => {
80+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
81+
if (!api.localFiles) throw new Error('Native file bridge missing')
82+
return api.localFiles(request)
83+
}, input)
84+
await expect
85+
.poll(async () => (await invoke({ operation: 'read', toolCallId: 'text' })).ok)
86+
.toBe(true)
87+
expect(await invoke({ operation: 'read', toolCallId: 'text' })).toMatchObject({
88+
ok: true,
89+
data: { representation: 'text', text: 'native file contents' },
90+
})
91+
expect(await invoke({ operation: 'read', toolCallId: 'image' })).toMatchObject({
92+
ok: true,
93+
data: { observations: [{ mediaType: 'image/png', data: png }] },
94+
})
95+
const result = await invoke({ operation: 'manifest', toolCallId: 'import' })
96+
if (!result.ok || result.data.kind !== 'manifest') throw new Error(JSON.stringify(result))
97+
expect(result.data.targetWorkspaceId).toBe('target-workspace')
98+
expect(result.data.entries.map((entry) => entry.relativePath)).toEqual([
99+
'',
100+
'empty',
101+
'image.png',
102+
'report.txt',
103+
])
104+
const file = result.data.entries.find((entry) => entry.relativePath === 'report.txt')
105+
if (!file) throw new Error('Missing import file')
106+
const chunk = await invoke({
107+
operation: 'chunk',
108+
toolCallId: 'import',
109+
relativePath: file.relativePath,
110+
revision: file.revision,
111+
offset: 0,
112+
})
113+
if (!chunk.ok || chunk.data.kind !== 'chunk') throw new Error(JSON.stringify(chunk))
114+
expect(Object.values(chunk.data.bytes)).toEqual([...Buffer.from('native file contents')])
115+
expect(chunk.data.eof).toBe(true)
116+
expect(
117+
await window.evaluate(
118+
async (input) => {
119+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
120+
const response = await api.localFiles?.(input)
121+
if (!response?.ok || response.data.kind !== 'chunk')
122+
throw new Error('Missing native chunk')
123+
const file = new File([new Uint8Array(response.data.bytes)], 'report.txt')
124+
return file.text()
125+
},
126+
{
127+
operation: 'chunk' as const,
128+
toolCallId: 'import',
129+
relativePath: file.relativePath,
130+
revision: file.revision,
131+
offset: 0,
132+
}
133+
)
134+
).toBe('native file contents')
135+
expect(await invoke({ operation: 'manifest', toolCallId: 'import' })).toMatchObject({
136+
ok: false,
137+
code: 'ALREADY_STARTED',
138+
})
139+
} finally {
140+
await app?.close()
141+
server?.close()
142+
rmSync(root, { recursive: true, force: true })
143+
}
144+
})

‎apps/desktop/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"@xterm/headless": "6.0.0",
4343
"electron-updater": "6.8.9",
4444
"micromatch": "4.0.8",
45-
"safe-regex2": "5.1.0"
45+
"safe-regex2": "5.1.0",
46+
"pdf-lib": "1.17.1"
4647
},
4748
"devDependencies": {
4849
"@electron/fuses": "1.8.0",

‎apps/desktop/src/main/ipc.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,71 @@ describe('registerIpcHandlers', () => {
507507
})
508508
})
509509

510+
it('reads a native file through canonical IPC arguments without folder grants or user activation', async () => {
511+
const { invoke } = collectHandlers()
512+
const handler = invoke.get('desktop:local-files')
513+
const path = fileURLToPath(import.meta.url)
514+
const fetchAuthorization = vi.fn(async () =>
515+
Response.json({ chatId: 'chat-1', toolName: 'read_local_file', args: { path, limit: 64 } })
516+
)
517+
const authorizedEvent = {
518+
senderFrame: { url: `${APP}/o/org/home` },
519+
sender: { session: { fetch: fetchAuthorization } },
520+
}
521+
const mounts = vi.spyOn(deps.localFilesystem, 'handle')
522+
expect(
523+
await handler?.(authorizedEvent, {
524+
operation: 'read',
525+
toolCallId: 'tool-native',
526+
path: '/not/the/canonical/path',
527+
})
528+
).toMatchObject({
529+
ok: true,
530+
data: { kind: 'read', path, text: readFileSync(path, 'utf8').slice(0, 64) },
531+
})
532+
expect(mounts).not.toHaveBeenCalled()
533+
expect(fetchAuthorization).toHaveBeenCalledWith(
534+
`${APP}/api/desktop/tool/authorize`,
535+
expect.objectContaining({ body: JSON.stringify({ toolCallId: 'tool-native' }) })
536+
)
537+
expect(
538+
await handler?.(evilEvent, { operation: 'read', toolCallId: 'tool-native' })
539+
).toMatchObject({ ok: false })
540+
})
541+
542+
it('claims native imports at IPC before traversal and rejects a replay', async () => {
543+
const { invoke } = collectHandlers()
544+
const handler = invoke.get('desktop:local-files')
545+
const fetchAuthorization = vi
546+
.fn()
547+
.mockResolvedValueOnce(
548+
Response.json({
549+
chatId: 'chat-1',
550+
toolName: 'import_local_files',
551+
args: { path: fileURLToPath(import.meta.url), targetWorkspaceId: 'workspace' },
552+
})
553+
)
554+
.mockResolvedValueOnce(Response.json({ error: 'already started' }, { status: 409 }))
555+
const event = {
556+
senderFrame: { url: `${APP}/o/org/home` },
557+
sender: { session: { fetch: fetchAuthorization } },
558+
}
559+
const request = { operation: 'manifest', toolCallId: 'tool-import' }
560+
expect(await handler?.(event, request)).toMatchObject({
561+
ok: true,
562+
data: {
563+
kind: 'manifest',
564+
targetWorkspaceId: 'workspace',
565+
entries: [{ relativePath: '', kind: 'file' }],
566+
},
567+
})
568+
expect(fetchAuthorization).toHaveBeenCalledWith(
569+
`${APP}/api/desktop/tool/authorize`,
570+
expect.objectContaining({ body: JSON.stringify({ toolCallId: 'tool-import', claim: true }) })
571+
)
572+
expect(await handler?.(event, request)).toMatchObject({ ok: false, code: 'ALREADY_STARTED' })
573+
})
574+
510575
it('requires server authorization for every privileged filesystem tool request', async () => {
511576
const { invoke } = collectHandlers()
512577
const handler = invoke.get('desktop:local-filesystem')

‎apps/desktop/src/main/ipc.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { isSafeInternalPath } from '@/main/config'
8181
import type { DesktopSettingsService } from '@/main/desktop-settings'
8282
import { isDesktopPreferenceKey } from '@/main/desktop-settings'
8383
import { hasRecentDeliberateInput, hasRecentDiscreteInput } from '@/main/input-activity'
84+
import { executeLocalFileRequest } from '@/main/local-files'
8485
import type { LocalFilesystemService } from '@/main/local-filesystem'
8586
import { isAppOrigin, openExternalSafe } from '@/main/navigation'
8687
import type { ScopedEventRouter } from '@/main/scoped-event-router'
@@ -507,7 +508,9 @@ interface DesktopToolAuthorization {
507508
async function fetchDesktopToolAuthorization(
508509
event: IpcMainInvokeEvent,
509510
deps: IpcDeps,
510-
toolCallId: unknown
511+
toolCallId: unknown,
512+
claim = false,
513+
onFailureStatus?: (status: number) => void
511514
): Promise<DesktopToolAuthorization | null> {
512515
if (!isDesktopToolCallId(toolCallId)) return null
513516
const startedAt = Date.now()
@@ -518,11 +521,12 @@ async function fetchDesktopToolAuthorization(
518521
method: 'POST',
519522
credentials: 'include',
520523
headers: { 'Content-Type': 'application/json' },
521-
body: JSON.stringify({ toolCallId }),
524+
body: JSON.stringify({ toolCallId, ...(claim ? { claim: true } : {}) }),
522525
signal: AbortSignal.timeout(BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS),
523526
}
524527
)
525528
if (!response.ok) {
529+
onFailureStatus?.(response.status)
526530
logger.warn('Desktop tool authorization was rejected', {
527531
toolCallId,
528532
status: response.status,
@@ -712,6 +716,15 @@ export function registerIpcHandlers(deps: IpcDeps): void {
712716
return deps.beginOAuthConnect(providerId, parsedScope)
713717
},
714718
},
719+
'desktop:local-files': {
720+
kind: 'invoke',
721+
gate: 'app-origin',
722+
requiresAccountData: true,
723+
passSender: true,
724+
denied: { ok: false, error: 'Local file tools are unavailable from this page.' },
725+
handler: (_sender, request, authorization) =>
726+
executeLocalFileRequest(request, authorization as DesktopToolAuthorization),
727+
},
715728
'desktop:local-filesystem': {
716729
kind: 'invoke',
717730
gate: 'app-origin',
@@ -2059,6 +2072,32 @@ export function registerIpcHandlers(deps: IpcDeps): void {
20592072
error: 'This local filesystem request is not an authorized pending Copilot tool call.',
20602073
}
20612074
}
2075+
if (channel === 'desktop:local-files') {
2076+
const request = args[0]
2077+
if (!isRecordLike(request)) return { ok: false, error: 'Invalid local file request.' }
2078+
let failureStatus: number | undefined
2079+
const authorization = await fetchDesktopToolAuthorization(
2080+
event,
2081+
deps,
2082+
request.toolCallId,
2083+
request.operation === 'manifest',
2084+
(status) => {
2085+
failureStatus = status
2086+
}
2087+
)
2088+
if (failureStatus === 409)
2089+
return {
2090+
ok: false,
2091+
code: 'ALREADY_STARTED',
2092+
error: 'This import is already running or was already started.',
2093+
}
2094+
if (
2095+
!authorization ||
2096+
!['read_local_file', 'import_local_files'].includes(authorization.toolName)
2097+
)
2098+
return { ok: false, error: 'This is not an authorized pending local file tool call.' }
2099+
handlerArgs = [request, authorization]
2100+
}
20622101
if (spec.passSender) {
20632102
handlerArgs = [event.sender, ...handlerArgs]
20642103
}

0 commit comments

Comments
 (0)