Skip to content

Commit 6713fab

Browse files
authored
feat(desktop): import local files from the background executor (#8672)
* feat(desktop): import local files from the background executor A chat whose turn runs on the desktop can now import a local file or folder into the workspace while the user is elsewhere, instead of the background executor answering import_local_files as not run. - Sim: PUT /api/desktop/tool/import stores one entry of a claimed import. The entry is admitted before its body is read: the caller's session must own the device, the call must be a running import_local_files bound to that device, and the request must carry the token that claimed it. The target workspace and folder come from Sim's record of the call, never from the request, and writing there is authorized as any file creation is. Each path segment becomes a folder under the target, reusing folders already there (find or create, converging when two land at once); a file never overwrites one already there. - Desktop: the runner reads the manifest from the call's own arguments, sends each directory, then each file in chunks checked against the manifest's revision, and reports what landed. A failure part way reports the partial result as outcome unknown, not to be retried. Stop aborts between entries and mid-upload. - The chunked read, the size check and the result projections move to @sim/desktop-bridge/tool-results, so the chat view's import and the background one report the same shapes. * fix(desktop): imports arrive whole, survive the rate limit, and record their folders - The proxy no longer runs for the desktop's raw upload routes (import, and the browser file transfer that has the same shape). Running it made Next buffer the body and cut it off at its 10 MB proxy limit. The import route also requires a declared length and refuses a body that does not match it, so a cut-off file is never stored as complete. - A file entry with no body is refused instead of stored as an empty file. - A name Sim cannot store (one with a backslash) is refused for the whole import before anything lands, and at the contract. - A chunk that runs past the size the manifest listed is refused, like one that ends early. - A rate-limited entry is sent again after the limit clears. A 429 means nothing was stored, so a large tree no longer fails part way. - Folders an import creates are audited as folder creations and announced to the workspace's file views, as folders made by hand are. - Tests record what the fake Sim stored instead of asserting mock calls. The integration suite removes its audit rows. * fix(desktop): an import the rate limit never let start stays safe to ask for again When Sim's rate limit outlasts every retry, it refused the entry outright, so the result lists exactly what landed instead of being outcome unknown. An import where nothing landed is no longer marked do-not-retry. * fix(desktop): harden the raw upload routes now that they skip the proxy - The desktop import and browser file routes refuse the dedicated MCP host themselves, as the proxy did before they left its matcher. - The import's execution token travels in a header, not the query string, so it stays out of load balancer, CDN and trace URLs. - A name that trims to nothing or to a dot segment is refused at the contract and, for the whole import, before anything lands, through one shared rule. Such a name no longer surfaces as a 500 part way through. - The browser download route also requires a declared length and refuses a body that does not match it. - Route tests cover 411, 413, a length mismatch, an entry that is not admitted reading no body, the 401 mappings, the missing token header and an unstorable name. * fix(desktop): keep accepting downloads from shells that send no declared length The truncation check applies whenever a download declares its length. A shell already in use that streams one without a length is still accepted under the byte ceiling. The E2E fixture's import route now refuses a file without a declared length, as Sim's does, so the device is proven to send one. * docs(desktop): document the import entry parser * test(desktop): the raw upload routes serve nothing on the dedicated MCP host * test(desktop): an import entry's execution token travels in a header, never the URL * docs(desktop): document the import entry input
1 parent d4d16a0 commit 6713fab

26 files changed

Lines changed: 1708 additions & 100 deletions

File tree

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

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execFileSync } from 'node:child_process'
2-
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { createHash } from 'node:crypto'
3+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
34
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
45
import { tmpdir } from 'node:os'
56
import { join } from 'node:path'
@@ -17,7 +18,8 @@ import { sleep } from '@sim/utils/helpers'
1718

