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
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/agent/passthrough-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe("runAgentJsonPassthrough", () => {
runAgentJsonPassthrough("alpha", ["openclaw", "agent", "--json"], proc, {
getOpenshellBinary: () => "/usr/local/bin/openshell",
provenanceLines: () => {
throw new RangeError("Maximum call stack size exceeded");
throw new SyntaxError("Unexpected token in OpenClaw JSON output");
},
spawnSync,
}),
Expand Down
217 changes: 217 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit";

const execMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureLiveMock = vi.hoisted(() =>
vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }),
);
const getSandboxMock = vi.hoisted(() => vi.fn(() => ({ agent: "openclaw" })));
const listAgentsMock = vi.hoisted(() => vi.fn(() => ["langchain-deepagents-code", "openclaw"]));
const loadAgentMock = vi.hoisted(() =>
vi.fn((name: string) => ({
name,
runtime:
name === "langchain-deepagents-code"
? { kind: "terminal", interactive_command: "dcode", headless_command: "dcode -n" }
: undefined,
})),
);
const isTerminalAgentMock = vi.hoisted(() =>
vi.fn((agent: { runtime?: { kind?: string } }) => agent.runtime?.kind === "terminal"),
);

vi.mock("../exec", () => ({ execSandbox: execMock }));
vi.mock("../gateway-state", () => ({ ensureLiveSandboxOrExit: ensureLiveMock }));
vi.mock("../../../state/registry", () => ({ getSandbox: getSandboxMock }));
vi.mock("../../../agent/defs", () => ({
isTerminalAgent: isTerminalAgentMock,
listAgents: listAgentsMock,
loadAgent: loadAgentMock,
}));
vi.mock("../../../shields/audit", () => ({
readRecentShieldsAutoRestore: vi.fn(() => ({ kind: "none" })),
}));

import { type AgentPassthroughDeps, runAgentPassthrough } from "./passthrough";

function makeProcMock() {
const writes: string[] = [];
return {
writes,
proc: {
exit: ((code: number): never => {
throw new Error(`__exit:${code}`);
}) as (code: number) => never,
stderr: { write: (value: string) => writes.push(value) },
},
};
}

describe("runAgentPassthrough shields-relock warning", () => {
beforeEach(() => {
vi.clearAllMocks();
});

async function runWarning(
result: ShieldsAutoRestoreReadResult,
sandboxName = "alpha",
): Promise<string> {
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
const { writes, proc } = makeProcMock();
await runAgentPassthrough(
sandboxName,
{ extraArgs: ["--agent", "main", "-m", "hi"] },
{ process: proc, getRecentShieldsAutoRestore: () => result },
);
return writes.join("");
}

it("emits the original timeout after a recent auto-relock (#5922)", async () => {
const output = await runWarning({
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 },
});
expect(execMock).toHaveBeenCalled();
expect(output).toMatch(/[Ss]hields auto-relocked after 20s/);
expect(output).toMatch(/shields down --timeout 20s/);
});

it("uses the safe fallback timeout when the original timeout is unavailable (#5922)", async () => {
const output = await runWarning({
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: null },
});
expect(execMock).toHaveBeenCalled();
expect(output).toMatch(/[Ss]hields auto-relocked/);
expect(output).toMatch(/shields down --timeout 60s/);
});

it("defensively rejects an invalid injected timeout from the command suggestion (#5922)", async () => {
const output = await runWarning({
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 9999 },
});
expect(output).not.toContain("9999s");
expect(output).toMatch(/shields down --timeout 60s/);
});

it("shell-quotes sandbox names in recovery command suggestions (#5922)", async () => {
const output = await runWarning(
{
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 },
},
"alpha; touch /tmp/pwn",
);
expect(output).toContain("nemoclaw 'alpha; touch /tmp/pwn' shields down --timeout 20s");
expect(output).not.toContain("nemoclaw alpha; touch /tmp/pwn");
});

it("escapes embedded single quotes in recovery command suggestions (#5922)", async () => {
const output = await runWarning(
{
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 },
},
"alpha'beta",
);
expect(output).toContain("nemoclaw 'alpha'\\''beta' shields down --timeout 20s");
});

