Skip to content

Commit b5a2e83

Browse files
committed
feat(desktop): ship the native local-file tools on Windows
read_local_file, import_local_files, and background imports now run on Windows through a Node fs backend behind the same openNativeFile seam the POSIX native module serves on macOS and Linux. Node has no descriptor-relative open on Windows, so the backend checks the grant root's identity, requires the resolved path to be canonical beneath the grant (no symlink or junction component), matches the opened handle's identity to the path's, and re-checks path and root after the open; a listing must leave the path naming the opened handle. Entries that Windows enumerates as reparse points are classified by lstat, so junctions list as links and OneDrive placeholders as files. Caps, errors, and the handle shape match the POSIX module, and both backends run the local-files suite where they can. The handle now owns readDirectory, isWithinRoot moves to its own module so both backends share it, platform-features turns localFiles on for win32, and the Windows test leg runs local-files.test.ts and the background-import suite.
1 parent bb5a1bb commit b5a2e83

13 files changed

Lines changed: 672 additions & 213 deletions

‎apps/desktop/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ The native `read_local_file` and `import_local_files` tools also accept absolute
192192

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

195-
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.
195+
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. On macOS and Linux, a native module opens each path component relative to the verified grant descriptor, and directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the open or 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.
196+
197+
On Windows, Node cannot open a path relative to a directory handle, so the same tools use checked Node fs opens instead (`src/main/win32-native-files.ts`). Paths are validated lexically first: only drive paths and `~\` paths are accepted, and reserved device names, alternate data streams, and trailing dots or spaces are refused. Then Electron checks that the granted folder still has its recorded identity, resolves the path and refuses it unless it is already canonical beneath the granted folder (so any symlink or junction in it, even one pointing back inside, is refused), and opens it only when the opened handle has the identity the path had just before the open; after the open it checks the path and the granted folder again. A directory listing must leave the path naming the opened handle. Listings report symlinks and junctions as links and never follow them, under the same caps as macOS. These checks narrow, but cannot eliminate, a race by a process already running as the user that swaps a path component between a check and the open. The model itself cannot create links or junctions: the tools are read-only. OneDrive Files-On-Demand placeholders are downloaded when they are read or imported. UNC and other network paths, including a drive letter mapped to a share, are refused in this release.
196198

197199
## Windows
198200

@@ -205,7 +207,7 @@ The Windows build is a one-click, per-user NSIS installer (`Sim-<version>-x64.ex
205207
- Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. A confirmed restart runs the installer silently and relaunches. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`.
206208
- Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last.
207209
- SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases.
208-
- Not available on Windows yet: Terminal, native local-file tools, native Computer Use, and importing from other browsers. node-pty still ships but is not exercised.
210+
- Not available on Windows yet: Terminal, native Computer Use, and importing from other browsers. The native local-file tools ship with the checks described in [Local filesystem access](#local-filesystem-access). node-pty still ships but is not exercised.
209211

210212
| Secret / variable | Kind | Contents |
211213
|---|---|---|

‎apps/desktop/src/main/desktop-executor/runner.test.ts‎

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -141,37 +141,53 @@ describe('background terminal calls', () => {
141141
})
142142

143143
describe('calls for a surface the platform does not ship', () => {
144-
it.each([
145-
['terminal', { operation: 'run', args: { command: 'echo hi' } }],
146-
['read_local_file', { path: '~/notes.txt' }],
147-
['import_local_files', { path: '~/notes', targetWorkspaceId: 'ws-1' }],
148-
])('never starts %s on Windows', async (toolName, args) => {
149-
const executeTool = vi.fn()
150-
const request = vi.fn()
151-
const completion = await runner({
152-
platform: 'win32',
153-
terminal: { executeTool, cancelTool: vi.fn(async () => true) },
154-
localFiles: { request },
155-
}).run(
144+
it.each([['terminal', { operation: 'run', args: { command: 'echo hi' } }]])(
145+
'never starts %s on Windows',
146+
async (toolName, args) => {
147+
const executeTool = vi.fn()
148+
const request = vi.fn()
149+
const completion = await runner({
150+
platform: 'win32',
151+
terminal: { executeTool, cancelTool: vi.fn(async () => true) },
152+
localFiles: { request },
153+
}).run(
154+
{
155+
toolCallId: 'call-1',
156+
toolName,
157+
args,
158+
chatId: 'chat-b',
159+
workspaceId: 'ws-1',
160+
executionToken: 'token-call-1',
161+
},
162+
new AbortController().signal
163+
)
164+
165+
expect(completion.data).toMatchObject({ notStarted: true })
166+
expect(completion.message).toContain('on this operating system')
167+
expect(executeTool).not.toHaveBeenCalled()
168+
expect(request).not.toHaveBeenCalled()
169+
}
170+
)
171+
})
172+
173+
describe('local file calls', () => {
174+
it.each(['read_local_file', 'import_local_files'])('starts %s on Windows', async (toolName) => {
175+
const request = vi.fn(async () => ({ ok: false as const, error: 'unreadable' }))
176+
await runner({ platform: 'win32', localFiles: { request } }).run(
156177
{
157-
toolCallId: 'call-1',
178+
toolCallId: 'local-1',
158179
toolName,
159-
args,
180+
args: { path: '~/notes', targetWorkspaceId: 'ws-1' },
160181
chatId: 'chat-b',
161182
workspaceId: 'ws-1',
162-
executionToken: 'token-call-1',
183+
executionToken: 'token-local-1',
163184
},
164185
new AbortController().signal
165186
)
166187

167-
expect(completion.data).toMatchObject({ notStarted: true })
168-
expect(completion.message).toContain('on this operating system')
169-
expect(executeTool).not.toHaveBeenCalled()
170-
expect(request).not.toHaveBeenCalled()
188+
expect(request).toHaveBeenCalledOnce()
171189
})
172-
})
173190

174-
describe('local file calls', () => {
175191
it('names a passing storage state, not a setting, when local files are out of reach', async () => {
176192
const completion = await runner({ accountDataAvailable: () => false }).run(
177193
{
@@ -191,7 +207,7 @@ describe('local file calls', () => {
191207
})
192208
})
193209

194-
describe.skipIf(process.platform === 'win32')('background imports', () => {
210+
describe('background imports', () => {
195211
const roots: string[] = []
196212

197213
afterEach(async () => {

‎apps/desktop/src/main/desktop-executor/service.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,17 @@ describe('results recovery will hand to the model', () => {
228228

229229
describe('desktop executor registration', () => {
230230
it.each([
231-
['darwin', true],
232-
['win32', false],
231+
['darwin', true, true],
232+
['win32', false, true],
233233
] as const)(
234-
'advertises the terminal and native file tools on %s: %s',
235-
async (platform, shipped) => {
234+
'advertises on %s the terminal: %s, and the native file tools: %s',
235+
async (platform, terminal, localFiles) => {
236236
const { sim, desktopExecutor } = await service(1, undefined, platform)
237237
desktopExecutor.start()
238238
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
239239

240240
expect(sim.registrationBodies[0]).toMatchObject({
241-
capabilities: { browser: true, terminal: shipped, localFiles: shipped },
241+
capabilities: { browser: true, terminal, localFiles },
242242
})
243243
sim.registrations[0]?.(false)
244244
await desktopExecutor.signOut()

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,6 @@ describe('registerIpcHandlers', () => {
375375
'terminal-themes:list-profiles',
376376
'computer-use:execute-tool',
377377
'browser-import:all',
378-
'desktop:local-files',
379378
'browser-credentials:copy',
380379
])('registers no handler on Windows for %s, which that shell does not ship', (channel) => {
381380
expect(collectHandlers().invoke.has(channel) || collectHandlers().on.has(channel)).toBe(true)
@@ -388,12 +387,13 @@ describe('registerIpcHandlers', () => {
388387
expect(invoke.has(channel) || on.has(channel)).toBe(false)
389388
})
390389

391-
it('keeps the user-local folder grants and password reveal on Windows', () => {
390+
it('keeps the folder grants, native file tools, and password reveal on Windows', () => {
392391
vi.mocked(ipcMain.handle).mockClear()
393392
registerIpcHandlers({ ...deps, platform: 'win32' })
394393

395394
const { invoke } = collectHandlers()
396395
expect(invoke.has('desktop:local-filesystem')).toBe(true)
396+
expect(invoke.has('desktop:local-files')).toBe(true)
397397
expect(invoke.has('browser-credentials:reveal')).toBe(true)
398398
})
399399

0 commit comments

Comments
 (0)