1819
/**
1920
* The Sim desktop app's background executor against a fixture Sim that speaks the executor's
20-
* device protocol (register, inbox, doorbell, claim, lease, complete) the way Sim's routes do.
21+
* device protocol (register, inbox, doorbell, claim, lease, complete, import) the way Sim's
22+
* routes do.
2123
* The window navigates, reloads and leaves the chats while their calls run: nothing in this
2224
* suite depends on a chat view, which is the point. Each scenario's checks land in a JSON
2325
* report at BACKGROUND_EXECUTOR_REPORT_PATH.
@@ -66,12 +68,26 @@ interface ReportCheck {
6668

6769
const report: ReportCheck[] = []
6870

71+
interface ImportedEntry {
72+
toolCallId: string
73+
kind: string
74+
sourceName: string
75+
relativePath: string
76+
sha256?: string
77+
bytes?: number
78+
}
79+
80+
function sha256(bytes: Buffer): string {
81+
return createHash('sha256').update(bytes).digest('hex')
82+
}
83+
6984
class FixtureSim {
7085
readonly calls = new Map<string, FixtureCall>()
7186
readonly devices = new Map<string, { name: string; platform: string }>()
7287
readonly requests: string[] = []
7388
readonly hits = new Map<string, number>()
7489
readonly streams = new Map<string, Set<ServerResponse>>()
90+
readonly imported: ImportedEntry[] = []
7591
enabled = true
7692
offline = false
7793
droppedWhileOffline = 0
@@ -100,6 +116,7 @@ class FixtureSim {
100116
this.devices.clear()
101117
this.requests.length = 0
102118
this.hits.clear()
119+
this.imported.length = 0
103120
this.enabled = true
104121
this.offline = false
105122
this.droppedWhileOffline = 0
@@ -156,12 +173,60 @@ class FixtureSim {
156173
}
157174
}
158175

176+
private async raw(request: IncomingMessage): Promise<Buffer> {
177+
const chunks: Buffer[] = []
178+
for await (const chunk of request) chunks.push(Buffer.from(chunk))
179+
return Buffer.concat(chunks)
180+
}
181+
159182
private async body(request: IncomingMessage): Promise<Record<string, unknown>> {
160-
let text = ''
161-
for await (const chunk of request) text += chunk.toString()
183+
const text = (await this.raw(request)).toString()
162184
return text ? (JSON.parse(text) as Record<string, unknown>) : {}
163185
}
164186

187+
/** Stores one entry of a claimed, running import, as Sim's import route does. */
188+
private async importEntry(
189+
url: URL,
190+
request: IncomingMessage,
191+
response: ServerResponse
192+
): Promise<void> {
193+
const query = Object.fromEntries(url.searchParams)
194+
const call = this.calls.get(query.toolCallId ?? '')
195+
if (
196+
request.method !== 'PUT' ||
197+
!call ||
198+
call.deviceId !== query.deviceId ||
199+
call.toolName !== 'import_local_files' ||
200+
call.token !== request.headers['x-sim-execution-token'] ||
201+
call.status !== 'running'
202+
) {
203+
this.json(response, 404, { error: 'Desktop import not found' })
204+
return
205+
}
206+
// As Sim's route does: a file must declare its length, and arrive whole.
207+
if (query.kind === 'file' && request.headers['content-length'] === undefined) {
208+
this.json(response, 411, { error: 'A file import must declare its length' })
209+
return
210+
}
211+
const content = await this.raw(request)
212+
if (query.kind === 'file' && content.length !== Number(request.headers['content-length'])) {
213+
this.json(response, 400, { error: 'The file did not arrive whole' })
214+
return
215+
}
216+
const entry: ImportedEntry = {
217+
toolCallId: call.toolCallId,
218+
kind: query.kind ?? '',
219+
sourceName: query.sourceName ?? '',
220+
relativePath: query.relativePath ?? '',
221+
...(query.kind === 'file' ? { sha256: sha256(content), bytes: content.length } : {}),
222+
}
223+
this.imported.push(entry)
224+
this.json(response, 200, {
225+
id: `entry-${this.imported.length}`,
226+
name: entry.relativePath.split('/').at(-1) || entry.sourceName,
227+
})
228+
}
229+
165230
private json(response: ServerResponse, status: number, body: unknown): void {
166231
response.writeHead(status, { 'Content-Type': 'application/json' })
167232
response.end(JSON.stringify(body))
@@ -261,6 +326,10 @@ class FixtureSim {
261326
this.json(response, 200, { items })
262327
return
263328
}
329+
if (path === '/api/desktop/tool/import') {
330+
await this.importEntry(url, request, response)
331+
return
332+
}
264333
if (path.startsWith('/api/desktop/tool/')) {
265334
const body = await this.body(request)
266335
const call = this.calls.get(String(body.toolCallId))
@@ -590,6 +659,67 @@ test.describe('background executor', () => {
590659
})
591660
})
592661

