Skip to content

Commit 6f96d50

Browse files
committed
computer, rpc, computerd: harden JavaScript finalize and add command stdin
Treat a JavaScript execution whose output stream closes without an exit frame as a failure rather than a silent exit 0 with no result: that state means a dropped frame write or a crashed isolate. Serialize the runner's frame writes on a chain the task awaits before closing, so a rejected write is caught instead of surfacing as an unhandled rejection. Thread standard input through the command path so container and worker shell backends receive it: the shell facade, the shell RPC contract and its server, the container runner spawn, and the worker shell entrypoint into just-bash. Reject structured input on the command path as well. Cover the new behavior: a no-exit-frame run settling as failed, stdin forwarded to command backends and to just-bash, stdin fed to a real spawned child, and structured input rejected on a command backend.
1 parent 05fb216 commit 6f96d50

13 files changed

Lines changed: 202 additions & 8 deletions

File tree

packages/computer/src/backends/worker-javascript/worker-javascript.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,57 @@ describe("WorkerJavaScriptBackend", () => {
638638
await handle.close();
639639
});
640640

641+
it("settles as failed when the output stream closes without an exit frame", async () => {
642+
const db = new Database(new SQLiteTestStorage());
643+
initializeSchema(db, () => 0);
644+
const fs = new WorkspaceFilesystem(db);
645+
await fs.mkdir("/workspace", { recursive: true });
646+
const encoder = new TextEncoder();
647+
const backend = new WorkerJavaScriptBackend({
648+
loader: {
649+
load() {
650+
return {
651+
getEntrypoint() {
652+
return {
653+
async evaluate(
654+
_input: unknown,
655+
host: { attachOutput(readable: ReadableStream<Uint8Array>): Promise<void> },
656+
) {
657+
// Emit stdout, then close the stream with no result or
658+
// exit frame, mimicking a dropped terminal write.
659+
const readable = new ReadableStream<Uint8Array>({
660+
start(controller) {
661+
controller.enqueue(
662+
encoder.encode(
663+
`${JSON.stringify({ name: "stdout", b64: btoa("partial\n") })}\n`,
664+
),
665+
);
666+
controller.close();
667+
},
668+
});
669+
await host.attachOutput(readable);
670+
},
671+
};
672+
},
673+
};
674+
},
675+
},
676+
});
677+
const handle = await backend.connect({
678+
db,
679+
fs,
680+
git: undefined as never,
681+
artifacts: undefined as never,
682+
});
683+
const execution = await handle.exec({ id: "no-exit", source: "export default 1" });
684+
const events = [];
685+
for await (const event of execution.events) events.push(event);
686+
const exit = events.find((event) => event.name === "exit");
687+
expect(exit).toMatchObject({ name: "exit", value: 1 });
688+
expect(events.some((event) => event.name === "result")).toBe(false);
689+
await handle.close();
690+
});
691+
641692
it("aborts cooperative trusted-module calls at their deadline", async () => {
642693
const db = new Database(new SQLiteTestStorage());
643694
initializeSchema(db, () => 0);

packages/computer/src/backends/worker-javascript/worker-javascript.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,23 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle {
659659
]);
660660
return;
661661
}
662-
const exitCode = record.exitCode ?? 0;
662+
// No exit frame means the runner never reported a terminal state:
663+
// the output stream closed early, a frame write was dropped, or the
664+
// isolate crashed. Settle as a failure rather than a silent exit 0
665+
// with no result.
666+
if (record.exitCode === undefined) {
667+
this.#finish(record, "failed", [
668+
{
669+
id: record.id,
670+
seq: record.events.length + 1,
671+
name: "stderr",
672+
value: new TextEncoder().encode("Execution ended without reporting a result.\n"),
673+
},
674+
{ id: record.id, seq: record.events.length + 2, name: "exit", value: 1 },
675+
]);
676+
return;
677+
}
678+
const exitCode = record.exitCode;
663679
const terminal: WorkspaceRuntimeEvent[] = [];
664680
if (exitCode === 0 && record.hasResult) {
665681
terminal.push({
@@ -1054,10 +1070,14 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) {
10541070
}
10551071
return btoa(binary);
10561072
};
1073+
// Serialize writes on a chain so a rejected write (the host
1074+
// cancelled or the stream errored) is caught here rather than
1075+
// surfacing as an unhandled rejection inside the isolate. The
1076+
// task awaits writeChain before closing.
1077+
let writeChain = Promise.resolve();
10571078
const enqueue = (frame) => {
1058-
try {
1059-
writer.write(encoder.encode(JSON.stringify(frame) + "\\n"));
1060-
} catch {}
1079+
const bytes = encoder.encode(JSON.stringify(frame) + "\\n");
1080+
writeChain = writeChain.then(() => writer.write(bytes)).catch(() => {});
10611081
};
10621082
let stdioBytes = 0;
10631083
let stdioTruncated = false;
@@ -1145,6 +1165,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) {
11451165
enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") });
11461166
enqueue({ name: "exit", value: 1 });
11471167
}
1168+
await writeChain;
11481169
try {
11491170
await writer.close();
11501171
} catch {}

