diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 83f0cc54593..d8fad1b21eb 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -470,6 +470,12 @@ Everything after `--` is forwarded verbatim to the sandbox command, including fl The exit code is the remote command's exit code. +The OpenShell exec endpoint rejects any command argument (the values after `--`) that contains a newline or carriage return, so multi-line commands such as a `bash` heredoc cannot be passed through `exec`. +NemoClaw detects this before dispatch, names the offending argument position, and exits with status `2` instead of surfacing the lower-level OpenShell `InvalidArgument` error. +Join the statements with semicolons (`nemohermes exec -- bash -lc "cmd1; cmd2"`). +Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | nemohermes exec -- bash`). +Or write the script to a file in the sandbox and run it (`nemohermes exec -- bash `). + | Flag | Description | |------|-------------| | `--workdir ` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: does not exist inside the sandbox` and exits with status `1` without invoking the inner command. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 4df898c6f56..ead0d7153e5 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -590,6 +590,12 @@ The exit code is the remote command's exit code. +The OpenShell exec endpoint rejects any command argument (the values after `--`) that contains a newline or carriage return, so multi-line commands such as a `bash` heredoc cannot be passed through `exec`. +NemoClaw detects this before dispatch, names the offending argument position, and exits with status `2` instead of surfacing the lower-level OpenShell `InvalidArgument` error. +Join the statements with semicolons (`$$nemoclaw exec -- bash -lc "cmd1; cmd2"`). +Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | $$nemoclaw exec -- bash`). +Or write the script to a file in the sandbox and run it (`$$nemoclaw exec -- bash `). + | Flag | Description | |------|-------------| | `--workdir ` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: does not exist inside the sandbox` and exits with status `1` without invoking the inner command. | diff --git a/src/commands/sandbox/exec.test.ts b/src/commands/sandbox/exec.test.ts index 179f486d268..948a8e4a654 100644 --- a/src/commands/sandbox/exec.test.ts +++ b/src/commands/sandbox/exec.test.ts @@ -41,6 +41,43 @@ describe("SandboxExecCommand oclif parse path", () => { }); }); + it("forwards a multi-line heredoc command verbatim to the action guard (#5980)", async () => { + // The command layer forwards argv unchanged; execSandbox() applies the + // newline guard (exit 2 before dispatch), which is asserted directly in the + // action test. Here we pin that the heredoc reaches the action intact. + const heredoc = "cat < { + // Mirrors the action-layer "forwards the semicolon workaround to dispatch" + // test: the single-line semicolon-joined command carries no newline, so the + // command layer hands it to execSandbox() unchanged, which then dispatches. + await SandboxExecCommand.run(["alpha", "--", "bash", "-lc", "echo line1; echo line2"], rootDir); + expect(execSandboxMock).toHaveBeenCalledWith( + "alpha", + ["bash", "-lc", "echo line1; echo line2"], + { workdir: undefined, tty: null, timeoutSeconds: undefined }, + ); + }); + + it("preserves --workdir and forwards a single-line command unchanged (#5980)", async () => { + await SandboxExecCommand.run( + ["alpha", "--workdir", "/sandbox", "--", "bash", "-lc", "echo line1; echo line2"], + rootDir, + ); + expect(execSandboxMock).toHaveBeenCalledWith( + "alpha", + ["bash", "-lc", "echo line1; echo line2"], + { workdir: "/sandbox", tty: null, timeoutSeconds: undefined }, + ); + }); + it("parses --tty / --no-tty and --timeout into typed options", async () => { await SandboxExecCommand.run(["alpha", "--tty", "--timeout", "30", "--", "hostname"], rootDir); expect(execSandboxMock).toHaveBeenCalledWith("alpha", ["hostname"], { diff --git a/src/lib/actions/sandbox/exec.multiline-guard.test.ts b/src/lib/actions/sandbox/exec.multiline-guard.test.ts new file mode 100644 index 00000000000..2fd4e4d30e1 --- /dev/null +++ b/src/lib/actions/sandbox/exec.multiline-guard.test.ts @@ -0,0 +1,363 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The default exec runner shells out via spawn with stdio: "inherit"; the +// stdin-pipe workaround relies on that inheritance to deliver piped script +// content to the sandbox shell. Mock node:child_process so a single test can +// assert the inherited-stdio wiring at the execSandbox boundary without +// spawning a real process. Every other test injects a runner/probe seam, so +// this default spawn is exercised only by that one test. +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn() }; +}); + +// execSandbox dynamically requires the OpenShell binary lookup, which exits the +// process when OpenShell is absent. The dispatch-path tests inject a +// resolveBinary seam (plus a runner and workdir probe) so they stay hermetic +// without spawning a real process or hitting that process-exiting lookup. +import { + buildOpenshellExecArgs, + execSandbox, + findMultilineExecArg, + multilineExecMessage, +} from "./exec"; + +describe("findMultilineExecArg", () => { + it("returns -1 when every argument is single-line", () => { + expect(findMultilineExecArg(["bash", "-lc", "echo line1; echo line2"])).toBe(-1); + }); + + it("returns the index of the first argument containing a newline", () => { + expect(findMultilineExecArg(["bash", "-lc", "cat < { + expect(findMultilineExecArg(["printf", "a\rb"])).toBe(1); + }); + + it("treats Unicode line separators (U+2028/U+2029) as single-line because OpenShell rejects only CR/LF", () => { + // The guard deliberately mirrors OpenShell's CR/LF-only rejection, so these + // code points are valid argv that dispatch unchanged. Broadening the guard + // to match them would reject commands OpenShell would otherwise run. + expect(findMultilineExecArg(["printf", "a\u2028b"])).toBe(-1); + expect(findMultilineExecArg(["printf", "a\u2029b"])).toBe(-1); + }); + + it("reports the earliest offending argument when several are multi-line", () => { + expect(findMultilineExecArg(["a", "b\nc", "d\ne"])).toBe(1); + }); +}); + +describe("multilineExecMessage", () => { + it("names the 1-based argument position and offers the semicolon, pipe, and script workarounds", () => { + const message = multilineExecMessage( + "nemoclaw", + "bug5980test", + ["bash", "-lc", "cat <"); + }); + + it("uses the active CLI name so the Hermes surface gets nemohermes guidance", () => { + const message = multilineExecMessage("nemohermes", "alpha", ["bash", "-lc", "a\nb"], 2); + expect(message).toContain("nemohermes alpha exec -- bash"); + expect(message).not.toContain("nemoclaw"); + }); + + it("describes the argument by size without echoing its contents (avoids leaking secrets)", () => { + // A multi-line value can carry pasted secrets; the message must never + // reproduce its contents. Use a neutral sentinel so the secret-scanner + // hooks do not flag the test fixture itself. + const sensitive = "SENSITIVE_LINE_ONE\nSENSITIVE_LINE_TWO\nSENSITIVE_LINE_THREE"; + const message = multilineExecMessage("nemoclaw", "alpha", ["bash", "-lc", sensitive], 2); + // The neutral size description appears... + expect(message).toContain(`${sensitive.length} characters spanning 3 lines`); + // ...but no fragment of the payload is ever printed. + expect(message).not.toContain("SENSITIVE_LINE"); + // Each line of the rendered message is itself free of stray carriage + // returns (the message is multi-line by design, joined with "\n"). + for (const line of message.split("\n")) { + expect(line).not.toMatch(/\r/); + } + }); + + it("uses singular units for a single-character single-line argument", () => { + const message = multilineExecMessage("nemoclaw", "alpha", ["printf", "\r"], 1); + expect(message).toContain("1 character spanning 2 lines"); + }); + + it("counts a trailing newline as a second (empty) line", () => { + // A single trailing "\n" splits into ["first", ""], so the size description + // reports 2 lines even though only one line carries text. This pins the + // documented bare-CR/trailing-break counting behavior. + const message = multilineExecMessage("nemoclaw", "alpha", ["bash", "-lc", "first\n"], 2); + expect(message).toContain("6 characters spanning 2 lines"); + }); +}); + +describe("execSandbox multi-line guard (#5980)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a multi-line command argument before dispatch with actionable guidance", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => { + throw new Error(`exit:${_code}`); + }) as never); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + + await expect( + execSandbox("bug5980test", ["bash", "-lc", "cat < String(call[0])).join("\n"); + expect(printed).toContain("contains a newline or carriage return"); + expect(printed).toContain('bash -lc "cmd1; cmd2"'); + }); + + it("forwards the semicolon workaround to dispatch and exits with the inner status", async () => { + // The reporter's confirmed workaround (`bash -lc "cmd1; cmd2"`) carries no + // newline/carriage return, so it passes the guard and dispatches. Injecting + // resolveBinary avoids the process-exiting OpenShell lookup, and the runner + // returns success so we can assert the argv forwarded and the exit code. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + + await expect( + execSandbox( + "bug5980test", + ["bash", "-lc", "echo line1; echo line2"], + {}, + { run, resolveBinary: () => "openshell" }, + ), + ).rejects.toThrow("exit:0"); + + expect(run).toHaveBeenCalledWith("openshell", [ + "sandbox", + "exec", + "--name", + "bug5980test", + "--", + "bash", + "-lc", + "echo line1; echo line2", + ]); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("forwards a Unicode line-separator argument through dispatch (OpenShell accepts U+2028/U+2029; only CR/LF are guarded)", async () => { + // The guard mirrors OpenShell's CR/LF-only rejection, so an argument that + // carries U+2028 passes the guard and dispatches unchanged at the + // execSandbox boundary — confirming the documented assumption end-to-end on + // the dispatch path, not just in findMultilineExecArg. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + + await expect( + execSandbox( + "bug5980test", + ["printf", "a\u2028b"], + {}, + { run, resolveBinary: () => "openshell" }, + ), + ).rejects.toThrow("exit:0"); + + expect(run).toHaveBeenCalledWith("openshell", [ + "sandbox", + "exec", + "--name", + "bug5980test", + "--", + "printf", + "a\u2028b", + ]); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("still validates --workdir for a single-line command and fails with the workdir error, not the multi-line error", async () => { + // Guard ordering: the multi-line check runs before the workdir probe. A + // valid single-line command with a missing --workdir must surface the + // workdir error (exit 1), proving the workdir probe still runs after the + // guard and that the guard did not swallow the command. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + const probeWorkdir = vi.fn(() => ({ status: 1 })); // `test -d` failure -> missing + + await expect( + execSandbox( + "alpha", + ["bash", "-lc", "echo ok"], + { workdir: "/no/such/dir" }, + { run, resolveBinary: () => "openshell", probeWorkdir }, + ), + ).rejects.toThrow("exit:1"); + + expect(probeWorkdir).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + const printed = errSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(printed).toContain("does not exist inside the sandbox"); + expect(printed).not.toContain("newline or carriage return"); + // The workdir probe failed, so the command is never dispatched. + expect(run).not.toHaveBeenCalled(); + }); + + it("rejects a multi-line command before probing --workdir (guard runs first)", async () => { + // Ordering guarantee: when both a multi-line argv and --workdir are present, + // the multi-line guard must exit 2 *before* the workdir probe runs, so the + // probe is never reached and nothing is dispatched. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + const probeWorkdir = vi.fn(() => ({ status: 0 })); + + await expect( + execSandbox( + "alpha", + ["bash", "-lc", "printf 'a\nb'"], + { workdir: "/workspace" }, + { run, resolveBinary: () => "openshell", probeWorkdir }, + ), + ).rejects.toThrow("exit:2"); + + expect(probeWorkdir).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(errSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( + "contains a newline or carriage return", + ); + }); + + it("forwards the stdin-pipe workaround argv to dispatch (script travels over stdin, not argv)", async () => { + // `printf 'cmd1\ncmd2\n' | nemoclaw exec -- bash` puts the multi-line + // script on stdin; the forwarded argv is just `bash` (no newline), so it + // passes the guard and dispatches. This test pins the argv shape only; the + // adjacent "inherits stdio" test proves the runner actually forwards stdin. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + + await expect( + execSandbox("bug5980test", ["bash"], {}, { run, resolveBinary: () => "openshell" }), + ).rejects.toThrow("exit:0"); + + expect(run).toHaveBeenCalledWith("openshell", [ + "sandbox", + "exec", + "--name", + "bug5980test", + "--", + "bash", + ]); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("dispatches the default runner with inherited stdio so the stdin-pipe workaround receives piped input", async () => { + // The argv-only test above cannot catch a regression that stops the runner + // from inheriting stdin (#5980). Exercise the *default* runner (no injected + // `run`) and assert the async child is spawned with stdio: "inherit", which + // is the observable mechanism the documented `printf ... | exec -- bash` + // workaround depends on. Only resolveBinary is injected, to avoid the + // process-exiting OpenShell binary lookup. + const childEvents = new EventEmitter(); + const child = { + exitCode: null, + signalCode: null, + kill: vi.fn(), + once: ((event: string, listener: (...args: unknown[]) => void) => + childEvents.once(event, listener)) as never, + }; + vi.mocked(spawn).mockImplementation(((): never => { + // Resolve the runner once the close handler is registered. + queueMicrotask(() => childEvents.emit("close", 0, null)); + return child as never; + }) as never); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + execSandbox("bug5980test", ["bash"], {}, { resolveBinary: () => "openshell" }), + ).rejects.toThrow("exit:0"); + + expect(spawn).toHaveBeenCalledWith( + "openshell", + ["sandbox", "exec", "--name", "bug5980test", "--", "bash"], + { stdio: "inherit" }, + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("forwards the script-file workaround to dispatch (bash )", async () => { + // `nemoclaw exec -- bash ` runs a script already written + // into the sandbox; the argv carries no newline and dispatches unchanged. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + const run = vi.fn(() => ({ status: 0 })); + + await expect( + execSandbox( + "bug5980test", + ["bash", "/sandbox/run.sh"], + {}, + { run, resolveBinary: () => "openshell" }, + ), + ).rejects.toThrow("exit:0"); + + expect(run).toHaveBeenCalledWith("openshell", [ + "sandbox", + "exec", + "--name", + "bug5980test", + "--", + "bash", + "/sandbox/run.sh", + ]); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("builds the forwarded argv unchanged for the single-line semicolon workaround", () => { + const command = ["bash", "-lc", "echo line1; echo line2"]; + expect(findMultilineExecArg(command)).toBe(-1); + expect(buildOpenshellExecArgs("bug5980test", command)).toEqual([ + "sandbox", + "exec", + "--name", + "bug5980test", + "--", + "bash", + "-lc", + "echo line1; echo line2", + ]); + }); +}); diff --git a/src/lib/actions/sandbox/exec.test.ts b/src/lib/actions/sandbox/exec.test.ts index 0c9a954461a..3e345ceff9c 100644 --- a/src/lib/actions/sandbox/exec.test.ts +++ b/src/lib/actions/sandbox/exec.test.ts @@ -3,6 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +// The multi-line guard suites (findMultilineExecArg, multilineExecMessage, and +// the execSandbox dispatch guard for #5980) live in exec.multiline-guard.test.ts +// so this file stays focused on argv construction and the workdir probe. import { buildOpenshellExecArgs, buildWorkdirProbeArgs, diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index d895f74062a..96842326187 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -89,6 +89,77 @@ export function buildWorkdirProbeArgs(sandboxName: string, workdir: string): str return ["sandbox", "exec", "--name", sandboxName, "--", "test", "-d", workdir]; } +// OpenShell's `sandbox exec` rejects any argv element that contains a newline +// or carriage return ("command argument N contains newline or carriage return +// characters"). Multi-line commands such as heredocs therefore fail with a +// low-level InvalidArgument error that gives the reporter no NemoClaw-specific +// recovery path (#5980). We detect the offending argument before dispatch and +// fail with actionable guidance instead. +// +// Source-of-truth for this guard: +// - Invalid state: OpenShell's exec endpoint returns InvalidArgument for any +// argv element containing \r or \n. +// - Source boundary: the limitation lives in the external OpenShell +// `sandbox exec` argv contract, not in NemoClaw. We cannot fix it at the +// source from this repo, so the guard is a deliberately localized +// translation of that constraint into actionable NemoClaw guidance. +// - Regression coverage: `findMultilineExecArg`, `multilineExecMessage`, and +// the `execSandbox multi-line guard (#5980)` suite in exec.test.ts. +// - Removal condition: if a future OpenShell release accepts multi-line argv +// elements (tracked upstream in NVIDIA/OpenShell#2110), this guard and the +// matching docs notice in docs/reference/commands.mdx + +// commands-nemohermes.mdx become unnecessary and should be removed together. +// +// The pattern is intentionally limited to \r and \n: OpenShell rejects only +// "newline or carriage return characters", so Unicode line separators (U+2028 +// LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR) are valid argv that OpenShell +// accepts. Broadening the pattern to those code points would reject commands +// OpenShell would otherwise run, so the guard deliberately mirrors OpenShell's +// exact constraint rather than a general "line break" notion. +const MULTILINE_ARG_PATTERN = /[\r\n]/; + +/** @internal Exported for unit testing only; not part of the public API. */ +export function findMultilineExecArg(command: readonly string[]): number { + for (let index = 0; index < command.length; index += 1) { + if (MULTILINE_ARG_PATTERN.test(command[index])) return index; + } + return -1; +} + +// Describe the offending argument WITHOUT echoing its contents: a multi-line +// value can carry pasted secrets, env files, or private-key material, and +// printing even a truncated preview risks persisting it in terminal or CI logs. +// The 1-based position plus a neutral size description is enough for the user +// to find the argument they typed. +function describeMultilineArg(arg: string): string { + // Split on all three newline conventions (CRLF, bare CR, bare LF) so the + // count matches what a user sees regardless of platform. The alternation is + // ordered CRLF-first so a Windows "\r\n" counts as one break, not two. A + // single trailing break still yields a count of 2 (the empty final segment), + // which is correct: a lone "\r" argument spans two lines. + const lineCount = arg.split(/\r\n|\r|\n/).length; + const charLabel = arg.length === 1 ? "character" : "characters"; + const lineLabel = lineCount === 1 ? "line" : "lines"; + return `${arg.length} ${charLabel} spanning ${lineCount} ${lineLabel}`; +} + +export function multilineExecMessage( + cliName: string, + sandboxName: string, + command: readonly string[], + index: number, +): string { + // Report a 1-based position within the user command (the args after `--`). + const position = index + 1; + return [ + `error: command argument ${position} (${describeMultilineArg(command[index])}) contains a newline or carriage return, which OpenShell exec does not accept.`, + "Multi-line commands (for example heredocs) cannot be passed through exec argv. Instead:", + ` - join statements with semicolons: ${cliName} ${sandboxName} exec -- bash -lc "cmd1; cmd2"`, + ` - pipe the script into the sandbox shell over stdin: printf 'cmd1\\ncmd2\\n' | ${cliName} ${sandboxName} exec -- bash`, + ` - or write the script to a file in the sandbox and run it: ${cliName} ${sandboxName} exec -- bash `, + ].join("\n"); +} + export function workdirMissingMessage(workdir: string): string { return `error: --workdir: ${workdir} does not exist inside the sandbox`; } @@ -284,29 +355,48 @@ export function validateWorkdirOrFail( } } +function defaultResolveBinary(): string { + const { getOpenshellBinary } = require("../../adapters/openshell/runtime"); + return getOpenshellBinary(); +} + +// Test seams for execSandbox. All default to the production behavior; tests +// inject them so the dispatch path stays hermetic without spawning a real +// process or hitting the process-exiting OpenShell binary lookup. +export type ExecSandboxDeps = { + resolveBinary?: () => string; + probeWorkdir?: WorkdirProbeRunner; + run?: SandboxExecRunner; +}; + export async function execSandbox( sandboxName: string, command: readonly string[], options: SandboxExecOptions = {}, + deps: ExecSandboxDeps = {}, ): Promise { const { CLI_NAME } = require("../../cli/branding"); - const { getOpenshellBinary } = require("../../adapters/openshell/runtime"); if (command.length === 0) { console.error( ` Usage: ${CLI_NAME} ${sandboxName} exec [--workdir ] [--tty|--no-tty] [--timeout ] -- [args...]`, ); process.exit(2); } - const binary = getOpenshellBinary(); + const multilineIndex = findMultilineExecArg(command); + if (multilineIndex !== -1) { + console.error(multilineExecMessage(CLI_NAME, sandboxName, command, multilineIndex)); + process.exit(2); + } + const binary = (deps.resolveBinary ?? defaultResolveBinary)(); if (options.workdir) { - validateWorkdirOrFail(binary, sandboxName, options.workdir); + validateWorkdirOrFail(binary, sandboxName, options.workdir, deps.probeWorkdir); } const completion = await runSandboxExecCommand( binary, sandboxName, command, options, - runSandboxExecChild, + deps.run ?? runSandboxExecChild, { getSandbox: (name) => (require("../../state/registry") as typeof import("../../state/registry")).getSandbox(name),