diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index deebd6cf86a..2a988593e43 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -573,10 +573,19 @@ Everything after `--` is forwarded verbatim to the sandbox command, including fl The exit code is the remote command's exit code. +By default, NemoClaw inherits caller stdin only when it is a terminal. +Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. +Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. + +```bash +printf 'hello\n' | nemohermes my-assistant exec --stdin -- cat +ssh dgx-spark 'nemohermes my-assistant exec --no-stdin -- pwd' +``` + 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`). +Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | nemohermes exec --stdin -- bash`). Or write the script to a file in the sandbox and run it (`nemohermes exec -- bash `). | Flag | Description | @@ -584,6 +593,7 @@ Or write the script to a file in the sandbox and run it (`nemohermes exec | `--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. | | `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) | | `--timeout ` | Timeout in seconds (`0` means no timeout) | +| `--stdin` / `--no-stdin` | Force caller stdin forwarding or closure (default: inherit terminal stdin; close non-terminal or unavailable stdin). | ### `nemohermes agent` @@ -834,14 +844,19 @@ Use `--` to separate `exec` options from the command you want to run inside the The command exits with the remote command's exit code. ```bash -nemohermes my-assistant exec [--workdir ] [--tty|--no-tty] [--timeout ] -- [args...] +nemohermes my-assistant exec [--workdir ] [--tty|--no-tty] [--timeout ] [--stdin|--no-stdin] -- [args...] ``` +By default, NemoClaw inherits caller stdin only when it is a terminal. +Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. +Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. + | Flag | Description | |------|-------------| | `--workdir ` | Set the 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. | | `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection | | `--timeout ` | Timeout in seconds. Use `0` for no timeout | +| `--stdin`, `--no-stdin` | Force caller stdin forwarding or closure (default: inherit terminal stdin; close non-terminal or unavailable stdin). | ### `nemohermes logs` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 4930992cc36..f162097253b 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -703,10 +703,19 @@ The exit code is the remote command's exit code. +By default, NemoClaw inherits caller stdin only when it is a terminal. +Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. +Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. + +```bash +printf 'hello\n' | $$nemoclaw my-assistant exec --stdin -- cat +ssh dgx-spark '$$nemoclaw my-assistant exec --no-stdin -- pwd' +``` + 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`). +Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | $$nemoclaw exec --stdin -- bash`). Or write the script to a file in the sandbox and run it (`$$nemoclaw exec -- bash `). | Flag | Description | @@ -714,6 +723,7 @@ Or write the script to a file in the sandbox and run it (`$$nemoclaw exec | `--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. | | `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) | | `--timeout ` | Timeout in seconds (`0` means no timeout) | +| `--stdin` / `--no-stdin` | Force caller stdin forwarding or closure (default: inherit terminal stdin; close non-terminal or unavailable stdin). | ### `$$nemoclaw agent` @@ -1083,14 +1093,19 @@ The command exits with the remote command's exit code. ```bash -$$nemoclaw my-assistant exec [--workdir ] [--tty|--no-tty] [--timeout ] -- [args...] +$$nemoclaw my-assistant exec [--workdir ] [--tty|--no-tty] [--timeout ] [--stdin|--no-stdin] -- [args...] ``` +By default, NemoClaw inherits caller stdin only when it is a terminal. +Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. +Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. + | Flag | Description | |------|-------------| | `--workdir ` | Set the 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. | | `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection | | `--timeout ` | Timeout in seconds. Use `0` for no timeout | +| `--stdin`, `--no-stdin` | Force caller stdin forwarding or closure (default: inherit terminal stdin; close non-terminal or unavailable stdin). | ### `$$nemoclaw logs` diff --git a/src/commands/sandbox/exec.test.ts b/src/commands/sandbox/exec.test.ts index 948a8e4a654..3b5c449364c 100644 --- a/src/commands/sandbox/exec.test.ts +++ b/src/commands/sandbox/exec.test.ts @@ -25,7 +25,7 @@ describe("SandboxExecCommand oclif parse path", () => { expect(execSandboxMock).toHaveBeenCalledWith( "alpha", ["openclaw", "agent", "--agent", "main", "-m", "hi"], - { workdir: undefined, tty: null, timeoutSeconds: undefined }, + { workdir: undefined, tty: null, timeoutSeconds: undefined, stdin: undefined }, ); }); @@ -38,6 +38,7 @@ describe("SandboxExecCommand oclif parse path", () => { workdir: "/sandbox/workspace", tty: null, timeoutSeconds: undefined, + stdin: undefined, }); }); @@ -51,6 +52,7 @@ describe("SandboxExecCommand oclif parse path", () => { workdir: undefined, tty: null, timeoutSeconds: undefined, + stdin: undefined, }); }); @@ -62,7 +64,7 @@ describe("SandboxExecCommand oclif parse path", () => { expect(execSandboxMock).toHaveBeenCalledWith( "alpha", ["bash", "-lc", "echo line1; echo line2"], - { workdir: undefined, tty: null, timeoutSeconds: undefined }, + { workdir: undefined, tty: null, timeoutSeconds: undefined, stdin: undefined }, ); }); @@ -74,7 +76,7 @@ describe("SandboxExecCommand oclif parse path", () => { expect(execSandboxMock).toHaveBeenCalledWith( "alpha", ["bash", "-lc", "echo line1; echo line2"], - { workdir: "/sandbox", tty: null, timeoutSeconds: undefined }, + { workdir: "/sandbox", tty: null, timeoutSeconds: undefined, stdin: undefined }, ); }); @@ -84,6 +86,7 @@ describe("SandboxExecCommand oclif parse path", () => { workdir: undefined, tty: true, timeoutSeconds: 30, + stdin: undefined, }); execSandboxMock.mockReset(); @@ -92,6 +95,37 @@ describe("SandboxExecCommand oclif parse path", () => { workdir: undefined, tty: false, timeoutSeconds: undefined, + stdin: undefined, + }); + }); + + it("parses --stdin as explicit stdin forwarding", async () => { + await SandboxExecCommand.run(["alpha", "--stdin", "--", "cat"], rootDir); + expect(execSandboxMock).toHaveBeenCalledWith("alpha", ["cat"], { + workdir: undefined, + tty: null, + timeoutSeconds: undefined, + stdin: true, + }); + }); + + it("parses --no-stdin as explicit stdin closure", async () => { + await SandboxExecCommand.run(["alpha", "--no-stdin", "--", "pwd"], rootDir); + expect(execSandboxMock).toHaveBeenCalledWith("alpha", ["pwd"], { + workdir: undefined, + tty: null, + timeoutSeconds: undefined, + stdin: false, + }); + }); + + it("leaves stdin mode unset for the production spawner to auto-detect", async () => { + await SandboxExecCommand.run(["alpha", "--", "bash"], rootDir); + expect(execSandboxMock).toHaveBeenCalledWith("alpha", ["bash"], { + workdir: undefined, + tty: null, + timeoutSeconds: undefined, + stdin: undefined, }); }); }); diff --git a/src/commands/sandbox/exec.ts b/src/commands/sandbox/exec.ts index e54c64018b2..e2ce003880a 100644 --- a/src/commands/sandbox/exec.ts +++ b/src/commands/sandbox/exec.ts @@ -10,11 +10,14 @@ export default class SandboxExecCommand extends NemoClawCommand { static strict = false; static summary = "Run a command non-interactively in a running sandbox"; static description = - "Run a single command inside a running sandbox via the OpenShell exec endpoint. The command runs as the sandbox user (HOME=/sandbox) and exits with the remote command's exit code. Use `--` to separate exec options from the user command."; - static usage = [" [--workdir ] [--tty|--no-tty] [--timeout ] -- [args...]"]; + "Run a single command inside a running sandbox via the OpenShell exec endpoint. The command runs as the sandbox user (HOME=/sandbox) and exits with the remote command's exit code. Use `--` to separate exec options from the user command. Stdin is inherited by default only when it is a terminal; pass `--stdin` to forward an intentional pipe."; + static usage = [ + " [--workdir ] [--tty|--no-tty] [--timeout ] [--stdin|--no-stdin] -- [args...]", + ]; static examples = [ "<%= config.bin %> sandbox exec alpha -- openclaw agent --agent main -m hi", "<%= config.bin %> sandbox exec alpha --workdir /sandbox -- ls -la", + "printf 'hello' | <%= config.bin %> sandbox exec alpha --stdin -- cat", ]; static args = { sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), @@ -29,6 +32,11 @@ export default class SandboxExecCommand extends NemoClawCommand { min: 0, description: "Timeout in seconds (0 = no timeout)", }), + stdin: Flags.boolean({ + allowNo: true, + description: + "Pass caller stdin through to the sandbox command; defaults to terminal stdin only", + }), }; public async run(): Promise { @@ -38,6 +46,7 @@ export default class SandboxExecCommand extends NemoClawCommand { workdir: flags.workdir, tty: typeof flags.tty === "boolean" ? flags.tty : null, timeoutSeconds: flags.timeout, + stdin: flags.stdin, }); } } diff --git a/src/lib/actions/sandbox/exec-openclaw-permission-cleanup.test.ts b/src/lib/actions/sandbox/exec-openclaw-permission-cleanup.test.ts index f4bf1ebf3c9..2e0dc694a09 100644 --- a/src/lib/actions/sandbox/exec-openclaw-permission-cleanup.test.ts +++ b/src/lib/actions/sandbox/exec-openclaw-permission-cleanup.test.ts @@ -243,7 +243,7 @@ describe("runSandboxExecCommand mutable OpenClaw cleanup (#6047)", () => { "alpha", ["sleep", "30"], {}, - (binary, args) => runSandboxExecChild(binary, args, () => child, signalSource), + (binary, args) => runSandboxExecChild(binary, args, {}, () => child, signalSource), cleanupDeps({ inspectMutableConfigPerms: inspect }), ); signalEvents.emit(signal); @@ -282,7 +282,7 @@ describe("runSandboxExecCommand mutable OpenClaw cleanup (#6047)", () => { "alpha", ["sleep", "30"], {}, - (binary, args) => runSandboxExecChild(binary, args, () => child, signalSource), + (binary, args) => runSandboxExecChild(binary, args, {}, () => child, signalSource), cleanupDeps({ inspectMutableConfigPerms: inspect }), ); signalEvents.emit("SIGINT"); diff --git a/src/lib/actions/sandbox/exec-policy-hint-emission.ts b/src/lib/actions/sandbox/exec-policy-hint-emission.ts index 5e8cfa2190e..d1e2d0944d2 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-emission.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-emission.ts @@ -76,9 +76,10 @@ function defaultProbeLogs(sandboxName: string): string { /** * Emit a denial-adjacent hint after a failed exec. Every dependency is * best-effort: failures return null and never replace the command's exit code. - * Exec inherits stdio byte-for-byte, so proxy error text is intentionally not - * captured for a cheaper prefilter; nonzero status is the only safe pre-probe - * gate, and the timestamp-correlated structured denial is the confirmation. + * Exec leaves stdout and stderr inherited byte-for-byte, so proxy error text is + * intentionally not captured for a cheaper prefilter; nonzero status is the + * only safe pre-probe gate, and the timestamp-correlated structured denial is + * the confirmation. * Log-read failures are terminal rather than retried, while successful empty * reads get two 120 ms settling retries (240 ms total). */ diff --git a/src/lib/actions/sandbox/exec-stdio.test.ts b/src/lib/actions/sandbox/exec-stdio.test.ts new file mode 100644 index 00000000000..7ab75687e73 --- /dev/null +++ b/src/lib/actions/sandbox/exec-stdio.test.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { buildSandboxExecStdio, shouldInheritSandboxExecStdin } from "./exec-stdio"; + +describe("buildSandboxExecStdio", () => { + it("inherits terminal stdin by default", () => { + expect(buildSandboxExecStdio({}, true)).toBe("inherit"); + }); + + it("closes non-terminal or unknown stdin by default", () => { + expect(buildSandboxExecStdio({}, false)).toEqual(["ignore", "inherit", "inherit"]); + expect(buildSandboxExecStdio({}, undefined)).toEqual(["ignore", "inherit", "inherit"]); + }); + + it("honors explicit flags over terminal detection", () => { + expect(buildSandboxExecStdio({ stdin: true }, false)).toBe("inherit"); + expect(buildSandboxExecStdio({ stdin: true }, undefined)).toBe("inherit"); + expect(buildSandboxExecStdio({ stdin: false }, true)).toEqual(["ignore", "inherit", "inherit"]); + }); +}); + +describe("shouldInheritSandboxExecStdin", () => { + it("lets explicit --stdin and --no-stdin win", () => { + expect(shouldInheritSandboxExecStdin(true, false)).toBe(true); + expect(shouldInheritSandboxExecStdin(false, true)).toBe(false); + }); + + it("inherits only a positively identified TTY when no flag is present", () => { + expect(shouldInheritSandboxExecStdin(undefined, true)).toBe(true); + expect(shouldInheritSandboxExecStdin(undefined, false)).toBe(false); + expect(shouldInheritSandboxExecStdin(undefined, undefined)).toBe(false); + }); +}); diff --git a/src/lib/actions/sandbox/exec-stdio.ts b/src/lib/actions/sandbox/exec-stdio.ts new file mode 100644 index 00000000000..f1fcac0daa1 --- /dev/null +++ b/src/lib/actions/sandbox/exec-stdio.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { StdioOptions } from "node:child_process"; +import { isStdinTty } from "../../core/stdin"; +import type { SandboxExecOptions } from "./exec"; + +export function shouldInheritSandboxExecStdin( + requested: boolean | undefined, + stdinIsTty: boolean | undefined, +): boolean { + if (typeof requested === "boolean") return requested; + return stdinIsTty === true; +} + +export function buildSandboxExecStdio( + options: SandboxExecOptions = {}, + stdinIsTty: boolean | undefined = isStdinTty(), +): StdioOptions { + return shouldInheritSandboxExecStdin(options.stdin, stdinIsTty) + ? "inherit" + : ["ignore", "inherit", "inherit"]; +} diff --git a/src/lib/actions/sandbox/exec.multiline-guard.test.ts b/src/lib/actions/sandbox/exec.multiline-guard.test.ts index fd511fa4e4a..fe7047da36c 100644 --- a/src/lib/actions/sandbox/exec.multiline-guard.test.ts +++ b/src/lib/actions/sandbox/exec.multiline-guard.test.ts @@ -5,12 +5,10 @@ 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. +// The default exec runner shells out via spawn and chooses whether to inherit +// or ignore stdin. Mock node:child_process so the tests can assert that wiring +// at the execSandbox boundary without spawning a real process. Every other test +// injects a runner/probe seam. vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: vi.fn() }; @@ -69,13 +67,13 @@ describe("multilineExecMessage", () => { expect(message).toContain("command argument 3"); expect(message).toContain("contains a newline or carriage return"); expect(message).toContain('nemoclaw bug5980test exec -- bash -lc "cmd1; cmd2"'); - expect(message).toContain("| nemoclaw bug5980test exec -- bash"); + expect(message).toContain("| nemoclaw bug5980test exec --stdin -- bash"); expect(message).toContain("nemoclaw bug5980test exec -- bash "); }); 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).toContain("nemohermes alpha exec --stdin -- bash"); expect(message).not.toContain("nemoclaw"); }); @@ -248,10 +246,10 @@ describe("execSandbox multi-line guard (#5980)", () => { }); 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. + // `printf 'cmd1\ncmd2\n' | nemoclaw exec --stdin -- 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 stdio test proves the runner forwards explicitly opted-in stdin. const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never); @@ -266,13 +264,16 @@ describe("execSandbox multi-line guard (#5980)", () => { 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. + it.each([ + { label: "inherits stdin after explicit --stdin", stdin: true, expectedStdio: "inherit" }, + { + label: "closes stdin after explicit --no-stdin", + stdin: false, + expectedStdio: ["ignore", "inherit", "inherit"], + }, + ])("dispatches the default runner and $label", async ({ stdin, expectedStdio }) => { + // Exercise the *default* runner (no injected `run`) so the assertion covers + // the production child-process wiring, not only the pure stdio selector. const childEvents = new EventEmitter(); const child = { exitCode: null, @@ -281,6 +282,7 @@ describe("execSandbox multi-line guard (#5980)", () => { once: ((event: string, listener: (...args: unknown[]) => void) => childEvents.once(event, listener)) as never, }; + vi.mocked(spawn).mockReset(); vi.mocked(spawn).mockImplementation(((): never => { // Resolve the runner once the close handler is registered. queueMicrotask(() => childEvents.emit("close", 0, null)); @@ -292,11 +294,11 @@ describe("execSandbox multi-line guard (#5980)", () => { vi.spyOn(console, "error").mockImplementation(() => {}); await expect( - execSandbox("bug5980test", ["bash"], {}, { resolveBinary: () => "openshell" }), + execSandbox("bug5980test", ["bash"], { stdin }, { resolveBinary: () => "openshell" }), ).rejects.toThrow("exit:0"); expect(spawn).toHaveBeenCalledWith("openshell", expectedExecArgs("bug5980test", ["bash"]), { - stdio: "inherit", + stdio: expectedStdio, }); expect(exitSpy).toHaveBeenCalledWith(0); }); diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index d7ab140dfc8..9ad2f58d087 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -9,14 +9,17 @@ import type { } from "../../shields/mutable-config-perms"; import type { SandboxEntry } from "../../state/registry"; import { type ExecPolicyHintDeps, preparePolicyHint } from "./exec-policy-hint-integration"; +import { buildSandboxExecStdio } from "./exec-stdio"; import { wrapExecCommandWithRuntimeEnv } from "./runtime-env"; +export { buildSandboxExecStdio, shouldInheritSandboxExecStdin } from "./exec-stdio"; export { wrapExecCommandWithRuntimeEnv } from "./runtime-env"; export type SandboxExecOptions = { workdir?: string; tty?: boolean | null; timeoutSeconds?: number; + stdin?: boolean; }; type SpawnLikeResult = { @@ -44,7 +47,11 @@ export type SandboxExecChild = { }; }; -export type SandboxExecSpawner = (binary: string, args: readonly string[]) => SandboxExecChild; +export type SandboxExecSpawner = ( + binary: string, + args: readonly string[], + options: SandboxExecOptions, +) => SandboxExecChild; export type SandboxExecSignalSource = { add: (signal: "SIGTERM" | "SIGINT", listener: () => void) => void; @@ -159,7 +166,7 @@ export function multilineExecMessage( `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`, + ` - pipe the script into the sandbox shell over stdin: printf 'cmd1\\ncmd2\\n' | ${cliName} ${sandboxName} exec --stdin -- bash`, ` - or write the script to a file in the sandbox and run it: ${cliName} ${sandboxName} exec -- bash `, ].join("\n"); } @@ -264,8 +271,8 @@ export function cleanupOpenClawAfterExec( return null; } -const defaultSandboxExecSpawner: SandboxExecSpawner = (binary, args) => - spawn(binary, [...args], { stdio: "inherit" }); +const defaultSandboxExecSpawner: SandboxExecSpawner = (binary, args, options) => + spawn(binary, [...args], { stdio: buildSandboxExecStdio(options) }); const defaultSandboxExecSignalSource: SandboxExecSignalSource = { add: (signal, listener) => process.on(signal, listener), @@ -275,12 +282,13 @@ const defaultSandboxExecSignalSource: SandboxExecSignalSource = { export async function runSandboxExecChild( binary: string, args: readonly string[], + options: SandboxExecOptions = {}, spawnChild: SandboxExecSpawner = defaultSandboxExecSpawner, signalSource: SandboxExecSignalSource = defaultSandboxExecSignalSource, ): Promise { let child: SandboxExecChild; try { - child = spawnChild(binary, args); + child = spawnChild(binary, args, options); } catch (error) { return { status: null, error: error instanceof Error ? error : new Error(String(error)) }; } @@ -395,7 +403,7 @@ export async function execSandbox( const { CLI_NAME } = require("../../cli/branding"); if (command.length === 0) { console.error( - ` Usage: ${CLI_NAME} ${sandboxName} exec [--workdir ] [--tty|--no-tty] [--timeout ] -- [args...]`, + ` Usage: ${CLI_NAME} ${sandboxName} exec [--workdir ] [--tty|--no-tty] [--timeout ] [--stdin|--no-stdin] -- [args...]`, ); process.exit(2); } @@ -414,7 +422,7 @@ export async function execSandbox( sandboxName, wrapExecCommandWithRuntimeEnv(command), options, - deps.run ?? runSandboxExecChild, + deps.run ?? ((runBinary, runArgs) => runSandboxExecChild(runBinary, runArgs, options)), deps.cleanupDeps ?? { getSandbox: (name) => (require("../../state/registry") as typeof import("../../state/registry")).getSandbox(name),