Skip to content

Commit 4cf15ef

Browse files
committed
fix(desktop): revalidate folder consent and preserve exact identities
1 parent b4cf27c commit 4cf15ef

15 files changed

Lines changed: 290 additions & 70 deletions

‎apps/desktop/e2e/background-executor.spec.ts‎

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
mkdtempSync,
5+
readFileSync,
6+
renameSync,
7+
rmSync,
8+
writeFileSync,
9+
} from 'node:fs'
210
import { tmpdir } from 'node:os'
311
import { join } from 'node:path'
412
import { type ElectronApplication, expect, test } from '@playwright/test'
@@ -203,6 +211,127 @@ test.describe('background executor', () => {
203211
})
204212
})
205213

214+
test('background consent cannot grant access when Sim cannot verify the call', async () => {
215+
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-offline-consent-'))
216+
const launched = await launch(sim, userData)
217+
app = launched.app
218+
const deviceId = await registeredDevice(sim)
219+
const readable = join(userData, 'private.txt')
220+
writeFileSync(readable, 'offline consent fixture')
221+
222+
await check('offline approval returns an error without remembering a grant', async () => {
223+
const shown = launched.app.waitForEvent('window')
224+
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
225+
const prompt = await shown
226+
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
227+
sim.disconnect()
228+
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
229+
await expect
230+
.poll(() =>
231+
launched.app.evaluate(({ safeStorage }, toolCallId) => {
232+
const fs = process.getBuiltinModule('node:fs')
233+
const path = `${process.env.SIM_DESKTOP_USER_DATA}/desktop-executor-journal.json`
234+
const envelope = JSON.parse(fs.readFileSync(path, 'utf8'))
235+
const journal = JSON.parse(
236+
safeStorage.decryptString(Buffer.from(envelope.ciphertext, 'base64'))
237+
) as { entries: { toolCallId: string; state: string }[] }
238+
return journal.entries.find((entry) => entry.toolCallId === toolCallId)?.state
239+
}, call)
240+
)
241+
.toBe('result')
242+
sim.reconnect()
243+
const completion = await settled(sim, call)
244+
expect(completion.status).toBe('error')
245+
expect(JSON.stringify(completion)).not.toContain('offline consent fixture')
246+
const nextPrompt = launched.app.waitForEvent('window')
247+
const next = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
248+
await (await nextPrompt).getByRole('button', { name: "Don't allow", exact: true }).click()
249+
expect((await settled(sim, next)).status).toBe('error')
250+
})
251+
})
252+
253+
test('background consent does not reopen the main app after its windows are closed', async () => {
254+
test.skip(process.platform !== 'darwin', 'The macOS app remains running without a window.')
255+
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-windowless-consent-'))
256+
const launched = await launch(sim, userData)
257+
app = launched.app
258+
const deviceId = await registeredDevice(sim)
259+
const readable = join(userData, 'private.txt')
260+
writeFileSync(readable, 'windowless consent fixture')
261+
await launched.window.close()
262+
263+
await check('only the standalone consent window opens for a background read', async () => {
264+
const shown = launched.app.waitForEvent('window')
265+
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
266+
const prompt = await shown
267+
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
268+
expect(
269+
await launched.app.evaluate(({ BrowserWindow }) =>
270+
BrowserWindow.getAllWindows().map((window) => window.getParentWindow() === null)
271+
)
272+
).toEqual([true])
273+
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
274+
expect((await settled(sim, call)).status).toBe('error')
275+
})
276+
})
277+
278+
test('sign-out clears folder grants even when desktop settings cannot be saved', async () => {
279+
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-grant-cleanup-'))
280+
const launched = await launch(sim, userData)
281+
app = launched.app
282+
const deviceId = await registeredDevice(sim)
283+
const readable = join(userData, 'private.txt')
284+
writeFileSync(readable, 'cleanup consent fixture')
285+
const shown = launched.app.waitForEvent('window')
286+
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
287+
await (await shown).getByRole('button', { name: 'Allow folder', exact: true }).click()
288+
expect((await settled(sim, call)).status).toBe('success')
289+
const grants = join(userData, 'local-filesystem-grants.json')
290+
expect(existsSync(grants)).toBe(true)
291+
await launched.window.evaluate(() => {
292+
const button = document.createElement('button')
293+
button.textContent = 'Enable full file access'
294+
button.onclick = async () => {
295+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
296+
await api.settings.setFullFileAccess?.(true)
297+
button.textContent = 'Full file access enabled'
298+
}
299+
document.body.append(button)
300+
})
301+
await launched.window
302+
.getByRole('button', { name: 'Enable full file access', exact: true })
303+
.click()
304+
await expect(
305+
launched.window.getByRole('button', { name: 'Full file access enabled', exact: true })
306+
).toBeVisible()
307+
const settings = join(userData, 'settings.json')
308+
if (existsSync(settings)) renameSync(settings, `${settings}.backup`)
309+
mkdirSync(settings)
310+
311+
try {
312+
await check(
313+
'failed settings persistence does not skip independent grant cleanup',
314+
async () => {
315+
const failedSignOut = launched.app.waitForEvent('window')
316+
await launched.app.evaluate(({ Menu }) => {
317+
const item = Menu.getApplicationMenu()
318+
?.items.flatMap((entry) => entry.submenu?.items ?? [])
319+
.find((entry) => entry.label === 'Sign Out')
320+
if (!item) throw new Error('Missing Sign Out menu item')
321+
item.click()
322+
})
323+
const failure = await failedSignOut
324+
await failure.getByRole('button', { name: 'OK', exact: true }).click()
325+
expect(existsSync(join(userData, 'account-data-teardown-required.json'))).toBe(true)
326+
await expect.poll(() => existsSync(grants)).toBe(false)
327+
}
328+
)
329+
} finally {
330+
rmSync(settings, { recursive: true, force: true })
331+
if (existsSync(`${settings}.backup`)) renameSync(`${settings}.backup`, settings)
332+
}
333+
})
334+
206335
test('B: a result produced while the network is cut is delivered once after reconnecting', async () => {
207336
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-b-'))
208337
app = (await launch(sim, userData)).app

‎apps/desktop/e2e/browser-focus.spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,33 @@ test('browser focus and shortcuts stay with the surface the user is using', asyn
308308
await clickMenu('New Tab')
309309
await expect.poll(tabCount).toBe(before + 1)
310310
})
311+
await check(
312+
'a revealed page resumes throttling in its own chat after switching chats',
313+
async () => {
314+
await panelAction({ action: 'switch-tab', tabId: '1' })
315+
await shell.evaluate(async (scope) => {
316+
const api = (globalThis as Bridge).simDesktop.browserAgent
317+
api.setPanelBounds(
318+
{ x: 0, y: 120, width: innerWidth, height: innerHeight - 120 },
319+
null,
320+
scope
321+
)
322+
await api.activateScope('browser-focus-other-chat')
323+
}, SCOPE)
324+
await expect
325+
.poll(() =>
326+
shellApp.evaluate(
327+
({ webContents }, url) =>
328+
webContents
329+
.getAllWebContents()
330+
.find((contents) => contents.getURL() === url)
331+
?.getBackgroundThrottling(),
332+
`${site}/five`
333+
)
334+
)
335+
.toBe(true)
336+
}
337+
)
311338
passed = true
312339
} finally {
313340
mkdirSync(dirname(reportPath), { recursive: true })

‎apps/desktop/e2e/local-files.spec.ts‎

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@ createRoot(document.getElementById('settings')).render(
219219
process.env.DESKTOP_LOCAL_FILES_REPORT_PATH ??
220220
test.info().outputPath('local-file-consent.png'),
221221
})
222-
await denial.getByRole('button', { name: "Don't allow", exact: true }).click()
222+
await denial
223+
.getByRole('button', { name: "Don't allow", exact: true })
224+
.click({ noWaitAfter: true })
223225
await expect.poll(() => deniedResults.length).toBe(2)
224226
expect(deniedResults).toEqual([
225227
{ ok: false, error: expect.any(String) },
@@ -232,10 +234,15 @@ createRoot(document.getElementById('settings')).render(
232234
const folderConsent = await folderPrompt
233235
const queuedRead = invoke({ operation: 'read', toolCallId: 'text' })
234236
void queuedRead.catch(() => {})
237+
await expect(
238+
folderConsent.getByRole('button', { name: 'Allow folder', exact: true })
239+
).toBeVisible()
235240
expect(
236241
await folderConsent.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop)
237242
).toBe('undefined')
238-
await folderConsent.getByRole('button', { name: 'Allow folder', exact: true }).click()
243+
await folderConsent
244+
.getByRole('button', { name: 'Allow folder', exact: true })
245+
.click({ noWaitAfter: true })
239246
expect(await folderRead).toMatchObject({ ok: true, data: { representation: 'directory' } })
240247
expect(await queuedRead).toMatchObject({ ok: true, data: { text: 'native file contents' } })
241248
const canonicalRequest = {
@@ -292,7 +299,9 @@ createRoot(document.getElementById('settings')).render(
292299
await invoke({ operation: 'cancel', toolCallId: 'sharedLeader' })
293300
expect(await leader.result).toMatchObject({ ok: false })
294301
await expect(leader.prompt.getByRole('dialog')).toBeVisible()
295-
await leader.prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
302+
await leader.prompt
303+
.getByRole('button', { name: 'Allow folder', exact: true })
304+
.click({ noWaitAfter: true })
296305
expect(await survivor).toMatchObject({ ok: true, data: { text: 'shared contents' } })
297306
})
298307
await test.step('Full file access is opt-in, survives restart, and stops granting access when disabled', async () => {
@@ -375,7 +384,9 @@ createRoot(document.getElementById('settings')).render(
375384
window.getByRole('switch', { name: 'Full file access', exact: true })
376385
).not.toBeChecked()
377386
const permission = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' })
378-
await permission.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
387+
await permission.prompt
388+
.getByRole('button', { name: "Don't allow", exact: true })
389+
.click({ noWaitAfter: true })
379390
expect(await permission.result).toMatchObject({ ok: false })
380391
})
381392
await test.step('a folder grant works in another chat but does not permit symlink escapes', async () => {
@@ -400,7 +411,9 @@ createRoot(document.getElementById('settings')).render(
400411
await expect(escapedRead.prompt.getByRole('dialog')).toContainText(
401412
JSON.stringify(realpathSync(outside))
402413
)
403-
await escapedRead.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
414+
await escapedRead.prompt
415+
.getByRole('button', { name: "Don't allow", exact: true })
416+
.click({ noWaitAfter: true })
404417
expect(await escapedRead.result).toMatchObject({ ok: false })
405418
rmSync(join(source, 'linked.txt'))
406419
})
@@ -410,7 +423,9 @@ createRoot(document.getElementById('settings')).render(
410423
const stale = await requestPermission({ operation: 'read', toolCallId: 'stale' })
411424
if (changed) calls.stale.args.path = join(source, 'report.txt')
412425
else calls.stale = undefined
413-
await stale.prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
426+
await stale.prompt
427+
.getByRole('button', { name: 'Allow folder', exact: true })
428+
.click({ noWaitAfter: true })
414429
expect(await stale.result).toMatchObject({ ok: false })
415430
}
416431
})
@@ -432,12 +447,12 @@ createRoot(document.getElementById('settings')).render(
432447
openApproved(
433448
root: string,
434449
relative: string,
435-
dev: number,
436-
ino: number,
450+
dev: bigint,
451+
ino: bigint,
437452
directory: boolean
438453
): Promise<number>
439454
}
440-
const root = await stat(paths.source)
455+
const root = await stat(paths.source, { bigint: true })
441456
const denied = async (path: string, ino = root.ino) => {
442457
try {
443458
const fd = await native.openApproved(paths.source, path, root.dev, ino, false)
@@ -460,12 +475,19 @@ createRoot(document.getElementById('settings')).render(
460475
text,
461476
ancestor: await denied('native-link/private.txt'),
462477
traversal: await denied('../Reports-other/private.txt'),
463-
replaced: await denied('native-parent/inside.txt', root.ino + 1),
478+
replaced: await denied('native-parent/inside.txt', root.ino + 1n),
479+
overflow: await denied('native-parent/inside.txt', root.ino + (1n << 64n)),
464480
}
465481
},
466482
{ source: realpathSync(source) }
467483
)
468-
expect(result).toEqual({ text: 'inside', ancestor: true, traversal: true, replaced: true })
484+
expect(result).toEqual({
485+
text: 'inside',
486+
ancestor: true,
487+
traversal: true,
488+
replaced: true,
489+
overflow: true,
490+
})
469491
} finally {
470492
rmSync(linked)
471493
rmSync(parent, { recursive: true })
@@ -654,7 +676,9 @@ createRoot(document.getElementById('settings')).render(
654676
await closed
655677
expect(await cancelled.result).toMatchObject({ ok: false })
656678
const again = await requestPermission({ operation: 'read', toolCallId: 'cancelled' })
657-
await again.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
679+
await again.prompt
680+
.getByRole('button', { name: "Don't allow", exact: true })
681+
.click({ noWaitAfter: true })
658682
expect(await again.result).toMatchObject({ ok: false })
659683
})
660684
await test.step('consent escapes direction controls in folder names', async () => {
@@ -663,14 +687,18 @@ createRoot(document.getElementById('settings')).render(
663687
calls.bidi = { toolName: 'read_local_file', args: { path: folder } }
664688
const bidi = await requestPermission({ operation: 'read', toolCallId: 'bidi' })
665689
await expect(bidi.prompt.getByRole('dialog')).toContainText('Bidi\\u061c\\u200e\\u200f')
666-
await bidi.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
690+
await bidi.prompt
691+
.getByRole('button', { name: "Don't allow", exact: true })
692+
.click({ noWaitAfter: true })
667693
expect(await bidi.result).toMatchObject({ ok: false })
668694
})
669695
await test.step('an unanswered prompt does not block approved folders', async () => {
670696
calls.blocker = { toolName: 'read_local_file', args: { path: join(outside, 'private.txt') } }
671697
const blocker = await requestPermission({ operation: 'read', toolCallId: 'blocker' })
672698
expect(await invoke({ operation: 'read', toolCallId: 'text' })).toMatchObject({ ok: true })
673-
await blocker.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
699+
await blocker.prompt
700+
.getByRole('button', { name: "Don't allow", exact: true })
701+
.click({ noWaitAfter: true })
674702
expect(await blocker.result).toMatchObject({ ok: false })
675703
})
676704
await test.step('cancelled calls cannot reuse an approved folder', async () => {
@@ -688,7 +716,9 @@ createRoot(document.getElementById('settings')).render(
688716
renameSync(proposed, join(root, 'Original'))
689717
mkdirSync(proposed)
690718
writeFileSync(join(proposed, 'unapproved.txt'), 'replacement folder contents')
691-
await retargeted.prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
719+
await retargeted.prompt
720+
.getByRole('button', { name: 'Allow folder', exact: true })
721+
.click({ noWaitAfter: true })
692722
expect(await retargeted.result).toMatchObject({ ok: false })
693723
})
694724
const result = await invoke({ operation: 'manifest', toolCallId: 'import' })
@@ -763,7 +793,9 @@ createRoot(document.getElementById('settings')).render(
763793
'Local files'
764794
)
765795
const revoked = await requestPermission({ operation: 'read', toolCallId: 'text' })
766-
await revoked.prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
796+
await revoked.prompt
797+
.getByRole('button', { name: 'Allow folder', exact: true })
798+
.click({ noWaitAfter: true })
767799
expect(await revoked.result).toMatchObject({ ok: true })
768800
})
769801