662+
test('D: a folder import lands in Sim while the user is in another chat', async () => {
663+
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-d-'))
664+
const launched = await launch(userData)
665+
app = launched.app
666+
const deviceId = await registeredDevice()
667+
const source = join(userData, 'Reports')
668+
mkdirSync(join(source, 'q3'), { recursive: true })
669+
writeFileSync(join(source, 'notes.txt'), 'remember the numbers')
670+
// Larger than one 8 MB read, so the file crosses Electron in several chunks.
671+
const large = Buffer.alloc(9 * 1024 * 1024, 7)
672+
writeFileSync(join(source, 'q3', 'export.bin'), large)
673+
await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
674+
675+
const call = sim.issue(deviceId, CHAT_B, 'import_local_files', {
676+
path: source,
677+
targetWorkspaceId: WORKSPACE,
678+
folderId: 'folder-e2e',
679+
})
680+
681+
await check('D: the import completes with every entry it stored', async () => {
682+
const completion = await settled(call, 60_000)
683+
expect(completion.status).toBe('success')
684+
expect(completion.data).toMatchObject({
685+
success: true,
686+
workspaceId: WORKSPACE,
687+
folders: [
688+
{ id: 'entry-1', relativePath: '' },
689+
{ id: 'entry-3', relativePath: 'q3' },
690+
],
691+
files: [
692+
{ id: 'entry-2', relativePath: 'notes.txt' },
693+
{ id: 'entry-4', relativePath: 'q3/export.bin' },
694+
],
695+
})
696+
})
697+
698+
await check('D: Sim received the tree with the bytes on disk, once', async () => {
699+
expect(sim.imported).toEqual([
700+
{ toolCallId: call, kind: 'directory', sourceName: 'Reports', relativePath: '' },
701+
{
702+
toolCallId: call,
703+
kind: 'file',
704+
sourceName: 'Reports',
705+
relativePath: 'notes.txt',
706+
sha256: sha256(Buffer.from('remember the numbers')),
707+
bytes: 20,
708+
},
709+
{ toolCallId: call, kind: 'directory', sourceName: 'Reports', relativePath: 'q3' },
710+
{
711+
toolCallId: call,
712+
kind: 'file',
713+
sourceName: 'Reports',
714+
relativePath: 'q3/export.bin',
715+
sha256: sha256(large),
716+
bytes: large.length,
717+
},
718+
])
719+
expect(sim.requireCall(call).claims).toBe(1)
720+
})
721+
})
722+
593723
test('E: Stop from another chat stops a running browser wait and terminal command', async () => {
594724
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-e-'))
595725
app = (await launch(userData)).app

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DESKTOP_IMPORT_TOKEN_HEADER } from '@sim/desktop-bridge'
12
import { describe, expect, it } from 'vitest'
23
import { createDesktopExecutorClient, UnsendableRequestError } from '@/main/desktop-executor/client'
34

@@ -48,4 +49,38 @@ describe('desktop executor client', () => {
4849
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
4950
expect(loneSurrogate.test(sentMessage)).toBe(false)
5051
})
52+
53+
it('sends an import entry with its execution token in a header, never in the URL', async () => {
54+
const received: Array<{ url: string; token: string | null }> = []
55+
const client = createDesktopExecutorClient({
56+
origin: () => 'https://sim.test',
57+
fetch: async (url, init) => {
58+
received.push({ url, token: new Headers(init.headers).get(DESKTOP_IMPORT_TOKEN_HEADER) })
59+
return Response.json({ id: 'file-1', name: 'notes.txt' })
60+
},
61+
deviceId: '00000000-0000-4000-8000-000000000000',
62+
})
63+
64+
await client.importEntry(
65+
{
66+
call: {
67+
toolCallId: 'call-1',
68+
toolName: 'import_local_files',
69+
args: {},
70+
chatId: 'chat-1',
71+
workspaceId: 'ws-1',
72+
executionToken: 'secret-token-1',
73+
},
74+
kind: 'file',
75+
sourceName: 'notes.txt',
76+
relativePath: '',
77+
content: new Blob(['hello']),
78+
},
79+
new AbortController().signal
80+
)
81+
82+
expect(received).toHaveLength(1)
83+
expect(received[0]?.token).toBe('secret-token-1')
84+
expect(decodeURIComponent(received[0]?.url ?? '')).not.toContain('secret-token-1')
85+
})
5186
})

