Skip to content

Commit f524997

Browse files
committed
fix(desktop): share pending folder consent decisions
1 parent 4280b1a commit f524997

3 files changed

Lines changed: 36 additions & 31 deletions

File tree

‎apps/desktop/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ Copilot can inspect user-selected local directories through the ordinary VFS too
188188
- **Bound to a live Copilot call:** before a native read/search or browser action, Electron asks the authenticated Sim origin for the pending tool-call record. Local requests must exactly match its persisted operation, path, and options; browser actions run with the persisted arguments rather than renderer-supplied ones. Completed, failed, and aborted runs are rejected.
189189
- **Abort-aware and bounded:** stop/cancel propagates to active native scans and reads. File size, aggregate grep bytes, line, result, traversal-depth, and scan-count limits remain enforced in Electron, and unsafe regular expressions are rejected before execution.
190190

191-
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. 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.
191+
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

193193
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.
194194

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,12 @@ test('native file tools remember folder consent across chats and restarts until
140140
await api.settings.setPreference('terminalEnabled', false)
141141
})
142142
const deniedPrompt = app.waitForEvent('window', { timeout: 10_000 })
143-
const deniedRead = invoke({ operation: 'read', toolCallId: 'text' })
144-
void deniedRead.catch(() => {})
143+
const deniedReads = ['text', 'otherChat'].map((toolCallId) =>
144+
invoke({ operation: 'read', toolCallId })
145+
)
146+
const deniedResults: unknown[] = []
147+
for (const read of deniedReads)
148+
void read.then((result) => deniedResults.push(result)).catch(() => {})
145149
const denial = await deniedPrompt
146150
await expect(denial.getByRole('button', { name: "Don't allow", exact: true })).toBeFocused()
147151
await denial.screenshot({
@@ -150,7 +154,11 @@ test('native file tools remember folder consent across chats and restarts until
150154
test.info().outputPath('local-file-consent.png'),
151155
})
152156
await denial.getByRole('button', { name: "Don't allow", exact: true }).click()
153-
expect(await deniedRead).toMatchObject({ ok: false })
157+
await expect.poll(() => deniedResults.length).toBe(2)
158+
expect(deniedResults).toEqual([
159+
{ ok: false, error: expect.any(String) },
160+
{ ok: false, error: expect.any(String) },
161+
])
154162

155163
const folderPrompt = app.waitForEvent('window')
156164
const folderRead = invoke({ operation: 'read', toolCallId: 'directory' })

‎apps/desktop/src/main/local-file-permissions.ts‎

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ async function revalidate(context: LocalFilePermissionContext): Promise<void> {
4040
assertCurrent(context)
4141
}
4242

43-
/** Serializes new consent prompts while remembered folder access remains concurrent. */
43+
/** Shares each pending folder decision while remembered access remains concurrent. */
4444
export class LocalFilePermissions {
4545
private queue: Promise<void> = Promise.resolve()
46-
private pending = 0
46+
private readonly pending = new Map<string, Promise<void>>()
4747

4848
constructor(private readonly filesystem: LocalFilesystemService) {}
4949

@@ -62,32 +62,32 @@ export class LocalFilePermissions {
6262
const path = await realpath(nativePath(authorization.args.path))
6363
const existing = await this.filesystem.nativeAccess(path)
6464
if (existing) return this.authorizedAccess(existing, context)
65-
if (this.pending >= MAX_PENDING_REQUESTS)
66-
throw new Error('Too many local file requests are waiting for permission. Try again later.')
67-
this.pending++
68-
const pending = this.queue.then(() => this.requestFolder(path, context))
69-
this.queue = pending.then(
70-
() => undefined,
71-
() => undefined
72-
)
73-
try {
74-
return await pending
75-
} finally {
76-
this.pending--
77-
}
78-
}
79-
80-
private async requestFolder(
81-
path: string,
82-
context: LocalFilePermissionContext
83-
): Promise<LocalFileAccess> {
84-
await revalidate(context)
85-
const existing = await this.filesystem.nativeAccess(path)
86-
if (existing) return this.authorizedAccess(existing, context)
8765
const info = await stat(path)
8866
if (!info.isFile() && !info.isDirectory())
8967
throw new Error('The path is not a regular file or directory.')
9068
const folder = info.isDirectory() ? path : dirname(path)
69+
const key = JSON.stringify([context.generation, context.origin, folder])
70+
let pending = this.pending.get(key)
71+
if (!pending) {
72+
if (this.pending.size >= MAX_PENDING_REQUESTS)
73+
throw new Error('Too many local file requests are waiting for permission. Try again later.')
74+
const decision = this.queue.then(() => this.requestFolder(folder, context))
75+
this.queue = decision.then(
76+
() => undefined,
77+
() => undefined
78+
)
79+
pending = decision.finally(() => this.pending.delete(key))
80+
this.pending.set(key, pending)
81+
}
82+
await pending
83+
const access = await this.filesystem.nativeAccess(path)
84+
if (!access) throw new Error('The approved folder is no longer available.')
85+
return this.authorizedAccess(access, context)
86+
}
87+
88+
private async requestFolder(folder: string, context: LocalFilePermissionContext): Promise<void> {
89+
await revalidate(context)
90+
if (await this.filesystem.nativeAccess(folder)) return
9191
const root = await lstat(folder)
9292
if (!root.isDirectory()) throw new Error('The folder is no longer available.')
9393
const displayedPath = JSON.stringify(folder).replace(
@@ -108,9 +108,6 @@ export class LocalFilePermissions {
108108
if (result.response !== 0) throw new Error('The user did not allow this local file access.')
109109
await revalidate(context)
110110
await this.filesystem.grantDirectory({ path: folder }, context.generation, root)
111-
const access = await this.filesystem.nativeAccess(path)
112-
if (!access) throw new Error('The approved folder is no longer available.')
113-
return this.authorizedAccess(access, context)
114111
}
115112

116113
private async authorizedAccess(

0 commit comments

Comments
 (0)