Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ src/main/ # main process (bundled to dist/main.cjs)
src/preload/ # isolated renderer bridges
index.ts # hosted-app contextBridge IPC bridge (dist/preload.cjs)
browser/ # minimal agent-browser credential helper (dist/browser-preload.cjs)
native/ # Node-API/AppKit bridge for native macOS Help docs search
native/ # Node-API bridges for directory enumeration and macOS Help docs search
static/ # bundled local pages (offline.html, server.html), served over sim-shell:
e2e/ # Playwright _electron smoke suite
```
Expand Down Expand Up @@ -182,13 +182,17 @@ Copilot can inspect user-selected local directories through the ordinary VFS too

- **Explicit and read-only:** only a user click may open the native folder picker or revoke a grant; model tool calls cannot do either. There are no write/delete/execute/upload operations.
- **Remembered securely:** grants are encrypted in Electron's private app data with OS-backed `safeStorage` and restored with the same opaque URI after a normal app restart. (A security-scoped bookmark is stored alongside each grant, but it is a no-op in the current Developer ID build — only the macOS App Sandbox consumes it — and is kept purely for forward-compatibility should a sandboxed/MAS build ever ship.) There is no plaintext fallback: when secure storage is unavailable, the returned mount has `remembered: false` and lasts only for that app session.
- **Revocable:** Desktop settings removes one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants.
- **Revocable:** File → Folder Access adds folders and removes individual grants. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants.
- **Opaque:** the model sees canonical paths such as `user-local/Project--<mount-id>/README.md`, never host paths or internal `localfs://` URIs. Electron resolves every request, checks lexical and realpath containment, and refuses symlink escapes.
- **Desktop-only:** the web app advertises `desktopCapabilities.localFilesystem` only when the Electron bridge is present. Mothership adds the `user-local/` prompt surface and per-call client routing only for that capability, including delegated and resumed work.
- **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.
- **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.

Raw local file bytes are never exposed through the preload bridge and cannot be staged or uploaded by a model. Bounded text read/search results are returned to the active Copilot request; a user must use the normal attachment UI when they want the file itself to leave the device.
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.

**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.

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.

## Auto-update, channels, rollout, rollback

Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/e2e/background-executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ test.describe('background executor', () => {
args: { command: `sleep 1; echo B-${n} >> '${marker}'`, waitSeconds: 30 },
})
)
const readConsent = launched.app.waitForEvent('window')
const localRead = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
await (await readConsent).getByRole('button', { name: 'Allow folder', exact: true }).click()

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

test('background file reads require consent and Stop cancels pending permission', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-consent-'))
const launched = await launch(sim, userData)
app = launched.app
const deviceId = await registeredDevice(sim)
const readable = join(userData, 'private.txt')
writeFileSync(readable, 'background consent fixture')

await check('background read stays pending until folder consent', async () => {
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
const prompt = await shown
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
expect(sim.requireCall(call).completions).toHaveLength(0)
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
const completion = await settled(sim, call)
expect(completion.status).toBe('error')
expect(JSON.stringify(completion)).not.toContain('background consent fixture')
})

await check('Stop dismisses background consent without granting access', async () => {
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
const prompt = await shown
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
sim.stopCall(call)
await expect.poll(() => prompt.isClosed()).toBe(true)
await settled(sim, call)
expect(sim.requireCall(call).completions[0]?.outcome).toBe('superseded')
})

await check('approved background reads reuse the shared folder grant', async () => {
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
const prompt = await shown
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
expect((await settled(sim, call)).status).toBe('success')
const next = sim.issue(deviceId, CHAT_A, 'read_local_file', { path: readable })
expect(JSON.stringify((await settled(sim, next)).data)).toContain(
'background consent fixture'
)
})
})

test('B: a result produced while the network is cut is delivered once after reconnecting', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-b-'))
app = (await launch(sim, userData)).app
Expand Down Expand Up @@ -237,12 +283,15 @@ test.describe('background executor', () => {
writeFileSync(join(source, 'q3', 'export.bin'), large)
await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)

const importConsent = launched.app.waitForEvent('window')
const call = sim.issue(deviceId, CHAT_B, 'import_local_files', {
path: source,
targetWorkspaceId: WORKSPACE,
folderId: 'folder-e2e',
})

await (await importConsent).getByRole('button', { name: 'Allow folder', exact: true }).click()

await check('D: the import completes with every entry it stored', async () => {
const completion = await settled(sim, call, 60_000)
expect(completion.status).toBe('success')
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/e2e/desktop-tools-live-sim.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
Expand Down Expand Up @@ -245,6 +245,12 @@
})
const page = await app.firstWindow({ timeout })
pageErrors = []
app.on('window', (permission) => {
void permission
.getByRole('button', { name: 'Allow folder', exact: true })
.click({ timeout: 10_000 })
.catch((error) => pageErrors.push(`Folder approval failed: ${String(error)}`))
})
page.on('pageerror', (error) => pageErrors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') pageErrors.push(message.text())
Expand Down
Loading
Loading