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
58 changes: 58 additions & 0 deletions src/lib/openshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ function stubSpawnSync(spec: SpawnResultSpec): OpenshellSpawnSync {
return () => makeSpawnResult(spec);
}

function timeoutError(): Error {
return Object.assign(new Error("spawnSync openshell ETIMEDOUT"), { code: "ETIMEDOUT" });
}

function exitWithCode(code: number): never {
throw new Error(`exit:${code}`);
}
Expand Down Expand Up @@ -94,6 +98,60 @@ describe("openshell helpers", () => {
expect(result.status).toBe(0);
});

it("passes timeout options through to OpenShell spawn calls", () => {
const timeouts: Array<number | undefined> = [];
const spawnSyncImpl: OpenshellSpawnSync = (_command, _args, options) => {
timeouts.push(options.timeout);
return makeSpawnResult({ status: 0, stdout: "ok\n", stderr: "" });
};

runOpenshellCommand("openshell", ["status"], { timeout: 4321, spawnSyncImpl });
captureOpenshellCommand("openshell", ["status"], { timeout: 9876, spawnSyncImpl });

expect(timeouts).toEqual([4321, 9876]);
});

it("returns ignored run timeouts so callers can fall back", () => {
const result = runOpenshellCommand("openshell", ["status"], {
ignoreError: true,
timeout: 1,
spawnSyncImpl: stubSpawnSync({
status: null,
stdout: "",
stderr: "",
error: timeoutError(),
signal: "SIGTERM",
}),
exit: exitWithCode,
});

expect(result.status).toBeNull();
expect(result.signal).toBe("SIGTERM");
expect(result.error?.message).toContain("ETIMEDOUT");
});

it("returns ignored capture timeouts with timeout metadata", () => {
const result = captureOpenshellCommand("openshell", ["sandbox", "list"], {
ignoreError: true,
timeout: 1,
spawnSyncImpl: stubSpawnSync({
status: null,
stdout: "partial\n",
stderr: "timeout detail\n",
error: timeoutError(),
signal: "SIGTERM",
}),
exit: exitWithCode,
});

expect(result).toEqual({
status: null,
output: "partial",
error: expect.objectContaining({ message: expect.stringContaining("ETIMEDOUT") }),
signal: "SIGTERM",
});
});

