Skip to content

Commit 5f310b6

Browse files
aron-cfHarness Agent
andauthored
computerd: let the caller choose the exec shell (#139)
Every exec command runs through a hardcoded /bin/sh. On a Debian-family image that is dash, where bash-only syntax is a parse error rather than a missing feature, so the whole command aborts instead of degrading. That matters most for the PIPESTATUS array: a caller that filters a command's output, such as piping git through sed to redact a credential from the URL, gets the pipeline's last exit status and reads a failed push as a success. PIPESTATUS is how the real status is recovered, and under dash reaching for it is worse than the problem it was reached for. Add an optional shell to the runner options, defaulting to /bin/sh, and read the same value from EXEC_SHELL so a deployed daemon can set it without a rebuild. Existing callers are unaffected. The previous workaround was to repoint /bin/sh inside the image, which changes echo semantics for every other script there and is unavailable when the image is prebuilt. Co-authored-by: Harness Agent <agent@harness.local>
1 parent 2a84578 commit 5f310b6

6 files changed

Lines changed: 109 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/computerd": minor
3+
---
4+
5+
The exec runner now takes an optional shell naming the interpreter each command runs under, and computerd reads the same value from EXEC_SHELL. Both default to /bin/sh, so existing behavior is unchanged. On a Debian-family image /bin/sh is dash, where bash-only syntax such as the PIPESTATUS array is a parse error that aborts the command rather than a missing feature, and that array is how a caller recovers the real exit status of a pipeline whose output it filters. Repointing /bin/sh in the image was the only previous workaround, which changes echo semantics for every other script in that image and is unavailable when the image is prebuilt.

packages/computerd/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,13 @@ Additional environment variables:
113113

114114
```sh
115115
EXEC_LOG_MAX_BYTES=1048576 # cap the in-memory exec log buffer (bytes)
116+
EXEC_SHELL=/usr/bin/bash # interpreter exec runs commands under (default /bin/sh)
116117
RPC_CLIENT_SECRET=<secret> # require Authorization: Bearer <secret> on every route but /health
117118
COMPUTER_VAR_NODE_ENV=production # forwarded into exec as NODE_ENV
118119
```
119120

121+
`EXEC_SHELL` must be an absolute path. It exists because `/bin/sh` is `dash` on a Debian-family image, where bash-only syntax is a parse error that aborts the command rather than a missing feature: `${PIPESTATUS[@]}`, arrays, `[[ ... ]]`, and process substitution all fail that way. `PIPESTATUS` is the usual way to recover the real exit status of a pipeline whose output is filtered — a command redacting a credential through `sed`, for instance — so a caller that needs it can select an interpreter that has it without repointing `/bin/sh` for every other script in the image.
122+
120123
`FUSE_MOUNT=auto` is the friendly default: if `/dev/fuse` (or macFUSE) is available `computerd` mounts a real FUSE filesystem, otherwise it transparently falls back to the userspace shim. Pin the value (`fuse` / `macfuse` / `shim` / `none`) when a test needs to assert a specific code path.
121124

122125
## `FUSE_MOUNT=shim` — userspace dev shim

packages/computerd/src/cli/computerd.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,12 +636,23 @@ async function main(): Promise<void> {
636636
}
637637
logMaxBytesOverride = parsed;
638638
}
639+
// EXEC_SHELL picks the interpreter exec runs commands under, for images
640+
// whose /bin/sh cannot be repointed. Default lives in the Runner.
641+
const shellEnv = process.env.EXEC_SHELL;
642+
let shellOverride: string | undefined;
643+
if (shellEnv !== undefined && shellEnv !== "") {
644+
if (!shellEnv.startsWith("/")) {
645+
throw new Error(`EXEC_SHELL must be an absolute path; got ${JSON.stringify(shellEnv)}`);
646+
}
647+
shellOverride = shellEnv;
648+
}
639649
const runner = new Runner({
640650
db,
641651
// When we have a mount (real FUSE or the shim) point spawned
642652
// children at it so writes from exec flow through the VFS.
643653
...(fuse !== undefined ? { cwd: mountPoint } : {}),
644654
...(logMaxBytesOverride !== undefined ? { logMaxBytes: logMaxBytesOverride } : {}),
655+
...(shellOverride !== undefined ? { shell: shellOverride } : {}),
645656
});
646657
// Heartbeat events are computerd-local and must not cross the RPC boundary.
647658
// Wrap the runner so every exec/get stream drops heartbeat events before

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { existsSync } from "node:fs";
2+
13
import { Database, initializeSchema, WorkspaceFilesystem } from "@cloudflare/dofs";
24
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
35
import { expect, test } from "vitest";
@@ -517,3 +519,74 @@ test("a spawned command sees the allowlisted environment, not the daemon's", asy
517519
dispose();
518520
}
519521
});
522+
523+
// The interpreter is a per-consumer choice, not a property of the image.
524+
//
525+
// A caller that redacts credentials through a pipe -- `git push "$URL" 2>&1 |
526+
// sed -E 's#//[^@]*@#//***@#'` -- gets the pipeline's last exit status, so a
527+
// failed push reads as success. PIPESTATUS is the usual recovery, and under
528+
// dash it is a parse error that aborts the command rather than a missing
529+
// feature, which is worse than the problem it was reached for.
530+
const hasBash = existsSync("/usr/bin/bash");
531+
532+
test("defaults to /bin/sh when no shell is given", async () => {
533+
const { runner, dispose } = fixture();
534+
try {
535+
const handle = runner.exec("printf '%s' \"$0\"");
536+
const events = await drain(handle.events);
537+
const stdout = events
538+
.filter((event) => event.name === "stdout")
539+
.map((event) => decode(event.value as Uint8Array))
540+
.join("");
541+
expect(stdout).toBe("/bin/sh");
542+
} finally {
543+
dispose();
544+
}
545+
});
546+
547+
test.skipIf(!hasBash)("runs commands under an explicitly chosen shell", async () => {
548+
const { runner, dispose } = fixture({ shell: "/usr/bin/bash" });
549+
try {
550+
const handle = runner.exec("printf '%s' \"$0\"");
551+
const events = await drain(handle.events);
552+
const stdout = events
553+
.filter((event) => event.name === "stdout")
554+
.map((event) => decode(event.value as Uint8Array))
555+
.join("");
556+
expect(stdout).toBe("/usr/bin/bash");
557+
} finally {
558+
dispose();
559+
}
560+
});
561+
562+
test.skipIf(!hasBash)("a chosen shell resolves PIPESTATUS instead of aborting", async () => {
563+
const { runner, dispose } = fixture({ shell: "/usr/bin/bash" });
564+
try {
565+
// false | true leaves $? as true's 0 while the first pipeline stage's real
566+
// failure survives in the PIPESTATUS array. The trailing marker proves the
567+
// command was not aborted: under dash the expansion is fatal and "after"
568+
// never prints. The expansion is assembled from parts so its braces are
569+
// not linted as a JavaScript template placeholder.
570+
const first = ['"$', "{PIPESTATUS[0]}", '"'].join("");
571+
const handle = runner.exec(`false | true; printf '[%s]' ${first}; printf "after"`);
572+
const events = await drain(handle.events);
573+
const stdout = events
574+
.filter((event) => event.name === "stdout")
575+
.map((event) => decode(event.value as Uint8Array))
576+
.join("");
577+
expect(stdout).toBe("[1]after");
578+
} finally {
579+
dispose();
580+
}
581+
});
582+
583+
test("rejects a shell that is not an absolute path", () => {
584+
const storage = new SQLiteTestStorage();
585+
const db = new Database(storage);
586+
initializeSchema(db, () => Date.now());
587+
try {
588+
expect(() => new Runner({ db, shell: "bash" })).toThrow(/absolute path/);
589+
} finally {
590+
storage.close?.();
591+
}
592+
});