packages/computer/src/backends/worker-shell/entrypoint.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ class TestShellWorker extends ShellWorker {
3333
env: E,
3434
bashFactory: (
3535
command: string,
36-
options: { cwd?: string; env?: Record<string, string>; signal?: AbortSignal },
36+
options: {
37+
cwd?: string;
38+
env?: Record<string, string>;
39+
stdin?: Uint8Array;
40+
signal?: AbortSignal;
41+
},
3742
) => Promise<{ stdout: string; stderr: string; exitCode: number }>,
3843
): TestShellWorker {
3944
const w = new TestShellWorker(undefined as never, env as never);
@@ -203,6 +208,17 @@ describe("ShellWorker", () => {
203208
expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" });
204209
});
205210

211+
it("forwards per-execution stdin bytes to Bash", async () => {
212+
let observedStdin: Uint8Array | undefined;
213+
const worker = TestShellWorker.withFakeBash(fakeEnv(), async (_command, options) => {
214+
observedStdin = options.stdin;
215+
return { stdout: "", stderr: "", exitCode: 0 };
216+
});
217+
const bytes = new TextEncoder().encode("piped");
218+
await drain((await worker.exec({ command: "cat", stdin: bytes })).events);
219+
expect(observedStdin).toEqual(bytes);
220+
});
221+
206222
it("getExec without a prior exec throws ENOENT", async () => {
207223
const worker = new TestShellWorker(undefined as never, fakeEnv() as never);
208224
await expect(worker.getExec({ id: "missing" })).rejects.toMatchObject({ code: "ENOENT" });

packages/computer/src/backends/worker-shell/entrypoint.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface ExecInput {
3131
id?: string;
3232
timeoutMs?: number;
3333
env?: Record<string, string>;
34+
stdin?: Uint8Array;
3435
}
3536

3637
export interface ShellWorkerOptions {
@@ -107,6 +108,7 @@ export class ShellWorker<
107108
options: {
108109
cwd?: string;
109110
env?: Record<string, string>;
111+
stdin?: Uint8Array;
110112
signal?: AbortSignal;
111113
customCommands: CustomCommand[];
112114
},
@@ -174,6 +176,7 @@ export class ShellWorker<
174176
result = await this.bashFactoryOverride(input.command, {
175177
cwd,
176178
env: input.env,
179+
stdin: input.stdin,
177180
signal: controller.signal,
178181
customCommands,
179182
});
@@ -195,7 +198,14 @@ export class ShellWorker<
195198
defenseInDepth: { enabled: false },
196199
executionLimits: { maxOutputSize: MAX_OUTPUT_BYTES },
197200
});
198-
result = await bash.exec(input.command, { cwd, env: input.env, signal: controller.signal });
201+
result = await bash.exec(input.command, {
202+
cwd,
203+
env: input.env,
204+
...(input.stdin !== undefined
205+
? { stdin: latin1FromBytes(input.stdin), stdinKind: "bytes" as const }
206+
: {}),
207+
signal: controller.signal,
208+
});
199209
}
200210
} catch (error) {
201211
const message = error instanceof Error ? error.message : String(error);
@@ -263,6 +273,16 @@ function framedStream(events: WireEvent[]): ReadableStream<Uint8Array> {
263273
});
264274
}
265275

276+
// just-bash's `stdin` with `stdinKind: "bytes"` carries each byte as one
277+
// latin1 char. Pack the caller's bytes into that shape.
278+
function latin1FromBytes(bytes: Uint8Array): string {
279+
let result = "";
280+
for (let index = 0; index < bytes.length; index += 1) {
281+
result += String.fromCharCode(bytes[index]);
282+
}
283+
return result;
284+
}
285+
266286
function createShellError(code: string, message: string): Error & { code: string } {
267287
const error = new Error(message) as Error & { code: string };
268288
error.name = "ShellWorkerError";

packages/computer/src/backends/worker-shell/worker-shell.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface WorkerShellFetcher {
3939
id?: string;
4040
timeoutMs?: number;
4141
env?: Record<string, string>;
42+
stdin?: Uint8Array;
4243
}): Promise<{
4344
id: string;
4445
events: ReadableStream<Uint8Array>;
@@ -175,6 +176,7 @@ export class WorkerShellBackend implements WorkspaceBackend {
175176
id: input.id,
176177
timeoutMs: input.timeoutMs,
177178
env: input.env,
179+
stdin: input.stdin,
178180
});
179181
return { id: envelope.id, events: decodeFramedEvents(envelope.events) };
180182
},

packages/computer/src/runtime/runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class WorkspaceRuntime {
6666
id: options.id,
6767
timeoutMs: options.timeoutMs,
6868
env: options.env,
69+
stdin: options.stdin,
6970
});
7071
return wrapCommandHandle(handle, backend);
7172
}