it("uses the injected exit handler on failure", () => {
expect(() =>
runOpenshellCommand("openshell", ["status"], {
Expand Down
28 changes: 23 additions & 5 deletions src/lib/openshell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,24 @@ export type OpenshellSpawnSync = (
interface OpenshellSpawnOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeout?: number;
ignoreError?: boolean;
spawnSyncImpl?: OpenshellSpawnSync;
errorLine?: (message: string) => void;
exit?: (code: number) => never;
}

export interface RunOpenshellOptions extends OpenshellSpawnOptions {
stdio?: SpawnSyncOptions["stdio"];
ignoreError?: boolean;
}

export interface CaptureOpenshellOptions extends OpenshellSpawnOptions {
ignoreError?: boolean;
}
export interface CaptureOpenshellOptions extends OpenshellSpawnOptions {}

export interface CaptureOpenshellResult {
status: number;
status: number | null;
output: string;
error?: Error;
signal?: NodeJS.Signals | null;
}

// eslint-disable-next-line no-control-regex
Expand Down Expand Up @@ -76,6 +77,10 @@ function handleSpawnError(
return (opts.exit ?? ((code) => process.exit(code)))(1);
}

function isIgnoredTimeout(error: Error, opts: OpenshellSpawnOptions): boolean {
return opts.ignoreError === true && (error as NodeJS.ErrnoException).code === "ETIMEDOUT";
}

export function runOpenshellCommand(
binary: string,
args: string[],
Expand All @@ -87,8 +92,12 @@ export function runOpenshellCommand(
env: { ...process.env, ...opts.env },
encoding: "utf-8",
stdio: opts.stdio ?? "inherit",
timeout: opts.timeout,
});
if (result.error) {
if (isIgnoredTimeout(result.error, opts)) {
return result;
}
return handleSpawnError(binary, args, result.error, opts);
}
if (result.status !== 0 && !opts.ignoreError) {
Expand All @@ -111,8 +120,17 @@ export function captureOpenshellCommand(
env: { ...process.env, ...opts.env },
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: opts.timeout,
});
if (result.error) {
if (isIgnoredTimeout(result.error, opts)) {
return {
status: result.status,
output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(),
error: result.error,
signal: result.signal,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return handleSpawnError(binary, args, result.error, opts);
}
return {
Expand Down
191 changes: 188 additions & 3 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const { execFileSync, spawnSync } = require("child_process");
const { execFileSync, spawn, spawnSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const os = require("os");
Expand Down Expand Up @@ -113,6 +113,8 @@ type SpawnLikeResult = {
stdout?: string;
stderr?: string;
output?: string;
error?: Error;
signal?: NodeJS.Signals | null;
};

type SandboxCommandResult = {
Expand All @@ -131,6 +133,8 @@ const REMOTE_UNINSTALL_URL = buildVersionedUninstallUrl(getVersion());
let OPENSHELL_BIN: string | null = null;
const NEMOCLAW_GATEWAY_NAME = "nemoclaw";
const DASHBOARD_FORWARD_PORT = String(DASHBOARD_PORT);
const DEFAULT_LOGS_PROBE_TIMEOUT_MS = 5000;
const LOGS_PROBE_TIMEOUT_ENV = "NEMOCLAW_LOGS_PROBE_TIMEOUT_MS";

function getOpenshellBinary(): string {
if (!OPENSHELL_BIN) {
Expand Down Expand Up @@ -1904,8 +1908,14 @@ async function sandboxStatus(sandboxName: string) {
}

function sandboxLogs(sandboxName: string, follow: boolean) {
const args = buildSandboxLogsArgs(sandboxName, follow);
if (follow) {
streamSandboxFollowLogs(sandboxName);
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
yimoj marked this conversation as resolved.
Resolved
}

enableSandboxAuditLogs(sandboxName);
runOpenclawGatewayLogs(sandboxName, false);
const args = buildSandboxLogsArgs(sandboxName, false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const result = runOpenshell(args, {
stdio: "inherit",
ignoreError: true,
Expand All @@ -1916,7 +1926,174 @@ function sandboxLogs(sandboxName: string, follow: boolean) {
exitWithSpawnResult(result);
}

function buildSandboxLogsArgs(sandboxName: string, follow: boolean): string[] {
function getLogsProbeTimeoutMs(): number {
const rawValue = process.env[LOGS_PROBE_TIMEOUT_ENV];
if (!rawValue) {
return DEFAULT_LOGS_PROBE_TIMEOUT_MS;
}
const parsed = Number(rawValue);
const timeoutMs = Number.isFinite(parsed) ? Math.floor(parsed) : Number.NaN;
return timeoutMs > 0 ? timeoutMs : DEFAULT_LOGS_PROBE_TIMEOUT_MS;
}

function describeLogProbeResult(result: SpawnLikeResult): string {
if (result.error) {
return result.error.message;
}
if (result.signal) {
return `signal ${result.signal}`;
}
return `exit ${result.status ?? "unknown"}`;
}

function runOpenclawGatewayLogs(sandboxName: string, follow: boolean): SpawnLikeResult {
const args = buildSandboxOpenclawGatewayLogsArgs(sandboxName, follow);
const result = runOpenshell(args, {
stdio: "inherit",
ignoreError: true,
timeout: getLogsProbeTimeoutMs(),
});
if (result.status !== 0) {
console.error(
` OpenClaw log source unavailable (${describeLogProbeResult(result)}): ` +
`openshell ${args.join(" ")}`,
);
}
return result;
}

function streamSandboxFollowLogs(sandboxName: string): void {
const openclawArgs = buildSandboxOpenclawGatewayLogsArgs(sandboxName, true);
const openshellArgs = buildSandboxLogsArgs(sandboxName, true);
const spawnOptions = {
cwd: ROOT,
env: process.env,
stdio: "inherit" as const,
};
const sources: Array<{
label: string;
args: string[];
child: import("node:child_process").ChildProcess;
done: boolean;
}> = [];
let exiting = false;
let completedSources = 0;
let finalStatus = 0;
let requestedExitCode: number | null = null;
let forcedExitTimer: NodeJS.Timeout | null = null;
// Guard against early exit: a source spawned before enableSandboxAuditLogs
// can fire its exit event during the blocking spawnSync call, before the
// second source is registered. Without this flag, maybeExit would see
// completedSources === sources.length === 1 and exit prematurely.
let setupComplete = false;

const stopChildren = (signal: NodeJS.Signals) => {
for (const { child } of sources) {
if (!child.killed && child.exitCode === null && child.signalCode === null) {
child.kill(signal);
}
}
};
const maybeExit = () => {
if (!setupComplete || completedSources !== sources.length) {
return;
}
if (forcedExitTimer) {
clearTimeout(forcedExitTimer);
forcedExitTimer = null;
}
process.exit(requestedExitCode ?? finalStatus);
};
const exitFromSignal = (signal: NodeJS.Signals | null): number => {
if (!signal) return 1;
const signalNumber = os.constants.signals[signal];
return signalNumber ? 128 + signalNumber : 1;
};
const markSourceDone = (
source: (typeof sources)[number],
status: number,
detail: string | null = null,
) => {
if (source.done) return;
source.done = true;
completedSources += 1;
if (status !== 0 && finalStatus === 0) {
finalStatus = status;
}
if (completedSources < sources.length && !exiting) {
const suffix = detail || `exit ${status}`;
console.error(` ${source.label} stopped (${suffix}); continuing with remaining log source.`);
}
maybeExit();
};
const requestExitAfterSignal = (signal: NodeJS.Signals, exitCode: number) => {
if (requestedExitCode !== null) return;
exiting = true;
requestedExitCode = exitCode;
stopChildren(signal);
forcedExitTimer = setTimeout(() => process.exit(exitCode), 2000);
forcedExitTimer.unref?.();
maybeExit();
};

process.once("SIGINT", () => {
requestExitAfterSignal("SIGINT", 130);
});
process.once("SIGTERM", () => {
requestExitAfterSignal("SIGTERM", 143);
});

const addSource = (label: string, args: string[]) => {
const source = { label, args, child: spawn(getOpenshellBinary(), args, spawnOptions), done: false };
sources.push(source);
source.child.on("error", (error: Error) => {
markSourceDone(source, 1, error.message);
});
source.child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
markSourceDone(source, code ?? exitFromSignal(signal), signal ? `signal ${signal}` : null);
});
};

addSource("OpenClaw log source", openclawArgs);
enableSandboxAuditLogs(sandboxName);
addSource("OpenShell log source", openshellArgs);
setupComplete = true;
maybeExit();
}

function enableSandboxAuditLogs(sandboxName: string) {
const args = buildEnableSandboxAuditLogsArgs(sandboxName);
const result = runOpenshell(args, {
stdio: ["ignore", "ignore", "pipe"],
ignoreError: true,
timeout: getLogsProbeTimeoutMs(),
});
if (result.status !== 0) {
warnSandboxAuditLogsUnavailable(sandboxName, args, result);
}
}

function warnSandboxAuditLogsUnavailable(
sandboxName: string,
args: string[],
result: SpawnLikeResult,
): void {
const stderr = String(result.stderr || "").trim();
console.error(
` Warning: failed to enable OpenShell audit logs for sandbox '${sandboxName}' ` +
`(${describeLogProbeResult(result)}): openshell ${args.join(" ")}`,
);
if (stderr) {
console.error(` ${stderr}`);
}
console.error(" Policy denial events may be missing from OpenShell logs.");
}

function buildEnableSandboxAuditLogsArgs(sandboxName: string): string[] {
return ["settings", "set", sandboxName, "--key", "ocsf_json_enabled", "--value", "true"];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

function buildSandboxOpenclawGatewayLogsArgs(sandboxName: string, follow: boolean): string[] {
const args = ["sandbox", "exec", "-n", sandboxName, "--", "tail", "-n", "200"];
if (follow) {
args.push("-f");
Expand All @@ -1925,6 +2102,14 @@ function buildSandboxLogsArgs(sandboxName: string, follow: boolean): string[] {
return args;
}

function buildSandboxLogsArgs(sandboxName: string, follow: boolean): string[] {
const args = ["logs", sandboxName, "-n", "200", "--source", "all"];
if (follow) {
args.push("--tail");
}
return args;
}

/**
* Handle `nemoclaw <sandbox> policy-add [flags]`. Supports three mutually
* exclusive modes: interactive preset picker (default), `--from-file <path>`
Expand Down
Loading
Loading