From 3069ccf5cc7ec5473f26d8b7c890f937c2395a4f Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 2 Oct 2026 10:22:25 +0100 Subject: [PATCH 1/6] computer: Mention -C in ws:git's description ws:git accepts a leading -C in cli, but its description for the model did not say so, so a model reading it had no reason to use it. --- packages/computer/src/modules/git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/computer/src/modules/git.ts b/packages/computer/src/modules/git.ts index cce42eed..33b3380b 100644 --- a/packages/computer/src/modules/git.ts +++ b/packages/computer/src/modules/git.ts @@ -85,7 +85,7 @@ export function createGitModule(options: GitModuleOptions = {}): WorkspaceModule }, }); return Object.assign(create, { - description: `The workspace's Git repository tools: \`status({ dir })\`, \`diff({ dir })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, + description: `The workspace's Git repository tools: \`status({ dir })\`, \`diff({ dir })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand, including a leading \`-C \`.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, }); } From 35cb79dbb5e750ee72415a5c154d96d9063e3f71 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 2 Oct 2026 10:22:26 +0100 Subject: [PATCH 2/6] computer: Leave publish out of tools built from a remote client without assets A remote client's assets getter returned the stub's assets property, which over RPC is always a placeholder, never undefined. createAITools therefore offered publish on a remote client even when the Workspace had no assets publisher, and calling it failed because the receiver did not implement assets. The Workspace stub now exposes a plain hasAssets boolean, which the client reads once when it is created, as it already does for useThink. A client without assets reports undefined, locally and remotely. --- .changeset/remote-client-assets.md | 5 +++++ packages/computer/src/client.test.ts | 20 ++++++++++++++++++++ packages/computer/src/client.ts | 7 ++++++- packages/computer/src/stub.ts | 7 +++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/remote-client-assets.md diff --git a/.changeset/remote-client-assets.md b/.changeset/remote-client-assets.md new file mode 100644 index 00000000..64ef299d --- /dev/null +++ b/.changeset/remote-client-assets.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A remote `WorkspaceClient` from `getWorkspace(stub)` reports `assets` as `undefined` when the Workspace has no assets publisher, as a local client does. `createAITools` built from a remote client no longer offers a `publish` tool that fails when called. diff --git a/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index 99e0e982..28b2ad3b 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -426,6 +426,26 @@ describe("getWorkspace — backend information", () => { }); } + for (const [path, connect] of [ + ["local", (ws: Workspace) => getWorkspace({ [WORKSPACE]: ws })], + [ + "remote", + (ws: Workspace) => getWorkspace({ __getWorkspaceStub: () => Promise.resolve(ws.stub()) }), + ], + ] as const) { + it(`leaves publish out on a ${path} client without assets`, async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [echoBackend()], + }); + const client = await connect(workspace); + + expect(client.assets).toBeUndefined(); + expect(createAITools({ workspace: client }).publish).toBeUndefined(); + await workspace.close(); + }); + } + it("keeps its backend snapshot from being edited", async () => { const client = await getWorkspace({ [WORKSPACE]: new Workspace({ storage: new SQLiteTestStorage(), backends: [echoBackend()] }), diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index dd49f8b6..db89f879 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -356,6 +356,7 @@ function makeClient( dispose: () => void, useThink: boolean, backends: readonly WorkspaceBackendInfo[], + hasAssets: boolean, ): WorkspaceClient { const runtime = makeRuntimeClient( surface.runtime as UnderlyingRuntime, @@ -370,8 +371,10 @@ function makeClient( get git() { return surface.git; }, + // Undefined when the Workspace has no assets publisher, so tools + // built from the client leave `publish` out, as they do locally. get assets() { - return surface.assets; + return hasAssets ? surface.assets : undefined; }, get artifacts() { return surface.artifacts; @@ -409,6 +412,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise {}, local.useThink, local.runtime.backends(), + local.assets !== undefined, ); } // Remote path: fetch the stub over RPC and delegate to it. Handle @@ -424,6 +428,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise void })[Symbol.dispose]?.(); diff --git a/packages/computer/src/stub.ts b/packages/computer/src/stub.ts index 2759b141..9e7d058f 100644 --- a/packages/computer/src/stub.ts +++ b/packages/computer/src/stub.ts @@ -667,6 +667,13 @@ export class WorkspaceStub extends RpcTarget { return this.#assets; } + // Whether the Workspace has an assets publisher. Reading `assets` + // over RPC always yields a placeholder, so a client asks this plain + // boolean once instead. + get hasAssets(): boolean { + return this.#assets !== undefined; + } + get artifacts(): WorkspaceArtifactsStub { return this.#artifacts; } From b56699e3ee767884b79bc64f44605852c53801d0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 2 Oct 2026 10:22:27 +0100 Subject: [PATCH 3/6] computer: Drop undefined result fields and reject cyclic arguments cleanly A run that returned { kept: 1, dropped: undefined } failed with "must be JSON-compatible values", although the result is framed as JSON, which drops undefined fields. Values like that are common in ordinary code, so the check now treats an undefined field as absent. The isolate's early size check walked arguments without tracking what it had seen, so a cyclic argument overflowed the stack. It now stops at a repeated object and reports that values must be acyclic, the same wording the host uses. --- .changeset/isolate-values.md | 5 +++ .../worker-javascript/module-graph.ts | 14 ++++---- packages/computer/src/runtime/capability.ts | 6 +++- packages/computer/tests/script-runner.test.ts | 36 +++++++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 .changeset/isolate-values.md diff --git a/.changeset/isolate-values.md b/.changeset/isolate-values.md new file mode 100644 index 00000000..bf76fc94 --- /dev/null +++ b/.changeset/isolate-values.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A `WorkerJavaScriptBackend` run that returns an object with `undefined` fields now completes with those fields dropped, as `JSON.stringify` does, instead of failing with "must be JSON-compatible values". A cyclic argument to a `node:fs` or host module call now fails with a clear "must be acyclic" error instead of a stack overflow. diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index 08de6ac8..34e9026c 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -359,14 +359,16 @@ function capabilitiesModule(maxCapabilityBytes: number) { } return payload.result; } - function approximateBytes(value) { + function approximateBytes(value, seen = new Set()) { if (typeof value === "string") return value.length; if (value instanceof Uint8Array) return value.byteLength; - if (Array.isArray(value)) return value.reduce((total, item) => total + approximateBytes(item), 8); - if (value && typeof value === "object") { - return Object.entries(value).reduce((total, [key, item]) => total + key.length + approximateBytes(item), 8); - } - return 8; + if (!value || typeof value !== "object") return 8; + if (seen.has(value)) throw new Error("Workspace capability request values must be acyclic."); + seen.add(value); + const entries = Array.isArray(value) ? value.map((item) => ["", item]) : Object.entries(value); + const total = entries.reduce((sum, [key, item]) => sum + key.length + approximateBytes(item, seen), 8); + seen.delete(value); + return total; } `; } diff --git a/packages/computer/src/runtime/capability.ts b/packages/computer/src/runtime/capability.ts index 96e8a500..af78ec62 100644 --- a/packages/computer/src/runtime/capability.ts +++ b/packages/computer/src/runtime/capability.ts @@ -274,6 +274,10 @@ function assertValue(value: unknown, seen: WeakSet): void { if (prototype !== Object.prototype && prototype !== null) { throw new Error("Workspace code inputs and results must use plain objects."); } - for (const item of Object.values(value)) assertValue(item, seen); + // An undefined field is absent, as with JSON.stringify, which drops + // it when the value is framed. + for (const item of Object.values(value)) { + if (item !== undefined) assertValue(item, seen); + } seen.delete(value); } diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index cdda1a21..f01debf8 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -398,6 +398,42 @@ describe("WorkspaceRuntime", () => { expect(JSON.parse(text).result.value).toContain('unknown option "shell"'); }); + it("rejects a cyclic argument with a clear error", async () => { + const response = await runtime({ + source: ` + import { echo } from "ws:test-host"; + export default async () => { + const value = {}; + value.self = value; + try { + await echo(value); + return "sent"; + } catch (error) { + return error.message; + } + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result.value, text).toContain("acyclic"); + }); + + it("drops undefined fields from a run result, as JSON does", async () => { + const response = await runtime({ + source: `export default () => ({ kept: 1, dropped: undefined, nested: { also: undefined } });`, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result, text).toMatchObject({ + status: "completed", + value: { kept: 1, nested: {} }, + }); + expect(JSON.parse(text).result.value).not.toHaveProperty("dropped"); + }); + it("does not expose unrestricted host operations through the node:fs dispatcher", async () => { const response = await runtime({ source: ` From 5ed02a743337517545e038e43547b15c8daf2236 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 2 Oct 2026 10:22:28 +0100 Subject: [PATCH 4/6] computer: Report ws:container's file sync to the caller ws:container returned the exit code and output but dropped the sync result, so code that called exec could not tell whether the container's file changes had reached the Workspace, or which ones the Workspace had refused. exec now also returns sync: its status, the skipped paths, and the error when the pull is still pending. The module's description and the docs also say the sync is last-writer-wins, because a file the isolate writes while the command runs is replaced by the container's version without being reported as skipped. --- .changeset/container-sync-report.md | 5 +++ docs/17_isolate_javascript.md | 2 ++ .../computer/src/modules/container.test.ts | 35 ++++++++++++++++++- packages/computer/src/modules/container.ts | 18 +++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .changeset/container-sync-report.md diff --git a/.changeset/container-sync-report.md b/.changeset/container-sync-report.md new file mode 100644 index 00000000..f61c8a07 --- /dev/null +++ b/.changeset/container-sync-report.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +`ws:container`'s `exec` also returns `sync: { status, skipped, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace, and `skipped` lists paths the Workspace refused, such as files in a read-only mount. The docs now state that the sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 9a702bd4..7ab2f4e4 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -287,6 +287,8 @@ export default async function () { `exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend: `ContainerBackend`, registered as `"container-shell"` unless you pass `backend`. If that backend is missing, or runs module source rather than shell commands, the JavaScript backend fails to connect. The container shares the Workspace's files: writes the module made before the call are pushed to the container, and the container's changes are pulled back before `exec` returns. A non-zero exit code comes back as a value, not as an error. +The result also carries `sync`: `{ status, skipped, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace yet, and `error` says why. `skipped` lists paths the container wrote that the Workspace refused, such as files in a read-only mount. The sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs, without reporting them as skipped. Don't write files from the isolate that the running command also writes. + A few limits follow from `exec` being a host call: - Output comes back when the command finishes, not while it runs. Each stream keeps its last 2000 lines or `maxOutputBytes` (64 KiB by default), which must stay well under the backend's `maxCapabilityBytes`. When a stream is cut, the result's `truncated.stdout.path` (or `stderr`) names a Workspace file holding all of it (see [Long output](./05_runtime_interface.md#long-output)), and the host never holds more than the end in memory. The default directory, `/.computer/output`, is outside the backend's default `root` (`/workspace`), so isolate code cannot open it with `node:fs`; the agent's `read` and `grep` tools can. Set the Workspace's `output.dir` under `root` if isolate code needs to read it. diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts index 88733383..caf6f68a 100644 --- a/packages/computer/src/modules/container.test.ts +++ b/packages/computer/src/modules/container.test.ts @@ -5,6 +5,7 @@ import type { WorkspaceModuleFunction, WorkspaceModuleHost, } from "../runtime/types.js"; +import type { ExecSyncResult } from "../shell.js"; import { createContainerModule } from "./container.js"; interface ExecOptions { @@ -31,6 +32,7 @@ function fakeRuntime(output: { stderr?: string; hang?: boolean; truncated?: Record; + sync?: ExecSyncResult; }) { const runs: Run[] = []; const runtime = { @@ -54,6 +56,7 @@ function fakeRuntime(output: { stdout: output.stdout ?? "", stderr: output.stderr ?? "", ...(output.truncated === undefined ? {} : { truncated: output.truncated }), + sync: output.sync ?? { status: "complete" as const, applied: 0, skipped: [] }, }; }, async kill() { @@ -101,7 +104,12 @@ describe("createContainerModule", () => { ["npm test", { cwd: "/workspace/app", env: { CI: "1" }, stdin: "y\n" }], callContext(), ), - ).resolves.toEqual({ exitCode: 3, stdout: "out", stderr: "err" }); + ).resolves.toEqual({ + exitCode: 3, + stdout: "out", + stderr: "err", + sync: { status: "complete", skipped: [] }, + }); expect(runs).toHaveLength(1); expect(runs[0]).toMatchObject({ command: "npm test", @@ -194,6 +202,7 @@ describe("createContainerModule", () => { stdout: "tail\n", stderr: "", truncated: { stdout: saved }, + sync: { status: "complete", skipped: [] }, }); expect(runs[0]?.options.output).toEqual({ maxBytes: 1024 }); }); @@ -206,6 +215,7 @@ describe("createContainerModule", () => { exitCode: 0, stdout: "a🙂\n\n[truncated, 1 more bytes]", stderr: "🙂\n\n[truncated, 4 more bytes]", + sync: { status: "complete", skipped: [] }, }); }); @@ -249,6 +259,29 @@ describe("createContainerModule", () => { ); }); + it("reports a sync that has not reached the Workspace, and skipped paths", async () => { + const { runtime } = fakeRuntime({ + sync: { + status: "pending", + applied: 1, + error: "pull failed", + skipped: [ + { + path: "/workspace/ro/x.txt", + mountRoot: "/workspace/ro", + op: "write", + reason: "read-only", + }, + ], + }, + }); + const container = build(runtime); + + await expect(container.exec(["touch ro/x.txt"], callContext())).resolves.toMatchObject({ + sync: { status: "pending", error: "pull failed", skipped: ["/workspace/ro/x.txt"] }, + }); + }); + it("describes itself for a model", () => { expect(createContainerModule().description).toContain("full Linux container"); }); diff --git a/packages/computer/src/modules/container.ts b/packages/computer/src/modules/container.ts index 0f300ce0..eed9e04a 100644 --- a/packages/computer/src/modules/container.ts +++ b/packages/computer/src/modules/container.ts @@ -21,6 +21,7 @@ import type { WorkspaceModuleHost, WorkspaceRuntimeValue, } from "../runtime/types.js"; +import type { ExecSyncResult } from "../shell.js"; import { truncateText } from "../text-truncation.js"; const DEFAULT_BACKEND = "container-shell"; @@ -46,7 +47,9 @@ export interface ContainerModuleOptions { * backend. * * It exports `exec(command, { cwd, env, stdin, timeoutMs })`, which - * returns `{ exitCode, stdout, stderr }` once the command finishes. A + * returns `{ exitCode, stdout, stderr, sync }` once the command + * finishes. `sync` reports whether the container's file changes reached + * the Workspace, and which paths it skipped. A * non-zero exit code is a normal result, not an error. Cancelling the * execution kills the command. * @@ -132,6 +135,7 @@ export function createContainerModule( ? truncateText(result.stderr, maxOutputBytes) : result.stderr, ...(result.truncated === undefined ? {} : { truncated: { ...result.truncated } }), + sync: syncSummary(result.sync), }; } finally { context.signal.removeEventListener("abort", kill); @@ -145,8 +149,20 @@ const DESCRIPTION = [ "Use it for npm, node, python, package managers, and native binaries. The container can take a while to start on first use.", 'Call `const { exitCode, stdout, stderr } = await exec("npm test", { cwd: "/workspace" })`. Options are `cwd`, `env`, `stdin`, and `timeoutMs`.', "Output comes back when the command finishes. Long output keeps its last lines; `truncated.stdout.path` (or `truncated.stderr.path`) then names a workspace file holding all of it. The file usually sits outside this code's `node:fs` root, so return the path and open it with the agent's read or grep tools. A non-zero `exitCode` is returned, not thrown.", + "`sync.status` is `pending` if the container's file changes have not reached the workspace yet. The container's changes win over files written meanwhile, so do not write files the command also writes while it runs.", ].join(" "); +// How the container's file changes came back to the Workspace. A +// "pending" status means they did not, yet; `skipped` lists paths the +// container wrote that the Workspace refused, such as read-only mounts. +function syncSummary(sync: ExecSyncResult) { + return { + status: sync.status, + skipped: sync.skipped.map((entry) => entry.path), + ...(sync.status === "pending" && sync.error !== undefined ? { error: sync.error } : {}), + }; +} + interface ExecRequest { readonly command: string; readonly cwd: string | undefined; From b4e61b7223e901f33c749d12126176e77228b05e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 2 Oct 2026 13:33:03 +0100 Subject: [PATCH 5/6] computer: Cap ws:container's skipped-path list A command that wrote thousands of files into a read-only mount made the sync summary larger than the bridge's response limits allow. exec then failed after the command had already run and its changes had been pulled, losing the exit code and inviting a retry that repeats the side effects. The summary now lists at most 100 skipped paths, adds skippedCount with the full count, and cuts a pending sync's error to 1 KiB. --- .changeset/container-sync-report.md | 2 +- docs/17_isolate_javascript.md | 2 +- .../computer/src/modules/container.test.ts | 31 +++++++++++++++++-- packages/computer/src/modules/container.ts | 16 ++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.changeset/container-sync-report.md b/.changeset/container-sync-report.md index f61c8a07..4b39440f 100644 --- a/.changeset/container-sync-report.md +++ b/.changeset/container-sync-report.md @@ -2,4 +2,4 @@ "@cloudflare/computer": patch --- -`ws:container`'s `exec` also returns `sync: { status, skipped, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace, and `skipped` lists paths the Workspace refused, such as files in a read-only mount. The docs now state that the sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs. +`ws:container`'s `exec` also returns `sync: { status, skipped, skippedCount, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace, and `skipped` lists up to 100 paths the Workspace refused, such as files in a read-only mount, with `skippedCount` giving the full count. The docs now state that the sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 7ab2f4e4..00481e6c 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -287,7 +287,7 @@ export default async function () { `exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend: `ContainerBackend`, registered as `"container-shell"` unless you pass `backend`. If that backend is missing, or runs module source rather than shell commands, the JavaScript backend fails to connect. The container shares the Workspace's files: writes the module made before the call are pushed to the container, and the container's changes are pulled back before `exec` returns. A non-zero exit code comes back as a value, not as an error. -The result also carries `sync`: `{ status, skipped, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace yet, and `error` says why. `skipped` lists paths the container wrote that the Workspace refused, such as files in a read-only mount. The sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs, without reporting them as skipped. Don't write files from the isolate that the running command also writes. +The result also carries `sync`: `{ status, skipped, skippedCount, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace yet, and `error` says why. `skipped` lists up to 100 paths the container wrote that the Workspace refused, such as files in a read-only mount, and `skippedCount` gives the full count. The sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs, without reporting them as skipped. Don't write files from the isolate that the running command also writes. A few limits follow from `exec` being a host call: diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts index caf6f68a..8f74d2aa 100644 --- a/packages/computer/src/modules/container.test.ts +++ b/packages/computer/src/modules/container.test.ts @@ -108,7 +108,7 @@ describe("createContainerModule", () => { exitCode: 3, stdout: "out", stderr: "err", - sync: { status: "complete", skipped: [] }, + sync: { status: "complete", skipped: [], skippedCount: 0 }, }); expect(runs).toHaveLength(1); expect(runs[0]).toMatchObject({ @@ -215,7 +215,7 @@ describe("createContainerModule", () => { exitCode: 0, stdout: "a🙂\n\n[truncated, 1 more bytes]", stderr: "🙂\n\n[truncated, 4 more bytes]", - sync: { status: "complete", skipped: [] }, + sync: { status: "complete", skipped: [], skippedCount: 0 }, }); }); @@ -278,10 +278,35 @@ describe("createContainerModule", () => { const container = build(runtime); await expect(container.exec(["touch ro/x.txt"], callContext())).resolves.toMatchObject({ - sync: { status: "pending", error: "pull failed", skipped: ["/workspace/ro/x.txt"] }, + sync: { + status: "pending", + error: "pull failed", + skipped: ["/workspace/ro/x.txt"], + skippedCount: 1, + }, }); }); + it("caps a large skipped list so the result fits the bridge limits", async () => { + const skipped = Array.from({ length: 5000 }, (_, index) => ({ + path: `/workspace/ro/${index}.txt`, + mountRoot: "/workspace/ro", + op: "write" as const, + reason: "read-only" as const, + })); + const { runtime } = fakeRuntime({ + sync: { status: "pending", applied: 0, error: "e".repeat(10_000), skipped }, + }); + const container = build(runtime); + + const result = (await container.exec(["touch ro/*"], callContext())) as { + sync: { skipped: string[]; skippedCount: number; error: string }; + }; + expect(result.sync.skipped).toHaveLength(100); + expect(result.sync.skippedCount).toBe(5000); + expect(new TextEncoder().encode(result.sync.error).byteLength).toBeLessThan(1200); + }); + it("describes itself for a model", () => { expect(createContainerModule().description).toContain("full Linux container"); }); diff --git a/packages/computer/src/modules/container.ts b/packages/computer/src/modules/container.ts index eed9e04a..63040fd6 100644 --- a/packages/computer/src/modules/container.ts +++ b/packages/computer/src/modules/container.ts @@ -27,6 +27,8 @@ import { truncateText } from "../text-truncation.js"; const DEFAULT_BACKEND = "container-shell"; const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024; const EXEC_OPTION_KEYS = new Set(["cwd", "env", "stdin", "timeoutMs"]); +const MAX_SKIPPED_PATHS = 100; +const MAX_SYNC_ERROR_BYTES = 1024; /** Options for {@link createContainerModule}. */ export interface ContainerModuleOptions { @@ -49,7 +51,7 @@ export interface ContainerModuleOptions { * It exports `exec(command, { cwd, env, stdin, timeoutMs })`, which * returns `{ exitCode, stdout, stderr, sync }` once the command * finishes. `sync` reports whether the container's file changes reached - * the Workspace, and which paths it skipped. A + * the Workspace, the first 100 paths it skipped, and `skippedCount`. A * non-zero exit code is a normal result, not an error. Cancelling the * execution kills the command. * @@ -155,11 +157,19 @@ const DESCRIPTION = [ // How the container's file changes came back to the Workspace. A // "pending" status means they did not, yet; `skipped` lists paths the // container wrote that the Workspace refused, such as read-only mounts. +// +// The list is capped, and the error cut short, so a command that skips +// thousands of paths still fits within the bridge's response limits; +// otherwise the call would fail after the command had already run. +// `skippedCount` is the full count. function syncSummary(sync: ExecSyncResult) { return { status: sync.status, - skipped: sync.skipped.map((entry) => entry.path), - ...(sync.status === "pending" && sync.error !== undefined ? { error: sync.error } : {}), + skipped: sync.skipped.slice(0, MAX_SKIPPED_PATHS).map((entry) => entry.path), + skippedCount: sync.skipped.length, + ...(sync.status === "pending" && sync.error !== undefined + ? { error: truncateText(sync.error, MAX_SYNC_ERROR_BYTES) } + : {}), }; } From ae388a38ff5ff75b1535f329337aead5811e940a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 6 Oct 2026 14:38:30 -0700 Subject: [PATCH 6/6] computer: Expect skippedCount in the ws:container truncation test --- packages/computer/src/modules/container.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts index 8f74d2aa..2001f67f 100644 --- a/packages/computer/src/modules/container.test.ts +++ b/packages/computer/src/modules/container.test.ts @@ -202,7 +202,7 @@ describe("createContainerModule", () => { stdout: "tail\n", stderr: "", truncated: { stdout: saved }, - sync: { status: "complete", skipped: [] }, + sync: { status: "complete", skipped: [], skippedCount: 0 }, }); expect(runs[0]?.options.output).toEqual({ maxBytes: 1024 }); });