packages/computerd/src/exec/runner.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const DEFAULTS = {
6161
// After SIGTERM, give the child this long to exit on its own
6262
// before sending SIGKILL.
6363
killGraceMs: 5_000,
64+
shell: "/bin/sh",
6465
} as const;
6566

6667
export interface RunnerInit extends RunnerOptions {
@@ -80,6 +81,7 @@ export class Runner {
8081
sweepIntervalMs: number;
8182
defaultTimeoutMs: number;
8283
heartbeatIntervalMs: number;
84+
shell: string;
8385
now: () => number;
8486
};
8587
private readonly records = new Map<string, ExecRecord>();
@@ -96,8 +98,14 @@ export class Runner {
9698
sweepIntervalMs: init.sweepIntervalMs ?? DEFAULTS.sweepIntervalMs,
9799
defaultTimeoutMs: init.defaultTimeoutMs ?? DEFAULTS.defaultTimeoutMs,
98100
heartbeatIntervalMs: init.heartbeatIntervalMs ?? 0,
101+
shell: init.shell ?? DEFAULTS.shell,
99102
now: init.now ?? Date.now,
100103
};
104+
// Fail here rather than letting every exec() surface a bare ENOENT from
105+
// spawn, which names the interpreter but not the misconfiguration.
106+
if (!this.opts.shell.startsWith("/")) {
107+
throw new Error(`shell must be an absolute path; got ${JSON.stringify(this.opts.shell)}`);
108+
}
101109
initializeExecSchema(this.db);
102110
if (init.resetSchema !== false) clearExecState(this.db);
103111
}
@@ -131,7 +139,7 @@ export class Runner {
131139
// status pipe. If cwd lives inside computerd's own FUSE mount, the
132140
// child's chdir issues a FUSE LOOKUP that computerd can't service
133141
// (its event loop is stuck in uv_spawn), and the whole
134-
// process deadlocks. Have /bin/sh do the chdir instead: by
142+
// process deadlocks. Have the shell do the chdir instead: by
135143
// the time the shell runs its `cd`, computerd's event loop is back
136144
// and can answer the FUSE callback normally.
137145
//
@@ -143,7 +151,7 @@ export class Runner {
143151
// (`spawn("/bin/sh", ["-c", command])`) was already a shell-
144152
// owned exec, so this is no change in process shape.
145153
const wrapped = cwd !== undefined ? `cd ${shellQuote(cwd)} && ${command}` : command;
146-
const child = spawn("/bin/sh", ["-c", wrapped], {
154+
const child = spawn(this.opts.shell, ["-c", wrapped], {
147155
env,
148156
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
149157
});

packages/computerd/src/exec/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export interface RunnerOptions {
6262
// Emit a heartbeat event every this many milliseconds while a child
6363
// process is alive. When unset or zero, no heartbeat events are emitted.
6464
heartbeatIntervalMs?: number;
65+
// Absolute path to the interpreter each command runs under. Defaults to
66+
// /bin/sh, which on a Debian-family image is dash: bash-only syntax a
67+
// caller may reach for, notably ${PIPESTATUS[@]}, is a parse error there
68+
// and aborts the command rather than degrading. Not inferred from SHELL,
69+
// which names the caller's login shell and is deliberately absent from the
70+
// env allowlist.
71+
shell?: string;
6572
// Test seam: replaces Date.now() for retention math and log ts.
6673
now?: () => number;
6774
}

0 commit comments

Comments
 (0)