Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 @@ -573,17 +573,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 @@ -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 <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 @@ -703,17 +703,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 @@ -1083,14 +1093,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
35 changes: 35 additions & 0 deletions src/lib/actions/sandbox/exec-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions src/lib/actions/sandbox/exec-stdio.ts
Original file line number Diff line number Diff line change
@@ -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"];
}
Loading
Loading