packages/computer/src/shell.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ export interface ExecOptions<E extends ExecEncoding = undefined> {
109109
// override the backend's base environment without changing later
110110
// executions.
111111
env?: Record<string, string>;
112+
// Standard input fed to the command. Bytes, or a string encoded
113+
// as UTF-8.
114+
stdin?: Uint8Array | string;
112115
// Backend selector. Omit to use the default backend (the first
113116
// one passed to the Workspace constructor); pass the id of
114117
// another configured backend to route this call there.
@@ -182,6 +185,10 @@ export class WorkspaceShell {
182185
cwd: options.cwd,
183186
timeoutMs: options.timeoutMs,
184187
env: options.env,
188+
stdin:
189+
typeof options.stdin === "string"
190+
? new TextEncoder().encode(options.stdin)
191+
: options.stdin,
185192
}),
186193
(span, outcome) => {
187194
if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id);

packages/computer/src/workspace.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,46 @@ describe("Workspace backend selection", () => {
243243
expect(result).toMatchObject({ status: "completed", exitCode: 0 });
244244
});
245245

246+
it("rejects structured input for a non-callable command backend", async () => {
247+
const backend = execBackend("command", () => {});
248+
const ws = new Workspace({ storage: makeStorage(), backends: [backend] });
249+
await expect(ws.runtime.exec("true", { backend: "command", input: { a: 1 } })).rejects.toThrow(
250+
/not callable/,
251+
);
252+
});
253+
254+
it("forwards per-execution stdin to command backends", async () => {
255+
let receivedStdin: Uint8Array | undefined;
256+
const shell: import("@cloudflare/computer-rpc").ShellRPC = {
257+
async exec(input) {
258+
receivedStdin = input.stdin;
259+
const id = input.id ?? "stdin-command";
260+
return {
261+
id,
262+
events: new ReadableStream({
263+
start(controller) {
264+
controller.enqueue({ id, seq: 1, name: "exit", value: 0 });
265+
controller.close();
266+
},
267+
}),
268+
};
269+
},
270+
getExec: () => Promise.reject(new Error("not used")),
271+
killExec: () => Promise.reject(new Error("not used")),
272+
disposeExec: async () => undefined,
273+
};
274+
const backend: WorkspaceBackend = {
275+
id: "command",
276+
type: "fake",
277+
async connect() {
278+
return { rpc: { sync: fakeRpc(), shell }, sync: "none", close: async () => undefined };
279+
},
280+
};
281+
const ws = new Workspace({ storage: makeStorage(), backends: [backend] });
282+
await drainExec(await ws.runtime.exec("cat", { backend: "command", stdin: "piped" }));
283+
expect(receivedStdin).toEqual(new TextEncoder().encode("piped"));
284+
});
285+
246286
it("forwards per-execution environment variables to command backends", async () => {
247287
let receivedEnv: Record<string, string> | undefined;
248288
const shell: import("@cloudflare/computer-rpc").ShellRPC = {

packages/computerd/src/exec/runner.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ test("per-execution env overrides the base env without leaking to later commands
103103
}
104104
});
105105

106+
test("feeds per-execution stdin to the child and closes it", async () => {
107+
const { runner, dispose } = fixture();
108+
try {
109+
const handle = runner.exec("cat", { stdin: new TextEncoder().encode("piped-input") });
110+
const events = await drain(handle.events);
111+
const stdout = events
112+
.filter((event) => event.name === "stdout")
113+
.map((event) => decode(event.value as Uint8Array))
114+
.join("");
115+
const exit = events.find((event) => event.name === "exit");
116+
expect(stdout).toBe("piped-input");
117+
expect(exit?.value).toBe(0);
118+
} finally {
119+
dispose();
120+
}
121+
});
122+
106123
test("reusing a live id throws EEXEC_BUSY", async () => {
107124
const { runner, dispose } = fixture();
108125
try {

packages/computerd/src/exec/runner.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,15 @@ export class Runner {
144144
const wrapped = cwd !== undefined ? `cd ${shellQuote(cwd)} && ${command}` : command;
145145
const child = spawn("/bin/sh", ["-c", wrapped], {
146146
env,
147-
stdio: ["ignore", "pipe", "pipe"],
147+
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
148148
});
149+
if (options.stdin !== undefined && child.stdin) {
150+
// Feed the caller's bytes then close so the child sees EOF.
151+
// Ignore write/EPIPE errors: a command that never reads stdin
152+
// (or exits first) must not fail the run.
153+
child.stdin.on("error", () => {});
154+
child.stdin.end(Buffer.from(options.stdin));
155+
}
149156
const log = createLog(this.db, id, {
150157
maxBytes: this.opts.logMaxBytes,
151158
now: this.opts.now,

0 commit comments

Comments
 (0)