it("keeps JSON stdout parseable while warning from a real audit file on stderr (#5922)", async () => {
const actualAudit =
await vi.importActual<typeof import("../../../shields/audit")>("../../../shields/audit");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-warning-"));
const auditPath = path.join(tempDir, "shields-audit.jsonl");
const restoreTimestamp = new Date().toISOString();
const stdoutWrites: string[] = [];
const { writes, proc } = makeProcMock();
const processWithStdout = {
...proc,
stdout: { write: (value: string) => stdoutWrites.push(value) },
};
const execJson = vi.fn(((_sandboxName, _command, jsonProc): never => {
jsonProc?.stdout.write('{"ok":true}\n');
throw new Error("__json-exit:0");
}) as NonNullable<AgentPassthroughDeps["execJson"]>);

try {
fs.writeFileSync(
auditPath,
[
JSON.stringify({
action: "shields_down",
sandbox: "alpha",
timestamp: new Date(Date.now() - 20 * 1000).toISOString(),
timeout_seconds: 20,
}),
JSON.stringify({
action: "shields_auto_restore",
sandbox: "alpha",
timestamp: restoreTimestamp,
}),
].join("\n") + "\n",
);

await expect(
runAgentPassthrough(
"alpha",
{ extraArgs: ["--agent", "main", "-m", "hi", "--json"] },
{
process: processWithStdout,
execJson,
getRecentShieldsAutoRestore: (sandboxName) =>
actualAudit.readRecentShieldsAutoRestore(sandboxName, 10 * 60 * 1000, auditPath),
},
),
).rejects.toThrow("__json-exit:0");

expect(JSON.parse(stdoutWrites.join(""))).toEqual({ ok: true });
expect(writes.join("")).toMatch(/Shields auto-relocked after 20s/);
expect(execJson).toHaveBeenCalledOnce();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

it("emits no relock warning when the audit has no recent event (#5922)", async () => {
const output = await runWarning({ kind: "none" });
expect(execMock).toHaveBeenCalled();
expect(output).not.toMatch(/[Ss]hields auto-relocked/);
});

it("reports unreadable audit history without blocking agent dispatch (#5922)", async () => {
const output = await runWarning({ kind: "unreadable" });
expect(execMock).toHaveBeenCalled();
expect(output).toMatch(/Could not read shields audit history/);
expect(output).toMatch(/shields status/);
});

it("does not consult OpenClaw relock history for terminal-runtime passthroughs (#5922)", async () => {
getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" });
const getRecentShieldsAutoRestore = vi.fn(
(): ShieldsAutoRestoreReadResult => ({
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 },
}),
);
const { writes, proc } = makeProcMock();

await runAgentPassthrough(
"alpha",
{ extraArgs: ["--help"] },
{ process: proc, getRecentShieldsAutoRestore },
);

expect(execMock).toHaveBeenCalledWith("alpha", ["dcode", "--help"], { tty: false });
expect(getRecentShieldsAutoRestore).not.toHaveBeenCalled();
expect(writes.join("")).not.toMatch(/[Ss]hields auto-relocked/);
});
});
87 changes: 87 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-shields-warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { CLI_NAME } from "../../../cli/branding";
import { shellQuote } from "../../../core/shell-quote";
import {
readRecentShieldsAutoRestore,
type ShieldsAutoRestoreEvent,
type ShieldsAutoRestoreReadResult,
} from "../../../shields/audit";

// Source-of-truth boundary for the host CLI relock diagnostic:
//
// - Invalid state: after shields auto-relock, OpenClaw can report only
// `missing scope: operator.write`; an older relock warning also becomes stale
// after the user lowers shields again.
// - Source boundary: OpenShell/OpenClaw own current scope state. NemoClaw audit
// JSONL is non-authoritative context. Validated chronology may suppress stale
// context but never establishes current policy state, and unreadable history
// never blocks dispatch. The audit writers are the shields timer and inline
// expired-timer recovery paths.
// - Presentation boundary: sandbox names are user-controlled command text and
// must remain shell-quoted. Direct stderr output is deliberate so the warning
// is visible in a one-shot CLI while machine-readable stdout stays clean.
// - Source-fix constraint: an already-running in-sandbox TUI has no host CLI
// interception point. That surface needs an upstream structured relock error
// or a separate extend-on-activity design; this helper covers only host
// `nemoclaw <name> agent` dispatches.
// - Regression tests cover validated/fallback timeouts, shell metacharacters
// and embedded quotes, real-file JSON stdout separation, unreadable/absent
// history, newer-down suppression, and terminal-runtime exclusion.
// - Removal condition: drop this diagnostic when OpenClaw exposes the relock
// cause directly or NemoClaw prevents mid-session relock by extending on
// activity.

