Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -568,17 +568,27 @@ 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 <name> exec -- bash -lc "cmd1; cmd2"`).
Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | nemohermes <name> exec -- bash`).
Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | nemohermes <name> exec --stdin -- bash`).
Or write the script to a file in the sandbox and run it (`nemohermes <name> exec -- bash <script-path>`).

| Flag | Description |
|------|-------------|
| `--workdir <dir>` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> 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 <seconds>` | 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 <name> agent`

Expand Down Expand Up @@ -829,14 +839,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 <dir>] [--tty|--no-tty] [--timeout <s>] -- <cmd> [args...]
nemohermes my-assistant exec [--workdir <dir>] [--tty|--no-tty] [--timeout <s>] [--stdin|--no-stdin] -- <cmd> [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 <dir>` | Set the working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> 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 <s>` | 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 <name> logs`

Expand Down
19 changes: 17 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -698,17 +698,27 @@ The exit code is the remote command's exit code.

</AgentOnly>

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 <name> exec -- bash -lc "cmd1; cmd2"`).
Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | $$nemoclaw <name> exec -- bash`).
Pipe the script into the sandbox shell over stdin (`printf 'cmd1\ncmd2\n' | $$nemoclaw <name> exec --stdin -- bash`).
Or write the script to a file in the sandbox and run it (`$$nemoclaw <name> exec -- bash <script-path>`).

| Flag | Description |
|------|-------------|
| `--workdir <dir>` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> 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 <seconds>` | 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 <name> agent`

Expand Down Expand Up @@ -1078,14 +1088,19 @@ The command exits with the remote command's exit code.
</AgentOnly>

```bash
$$nemoclaw my-assistant exec [--workdir <dir>] [--tty|--no-tty] [--timeout <s>] -- <cmd> [args...]
$$nemoclaw my-assistant exec [--workdir <dir>] [--tty|--no-tty] [--timeout <s>] [--stdin|--no-stdin] -- <cmd> [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 <dir>` | Set the working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> 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 <s>` | 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 <name> logs`

Expand Down
40 changes: 37 additions & 3 deletions src/commands/sandbox/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
});

Expand All @@ -38,6 +38,7 @@ describe("SandboxExecCommand oclif parse path", () => {
workdir: "/sandbox/workspace",
tty: null,
timeoutSeconds: undefined,
stdin: undefined,
});
});

Expand All @@ -51,6 +52,7 @@ describe("SandboxExecCommand oclif parse path", () => {
workdir: undefined,
tty: null,
timeoutSeconds: undefined,
stdin: undefined,
});
});

Expand All @@ -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 },
);
});

Expand All @@ -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 },
);
});

Expand All @@ -84,6 +86,7 @@ describe("SandboxExecCommand oclif parse path", () => {
workdir: undefined,
tty: true,
timeoutSeconds: 30,
stdin: undefined,
});
execSandboxMock.mockReset();

Expand All @@ -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,
});
});
});
13 changes: 11 additions & 2 deletions src/commands/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ["<name> [--workdir <dir>] [--tty|--no-tty] [--timeout <s>] -- <cmd> [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 = [
"<name> [--workdir <dir>] [--tty|--no-tty] [--timeout <s>] [--stdin|--no-stdin] -- <cmd> [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 }),
Expand All @@ -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<void> {
Expand All @@ -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,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 4 additions & 3 deletions src/lib/actions/sandbox/exec-policy-hint-emission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
44 changes: 23 additions & 21 deletions src/lib/actions/sandbox/exec.multiline-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("node:child_process")>();
return { ...actual, spawn: vi.fn() };
Expand Down Expand Up @@ -64,13 +62,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 <script-path>");
});

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");
});

Expand Down Expand Up @@ -254,10 +252,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 <sb> 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 <sb> 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);
Expand All @@ -279,13 +277,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,
Expand All @@ -294,6 +295,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));
Expand All @@ -305,13 +307,13 @@ 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",
["sandbox", "exec", "--name", "bug5980test", "--", "bash"],
{ stdio: "inherit" },
{ stdio: expectedStdio },
);
expect(exitSpy).toHaveBeenCalledWith(0);
});
Expand Down
Loading
Loading