@@ -778,12 +810,16 @@ createRoot(document.getElementById('settings')).render(
778810
'Local files'
779811
)
780812
const replaced = await requestPermission({ operation: 'read', toolCallId: 'text' })
781-
await replaced.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
813+
await replaced.prompt
814+
.getByRole('button', { name: "Don't allow", exact: true })
815+
.click({ noWaitAfter: true })
782816
expect(await replaced.result).toMatchObject({ ok: false })
783817
rmSync(source, { recursive: true })
784818
renameSync(join(root, 'Original-reports'), source)
785819
const restored = await requestPermission({ operation: 'read', toolCallId: 'text' })
786-
await restored.prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
820+
await restored.prompt
821+
.getByRole('button', { name: 'Allow folder', exact: true })
822+
.click({ noWaitAfter: true })
787823
expect(await restored.result).toMatchObject({ ok: true })
788824
})
789825

@@ -820,7 +856,9 @@ createRoot(document.getElementById('settings')).render(
820856
)
821857
.toMatchObject({ fullFileAccess: false })
822858
const revoked = await requestPermission({ operation: 'read', toolCallId: 'text' })
823-
await revoked.prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
859+
await revoked.prompt
860+
.getByRole('button', { name: "Don't allow", exact: true })
861+
.click({ noWaitAfter: true })
824862
expect(await revoked.result).toMatchObject({ ok: false })
825863
})
826864
await test.step('a failed settings write reports the error and leaves Full file access disabled', async () => {

0 commit comments

Comments
 (0)