// A relock remains useful context briefly after it happens. This is a
// relevance window measured from the restore event, independent of the
// original shields-down timeout; a longer window risks stale-session warnings.
const SHIELDS_RELOCK_WARNING_WINDOW_MS = 10 * 60 * 1000;

type ShieldsWarningProcess = {
stderr: { write(value: string): unknown };
};

type RecentShieldsAutoRestoreReader = (sandboxName: string) => ShieldsAutoRestoreReadResult;

function emitShieldsRelockWarning(
proc: ShieldsWarningProcess,
relock: ShieldsAutoRestoreEvent,
sandboxName: string,
): void {
// Defend the user-facing command suggestion even when tests or future
// callers inject an event without going through the audit reader.
const timeoutSeconds =
relock.timeoutSeconds !== null &&
Number.isInteger(relock.timeoutSeconds) &&
relock.timeoutSeconds >= 1 &&
relock.timeoutSeconds <= 1800
? relock.timeoutSeconds
: null;
const afterPart = timeoutSeconds !== null ? ` after ${String(timeoutSeconds)}s` : "";
const timeoutSuggestion =
timeoutSeconds !== null ? `--timeout ${String(timeoutSeconds)}s` : "--timeout 60s";
proc.stderr.write(
` ⚠ Shields auto-relocked${afterPart} — run \`${CLI_NAME} ${shellQuote(sandboxName)} shields down ${timeoutSuggestion}\` to extend.\n`,
);
}

function emitShieldsAuditUnreadableWarning(proc: ShieldsWarningProcess, sandboxName: string): void {
proc.stderr.write(
` ⚠ Could not read shields audit history; continuing without relock context. Run \`${CLI_NAME} ${shellQuote(sandboxName)} shields status\` to verify current state.\n`,
);
}

export function maybeEmitShieldsRelockWarning(
proc: ShieldsWarningProcess,
sandboxName: string,
getRecentShieldsAutoRestore: RecentShieldsAutoRestoreReader = (name) =>
readRecentShieldsAutoRestore(name, SHIELDS_RELOCK_WARNING_WINDOW_MS),
): void {
const relock = getRecentShieldsAutoRestore(sandboxName);
if (relock.kind === "event") {
emitShieldsRelockWarning(proc, relock.event, sandboxName);
} else if (relock.kind === "unreadable") {
emitShieldsAuditUnreadableWarning(proc, sandboxName);
}
}
12 changes: 9 additions & 3 deletions src/lib/actions/sandbox/agent/passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ vi.mock("../../../agent/defs", () => ({
listAgents: listAgentsMock,
loadAgent: loadAgentMock,
}));
// Default to no recent shields auto-restore so tests that don't inject
// getRecentShieldsAutoRestore don't read ~/.nemoclaw/state/shields-audit.jsonl.
vi.mock("../../../shields/audit", () => ({
readRecentShieldsAutoRestore: vi.fn(() => ({ kind: "none" })),
}));

import { type AgentPassthroughDeps, runAgentPassthrough } from "./passthrough";

Expand Down Expand Up @@ -147,22 +152,23 @@ describe("runAgentPassthrough", () => {
);
});

it("keeps --json after an unknown future value flag on the normal passthrough path", async () => {
it("keeps --json-something --json on the normal passthrough path", async () => {
const execJson = vi.fn(((): never => {
throw new Error("__unexpected-json");
}) as NonNullable<AgentPassthroughDeps["execJson"]>);
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });

// The first unknown flag selects conservative passthrough before the later --json token.
await runAgentPassthrough(
"alpha",
{ extraArgs: ["--agent", "work", "--some-future-value-flag", "--json"] },
{ extraArgs: ["--agent", "work", "--json-something", "--json"] },
{ execJson },
);

expect(execJson).not.toHaveBeenCalled();
expect(execMock).toHaveBeenCalledWith(
"alpha",
["openclaw", "agent", "--agent", "work", "--some-future-value-flag", "--json"],
["openclaw", "agent", "--agent", "work", "--json-something", "--json"],
{ tty: false },
);
});
Expand Down
Loading
Loading