Skip to content

Commit b4cf27c

Browse files
committed
fix(desktop): complete file consent and add full file access
1 parent 0758732 commit b4cf27c

21 files changed

Lines changed: 897 additions & 137 deletions

File tree

‎apps/desktop/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ Copilot can inspect user-selected local directories through the ordinary VFS too
190190

191191
The native `read_local_file` and `import_local_files` tools also accept absolute or `~/` paths. They reuse the same remembered folder grants as the VFS tools. For an unapproved path, Electron displays a bundled, isolated dialog showing the canonical folder and connected server. **Allow folder** grants read and import access to that folder and its subfolders across chats and normal app restarts. A file request proposes its containing folder explicitly; no wider folder is approved silently. Closing or declining the dialog returns no contents. Users can add or forget folders through **File → Folder Access**. As with VFS grants, sign-out and server changes clear access, and unavailable secure storage limits persistence to the app session. Concurrent requests for the same folder share one allow or deny decision. New consent prompts are serialized, but reads of approved folders proceed independently. Existing encrypted path-based approvals retain their scope and acquire folder-identity metadata on their first restore.
192192

193+
**Desktop settings → Local files → Full file access** bypasses folder prompts for authorized native reads and imports. It is off by default, persists across ordinary restarts, and resets on sign-out or server changes. Turning it off restores folder consent checks. Call authorization, cancellation, file identity, and resource limits still apply, and VFS access continues to use explicit mounts. A failed settings write reports an error and leaves full access disabled in the running app.
194+
193195
Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. Directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges.
194196

195197
## Auto-update, channels, rollout, rollback

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ test.describe('background executor', () => {
104104
args: { command: `sleep 1; echo B-${n} >> '${marker}'`, waitSeconds: 30 },
105105
})
106106
)
107+
const readConsent = launched.app.waitForEvent('window')
107108
const localRead = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
109+
await (await readConsent).getByRole('button', { name: 'Allow folder', exact: true }).click()
108110

109111
await window.goto(`${sim.origin}/workspace/ws-other/home`)
110112
await window.reload()
@@ -157,6 +159,50 @@ test.describe('background executor', () => {
157159
})
158160
})
159161

162+
test('background file reads require consent and Stop cancels pending permission', async () => {
163+
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-consent-'))
164+
const launched = await launch(sim, userData)
165+
app = launched.app
166+
const deviceId = await registeredDevice(sim)
167+
const readable = join(userData, 'private.txt')
168+
writeFileSync(readable, 'background consent fixture')
169+
170+
await check('background read stays pending until folder consent', async () => {
171+
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
172+
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
173+
const prompt = await shown
174+
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
175+
expect(sim.requireCall(call).completions).toHaveLength(0)
176+
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
177+
const completion = await settled(sim, call)
178+
expect(completion.status).toBe('error')
179+
expect(JSON.stringify(completion)).not.toContain('background consent fixture')
180+
})
181+
182+
await check('Stop dismisses background consent without granting access', async () => {
183+
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
184+
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
185+
const prompt = await shown
186+
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
187+
sim.stopCall(call)
188+
await expect.poll(() => prompt.isClosed()).toBe(true)
189+
await settled(sim, call)
190+
expect(sim.requireCall(call).completions[0]?.outcome).toBe('superseded')
191+
})
192+
193+
await check('approved background reads reuse the shared folder grant', async () => {
194+
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
195+
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
196+
const prompt = await shown
197+
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
198+
expect((await settled(sim, call)).status).toBe('success')
199+
const next = sim.issue(deviceId, CHAT_A, 'read_local_file', { path: readable })
200+
expect(JSON.stringify((await settled(sim, next)).data)).toContain(
201+
'background consent fixture'
202+
)
203+
})
204+
})
205+
160206
test('B: a result produced while the network is cut is delivered once after reconnecting', async () => {
161207
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-b-'))
162208
app = (await launch(sim, userData)).app
@@ -237,12 +283,15 @@ test.describe('background executor', () => {
237283
writeFileSync(join(source, 'q3', 'export.bin'), large)
238284
await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
239285

286+
const importConsent = launched.app.waitForEvent('window')
240287
const call = sim.issue(deviceId, CHAT_B, 'import_local_files', {
241288
path: source,
242289
targetWorkspaceId: WORKSPACE,
243290
folderId: 'folder-e2e',
244291
})
245292

293+
await (await importConsent).getByRole('button', { name: 'Allow folder', exact: true }).click()
294+
246295
await check('D: the import completes with every entry it stored', async () => {
247296
const completion = await settled(sim, call, 60_000)
248297
expect(completion.status).toBe('success')

‎apps/desktop/e2e/desktop-tools-live-sim.spec.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ test.describe('desktop tools against a live Sim', () => {
245245
})
246246
const page = await app.firstWindow({ timeout })
247247
pageErrors = []
248+
app.on('window', (permission) => {
249+
void permission
250+
.getByRole('button', { name: 'Allow folder', exact: true })
251+
.click({ timeout: 10_000 })
252+
.catch((error) => pageErrors.push(`Folder approval failed: ${String(error)}`))
253+
})
248254
page.on('pageerror', (error) => pageErrors.push(error.message))
249255
page.on('console', (message) => {
250256
if (message.type() === 'error') pageErrors.push(message.text())

0 commit comments

Comments
 (0)