Skip to content
Merged
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
7 changes: 4 additions & 3 deletions docs/reference/cli-selection-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ Use `openshell` when the docs explicitly call for a live OpenShell gateway opera
```console
$ openshell sandbox list
$ openshell sandbox get <sandbox-name>
$ openshell logs <sandbox-name> -n 200 --tail
$ openshell logs <sandbox-name> -n 20
$ openshell doctor check
```

- Run one-off commands or move files without starting a NemoClaw chat session:
Expand Down Expand Up @@ -151,8 +152,8 @@ $ openshell sandbox exec -n my-assistant -- cat /tmp/gateway.log
Use `nemoclaw <name> status` and `nemoclaw <name> logs` first.
They combine NemoClaw registry data, OpenShell state, OpenClaw process health, inference health, policy details, and messaging-channel warnings.

Use `openshell sandbox list`, `openshell sandbox get`, or `openshell logs` when debugging lower-level OpenShell behavior.
When using `openshell logs` directly, `--tail` follows live output and `-n <lines>` controls the line count; NemoClaw's `logs --tail <lines>` is the line-count form, and `logs --follow` opts into streaming.
Use `openshell sandbox list`, `openshell sandbox get`, `openshell logs <name> -n 20`, or `openshell doctor check` when debugging lower-level OpenShell behavior.
When using `openshell logs` directly, `-n <lines>` controls the line count; use `--tail` only when you want live OpenShell log streaming.

### Approve Blocked Network Requests

Expand Down
13 changes: 7 additions & 6 deletions src/lib/actions/sandbox/process-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as agentRuntime from "../../agent/runtime";
import { DASHBOARD_PORT } from "../../core/ports";
import { ROOT, shellQuote } from "../../runner";
import {
captureOpenshell,
captureOpenshellForStatus,
captureSandboxSshConfig,
getOpenshellBinary,
isCommandTimeout,
runOpenshell,
} from "../../adapters/openshell/runtime";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
import * as registry from "../../state/registry";
import { parseForwardList } from "../../state/sandbox-session";
import * as agentRuntime from "../../agent/runtime";
import { G, R } from "../../cli/terminal-style";
import { DASHBOARD_PORT } from "../../core/ports";
import { sleepSeconds } from "../../core/wait";
import { ROOT, shellQuote } from "../../runner";
import * as registry from "../../state/registry";
import { parseForwardList } from "../../state/sandbox-session";