‎apps/desktop/src/main/desktop-executor/client.ts‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* own session cookie, which is the session the device registered under; Sim refuses any other.
44
*/
55

6+
import { DESKTOP_IMPORT_TOKEN_HEADER } from '@sim/desktop-bridge'
67
import { getErrorMessage } from '@sim/utils/errors'
78
import { parseRetryAfter } from '@sim/utils/retry'
89
import { truncateAtCodePoint } from '@sim/utils/string'
@@ -13,14 +14,19 @@ import {
1314
type DesktopCompletionRequest,
1415
type DesktopDeviceRegistration,
1516
type DesktopExecutorTiming,
17+
type DesktopImportEntryRequest,
18+
type DesktopImportedEntry,
1619
type DesktopInboxItem,
1720
parseClaim,
1821
parseCompletionOutcome,
22+
parseImportedEntry,
1923
parseInbox,
2024
parseRegistration,
2125
} from '@/main/desktop-executor/protocol'
2226

2327
const REQUEST_TIMEOUT_MS = 15_000
28+
/** An import entry carries up to a 64 MB file, so it gets longer than a control request. */
29+
const IMPORT_TIMEOUT_MS = 300_000
2430

2531
/** A request Sim answered with a failure, or that never got an answer (`status` 0). */
2632
export class DeviceRequestError extends Error {
@@ -73,6 +79,10 @@ export interface DesktopExecutorClient {
7379
claim(toolCallId: string): Promise<ClaimedDesktopCall>
7480
renewLease(toolCallId: string, executionToken: string): Promise<void>
7581
complete(request: DesktopCompletionRequest): Promise<DesktopCompletionOutcome>
82+
importEntry(
83+
request: DesktopImportEntryRequest,
84+
signal: AbortSignal
85+
): Promise<DesktopImportedEntry>
7686
}
7787

7888
interface DesktopExecutorClientOptions {
@@ -97,21 +107,27 @@ export function createDesktopExecutorClient(
97107
const { deviceId } = options
98108

99109
async function send(
100-
method: 'GET' | 'POST',
110+
method: 'GET' | 'POST' | 'PUT',
101111
path: string,
102-
body?: Record<string, unknown>,
103-
signal?: AbortSignal
112+
body?: Record<string, unknown> | Blob,
113+
signal?: AbortSignal,
114+
timeoutMs = REQUEST_TIMEOUT_MS,
115+
extraHeaders: Record<string, string> = {}
104116
): Promise<Response> {
105-
const encoded = body ? encode(body) : undefined
106-
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
117+
const timeout = AbortSignal.timeout(timeoutMs)
118+
const raw = body instanceof Blob
119+
const encoded = body === undefined ? undefined : raw ? body : encode(body)
107120
let response: Response
108121
try {
109122
response = await options.fetch(`${options.origin()}${path}`, {
110123
method,
111124
credentials: 'include',
112125
headers: {
113126
Accept: 'application/json',
114-
...(body ? { 'Content-Type': 'application/json' } : {}),
127+
...(body
128+
? { 'Content-Type': raw ? 'application/octet-stream' : 'application/json' }
129+
: {}),
130+
...extraHeaders,
115131
},
116132
...(encoded !== undefined ? { body: encoded } : {}),
117133
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
@@ -204,5 +220,25 @@ export function createDesktopExecutorClient(
204220
if (!outcome) throw malformed('completion')
205221
return outcome
206222
},
223+
async importEntry({ call, kind, sourceName, relativePath, content }, signal) {
224+
const query = new URLSearchParams({
225+
deviceId,
226+
toolCallId: call.toolCallId,
227+
kind,
228+
sourceName,
229+
relativePath,
230+
})
231+
const response = await send(
232+
'PUT',
233+
`/api/desktop/tool/import?${query}`,
234+
content,
235+
signal,
236+
IMPORT_TIMEOUT_MS,
237+
{ [DESKTOP_IMPORT_TOKEN_HEADER]: call.executionToken }
238+
)
239+
const entry = parseImportedEntry(await response.json().catch(() => null))
240+
if (!entry) throw malformed('import')
241+
return entry
242+
},
207243
}
208244
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class FakeSim {
6767
openInboxStream: async () => {
6868
throw new Error('not used')
6969
},
70+
importEntry: async () => {
71+
throw new Error('not used')
72+
},
7073
claim: async (toolCallId) => {
7174
this.claims.push(toolCallId)
7275
const error = this.claimError?.(toolCallId)

‎apps/desktop/src/main/desktop-executor/protocol.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* The device side of Sim's background executor protocol (`/api/desktop/devices`, `/inbox`,
3-
* `/inbox/stream`, `/tool/claim`, `/tool/lease`, `/tool/complete`). Sim's contracts are the
3+
* `/inbox/stream`, `/tool/claim`, `/tool/lease`, `/tool/complete`, `/tool/import`). Sim's contracts are the
44
* source of truth; responses are parsed defensively here because a malformed one must never
55
* reach a tool.
66
*/
@@ -62,6 +62,25 @@ export interface ClaimedDesktopCall {
6262

6363
export type DesktopCompletionOutcome = 'recorded' | 'duplicate' | 'superseded'
6464

65+
/**
66+
* One entry of a claimed import, stored under the call's target folder: `sourceName` is the
67+
* import source's own name and `relativePath` the entry's place inside it (`''` for the source).
68+
*/
69+
export interface DesktopImportEntryRequest {
70+
call: ClaimedDesktopCall
71+
kind: 'file' | 'directory'
72+
sourceName: string
73+
relativePath: string
74+
/** A file's bytes; a directory has none. */
75+
content?: Blob
76+
}
77+
78+
/** What Sim stored an import entry as: the file, or the folder it reused or created. */
79+
export interface DesktopImportedEntry {
80+
id: string
81+
name: string
82+
}
83+
6584
export interface DesktopCompletionRequest {
6685
toolCallId: string
6786
executionToken: string
@@ -175,3 +194,13 @@ export function parseCompletionOutcome(body: unknown): DesktopCompletionOutcome
175194
? body.outcome
176195
: null
177196
}
197+
198+
/**
199+
* Reads Sim's answer to an import entry: the id and name it stored the entry as. Null when either
200+
* is missing or empty, which the client reports as a malformed response.
201+
*/
202+
export function parseImportedEntry(body: unknown): DesktopImportedEntry | null {
203+
if (!isRecordLike(body) || typeof body.id !== 'string' || typeof body.name !== 'string')
204+
return null
205+
return body.id && body.name ? { id: body.id, name: body.name } : null
206+
}

0 commit comments

Comments
 (0)