export type SandboxCommandResult = {
status: number;
Expand Down Expand Up @@ -83,7 +84,7 @@ export function executeSandboxCommand(
sandboxName: string,
command: string,
): SandboxCommandResult | null {
const sshConfigResult = captureOpenshell(["sandbox", "ssh-config", sandboxName], {
const sshConfigResult = captureSandboxSshConfig(sandboxName, {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
Expand Down
11 changes: 6 additions & 5 deletions src/lib/actions/sandbox/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { captureSandboxSshConfig } from "../../adapters/openshell/runtime";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
import * as agentRuntime from "../../agent/runtime";
import { CLI_NAME } from "../../cli/branding";
import { captureOpenshell } from "../../adapters/openshell/runtime";
import { ensureLiveSandboxOrExit } from "./gateway-state";
import * as skillInstall from "../../skill-install";
import { D, G, R, YW } from "../../cli/terminal-style";
import * as skillInstall from "../../skill-install";
import { ensureLiveSandboxOrExit } from "./gateway-state";

export function printSkillInstallUsage(): void {
console.log("");
Expand Down Expand Up @@ -169,8 +169,9 @@ export async function installSandboxSkill(
const paths = skillInstall.resolveSkillPaths(agent, frontmatter.name);

// 4. Get SSH config
const sshConfigResult = captureOpenshell(["sandbox", "ssh-config", sandboxName], {
const sshConfigResult = captureSandboxSshConfig(sandboxName, {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
if (sshConfigResult.status !== 0) {
console.error(" Failed to obtain SSH configuration for the sandbox.");
Expand Down
66 changes: 65 additions & 1 deletion src/lib/adapters/openshell/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import type { SpawnSyncReturns } from "node:child_process";
import { describe, expect, it } from "vitest";

import {
captureOpenshellCommandAsync,
captureOpenshellCommand,
captureOpenshellCommandAsync,
captureSandboxSshConfigCommand,
getInstalledOpenshellVersion,
type OpenshellSpawnSync,
parseVersionFromText,
Expand Down Expand Up @@ -153,6 +154,59 @@ describe("openshell helpers", () => {
});
});

it("verifies sandbox existence before requesting SSH config", () => {
const calls: string[][] = [];
const spawnSyncImpl: OpenshellSpawnSync = (_command, args) => {
calls.push([...args]);
if (args.join(" ") === "sandbox get alpha") {
return makeSpawnResult({ status: 0, stdout: "alpha Ready\n", stderr: "" });
}
return makeSpawnResult({
status: 0,
stdout: "Host openshell-alpha\n",
stderr: "",
});
};

const result = captureSandboxSshConfigCommand("openshell", "alpha", { spawnSyncImpl });

expect(result).toEqual({ status: 0, output: "Host openshell-alpha" });
expect(calls).toEqual([
["sandbox", "get", "alpha"],
["sandbox", "ssh-config", "alpha"],
]);
});

it("does not request SSH config when the sandbox is missing", () => {
const calls: string[][] = [];
const spawnSyncImpl: OpenshellSpawnSync = (_command, args) => {
calls.push([...args]);
return makeSpawnResult({ status: 1, stdout: "", stderr: "sandbox not found\n" });
};

const result = captureSandboxSshConfigCommand("openshell", "bogus", { spawnSyncImpl });

expect(result).toEqual({ status: 1, output: "sandbox 'bogus' not found" });
expect(calls).toEqual([["sandbox", "get", "bogus"]]);
});

it("preserves non-NotFound sandbox lookup failures", () => {
const calls: string[][] = [];
const spawnSyncImpl: OpenshellSpawnSync = (_command, args) => {
calls.push([...args]);
return makeSpawnResult({
status: 1,
stdout: "",
stderr: "transport error\nConnection refused\n",
});
};

const result = captureSandboxSshConfigCommand("openshell", "alpha", { spawnSyncImpl });

expect(result).toEqual({ status: 1, output: "transport error\nConnection refused" });
expect(calls).toEqual([["sandbox", "get", "alpha"]]);
});

it("bounds async captures and reports timeout metadata", async () => {
const script = [
"const { spawn } = require('node:child_process');",
Expand All @@ -173,6 +227,16 @@ describe("openshell helpers", () => {
expect(result.signal).toBeTruthy();
});

it("includes stderr in async capture output when requested", async () => {
const result = await captureOpenshellCommandAsync(
process.execPath,
["-e", "process.stdout.write('hello\\n'); process.stderr.write('boom\\n'); process.exitCode = 1;"],
{ ignoreError: true, includeStderr: true },
);

expect(result).toEqual({ status: 1, output: "hello\nboom", signal: null });
});

it("uses the injected exit handler on failure", () => {
expect(() =>
runOpenshellCommand("openshell", ["status"], {
Expand Down
43 changes: 37 additions & 6 deletions src/lib/adapters/openshell/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
// SPDX-License-Identifier: Apache-2.0

import {
spawn,
spawnSync,
type ChildProcess,
type SpawnSyncOptions,
type SpawnSyncOptionsWithStringEncoding,
type SpawnSyncReturns,
spawn,
spawnSync,
} from "node:child_process";

export type OpenshellSpawnSync = (
Expand All @@ -32,7 +32,9 @@ export interface RunOpenshellOptions extends OpenshellSpawnOptions {
stdio?: SpawnSyncOptions["stdio"];
}

export interface CaptureOpenshellOptions extends OpenshellSpawnOptions {}
export interface CaptureOpenshellOptions extends OpenshellSpawnOptions {
includeStderr?: boolean;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface CaptureOpenshellAsyncOptions extends CaptureOpenshellOptions {
killGraceMs?: number;
Expand Down Expand Up @@ -88,6 +90,14 @@ function isIgnoredTimeout(error: Error, opts: OpenshellSpawnOptions): boolean {
return opts.ignoreError === true && (error as NodeJS.ErrnoException).code === "ETIMEDOUT";
}

function shouldIncludeStderr(opts: CaptureOpenshellOptions): boolean {
return opts.includeStderr === true || opts.ignoreError !== true;
}

function captureOutput(result: SpawnSyncReturns<string>, opts: CaptureOpenshellOptions): string {
return `${result.stdout || ""}${shouldIncludeStderr(opts) ? result.stderr || "" : ""}`.trim();
}

function timeoutError(binary: string, args: string[], timeout: number): NodeJS.ErrnoException {
const error = new Error(
`spawn ${binary} ${args.join(" ")} timed out after ${timeout} ms`,
Expand Down Expand Up @@ -158,7 +168,7 @@ export function captureOpenshellCommand(
if (isIgnoredTimeout(result.error, opts)) {
return {
status: result.status,
output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(),
output: captureOutput(result, opts),
error: result.error,
signal: result.signal,
};
Expand All @@ -167,10 +177,31 @@ export function captureOpenshellCommand(
}
return {
status: result.status ?? 1,
output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(),
output: captureOutput(result, opts),
};
}

export function captureSandboxSshConfigCommand(
binary: string,
sandboxName: string,
opts: CaptureOpenshellOptions = {},
): CaptureOpenshellResult {
const sandboxGet = captureOpenshellCommand(binary, ["sandbox", "get", sandboxName], {
...opts,
ignoreError: true,
includeStderr: true,
});
if (sandboxGet.status !== 0) {
const output = sandboxGet.output || `failed to query sandbox '${sandboxName}'`;
const sandboxMissing = /\bnot[- ]?found\b/i.test(output);
return {
...sandboxGet,
output: sandboxMissing ? `sandbox '${sandboxName}' not found` : output,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return captureOpenshellCommand(binary, ["sandbox", "ssh-config", sandboxName], opts);
}

export function captureOpenshellCommandAsync(
binary: string,
args: string[],
Expand Down Expand Up @@ -200,7 +231,7 @@ export function captureOpenshellCommandAsync(
if (forceTimer) clearTimeout(forceTimer);
};

const buildOutput = () => `${stdout}${opts.ignoreError ? "" : stderr}`.trim();
const buildOutput = () => `${stdout}${shouldIncludeStderr(opts) ? stderr : ""}`.trim();

const settle = (
status: number | null,
Expand Down
14 changes: 13 additions & 1 deletion src/lib/adapters/openshell/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { ROOT } from "../../runner";
import {
captureOpenshellCommand,
captureOpenshellCommandAsync,
captureSandboxSshConfigCommand,
getInstalledOpenshellVersion,
runOpenshellCommand,
} from "./client";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts";
import { resolveOpenshell } from "./resolve";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts";

type CommandArgs = string[];

Expand Down Expand Up @@ -59,6 +60,17 @@ export function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) {
});
}

export function captureSandboxSshConfig(sandboxName: string, opts: RunnerOptions = {}) {
return captureSandboxSshConfigCommand(getOpenshellBinary(), sandboxName, {
cwd: ROOT,
env: opts.env,
ignoreError: opts.ignoreError,
timeout: opts.timeout,
errorLine: console.error,
exit: (code: number) => process.exit(code),
});
}

export function getStatusProbeTimeoutMs(): number {
const raw = process.env.NEMOCLAW_STATUS_PROBE_TIMEOUT_MS;
const parsed = raw ? Number(raw) : NaN;
Expand Down
11 changes: 4 additions & 7 deletions src/lib/diagnostics/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function section(title: string): void {
// ---------------------------------------------------------------------------

import { redactFull as redact } from "../security/redact";

export { redact };

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -371,18 +372,14 @@ function collectSandboxInternals(
): void {
if (!commandExists("openshell")) return;

// Check if sandbox exists
// Check if sandbox exists. OpenShell ssh-config may succeed for unknown
// names, so verify the live sandbox first.
try {
const output = execFileSync("openshell", ["sandbox", "list"], {
execFileSync("openshell", ["sandbox", "get", sandboxName], {
encoding: "utf-8",
timeout: 10_000,
stdio: ["ignore", "pipe", "ignore"],
});
const names = output
.split("\n")
.map((l) => l.trim().split(/\s+/)[0])
.filter((n) => n && n.toLowerCase() !== "name");
if (!names.includes(sandboxName)) return;
} catch {
return;
}
Expand Down
Loading
Loading