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
255 changes: 255 additions & 0 deletions src/lib/actions/sandbox/exec-gateway-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it, vi } from "vitest";

import { execSandbox, type SandboxExecCleanupDeps } from "./exec";

const cleanupSkipped: SandboxExecCleanupDeps = {
getSandbox: () => null,
inspectMutableConfigPerms: (() => {
throw new Error("cleanup should be skipped");
}) as unknown as SandboxExecCleanupDeps["inspectMutableConfigPerms"],
repairMutableConfigPerms: (() => {
throw new Error("cleanup should be skipped");
}) as unknown as SandboxExecCleanupDeps["repairMutableConfigPerms"],
};

describe("execSandbox gateway targeting", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it("selects the sandbox's owning gateway before dispatching the exec", async () => {
const order: string[] = [];
const selectGateway = vi.fn((name: string) => {
order.push(`select:${name}`);
return { outcome: "selected" as const, gatewayName: name };
});
const run = vi.fn(async (_binary: string, _args: readonly string[]) => {
order.push("run");
return { status: 0 };
});
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`__exit_${code ?? 0}__`);
}) as never);
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(
execSandbox(
"beta",
["hostname"],
{},
{
resolveBinary: () => "openshell",
selectGateway,
run,
cleanupDeps: cleanupSkipped,
policyHint: {
now: () => 0,
env: {},
probeLogs: () => "",
enableAudit: () => {},
sleep: async () => {},
attempts: 1,
writeStderr: () => {},
},
},
),
).rejects.toThrow("__exit_0__");

expect(selectGateway).toHaveBeenCalledWith("beta");
expect(run).toHaveBeenCalled();
const execArgs = run.mock.calls[0]?.[1] ?? [];
expect(execArgs.slice(0, 7)).toEqual(["sandbox", "exec", "--name", "beta", "-g", "beta", "--"]);
expect(execArgs.at(-2)).toBe("nemoclaw-runtime-env");
expect(execArgs.at(-1)).toBe("hostname");
expect(order.indexOf("select:beta")).toBeGreaterThanOrEqual(0);
expect(order.indexOf("select:beta")).toBeLessThan(order.indexOf("run"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("selects the owning gateway before the workdir probe when a workdir is set", async () => {
const order: string[] = [];
vi.stubEnv("OPENSHELL_GATEWAY", "ambient-sibling");
const selectGateway = vi.fn((name: string) => {
order.push(`select:${name}`);
process.env.OPENSHELL_GATEWAY = "drifted-sibling";
return { outcome: "selected" as const, gatewayName: "nemoclaw-8091" };
});
const probeWorkdir = vi.fn((_binary: string, _args: readonly string[]) => {
order.push("probe");
return { status: 0, error: undefined };
});
const run = vi.fn(async (_binary: string, _args: readonly string[]) => {
order.push("run");
return { status: 0 };
});
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`__exit_${code ?? 0}__`);
}) as never);
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(
execSandbox(
"beta",
["hostname"],
{ workdir: "/work" },
{
resolveBinary: () => "openshell",
selectGateway,
probeWorkdir,
run,
cleanupDeps: cleanupSkipped,
policyHint: {
now: () => 0,
env: {},
probeLogs: () => "",
enableAudit: () => {},
sleep: async () => {},
attempts: 1,
writeStderr: () => {},
},
},
),
).rejects.toThrow("__exit_0__");

expect(order).toEqual(["select:beta", "probe", "run"]);
expect(process.env.OPENSHELL_GATEWAY).toBe("drifted-sibling");
expect(probeWorkdir.mock.calls[0]?.[1]).toEqual([
"sandbox",
"exec",
"--name",
"beta",
"-g",
"nemoclaw-8091",
"--",
"test",
"-d",
"/work",
]);
const execArgs = run.mock.calls[0]?.[1] ?? [];
expect(execArgs.slice(0, 9)).toEqual([
"sandbox",
"exec",
"--name",
"beta",
"-g",
"nemoclaw-8091",
"--workdir",
"/work",
"--",
]);
expect(execArgs.at(-2)).toBe("nemoclaw-runtime-env");
expect(execArgs.at(-1)).toBe("hostname");
});

it("rejects a direct endpoint override before selecting, probing, or dispatching", async () => {
vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://sibling.invalid");
const resolveBinary = vi.fn(() => "openshell");
const selectGateway = vi.fn();
const probeWorkdir = vi.fn();
const run = vi.fn();
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`__exit_${code ?? 0}__`);
}) as never);
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(
execSandbox(
"beta",
["hostname"],
{ workdir: "/work" },
{ resolveBinary, selectGateway, probeWorkdir, run },
),
).rejects.toThrow("__exit_1__");

expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("OPENSHELL_GATEWAY_ENDPOINT is set"),
);
expect(resolveBinary).not.toHaveBeenCalled();
expect(selectGateway).not.toHaveBeenCalled();
expect(probeWorkdir).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
});

it("keeps post-exec policy probes pinned after ambient gateway selection drifts", async () => {
vi.stubEnv("OPENSHELL_GATEWAY", "ambient-sibling");
const enableAudit = vi.fn();
const probeLogs = vi.fn(() => "");
const selectGateway = vi.fn(() => {
process.env.OPENSHELL_GATEWAY = "drifted-sibling";
return { outcome: "selected" as const, gatewayName: "nemoclaw-8091" };
});
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`__exit_${code ?? 0}__`);
}) as never);
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(
execSandbox(
"beta",
["curl", "https://example.invalid"],
{},
{
resolveBinary: () => "openshell",
selectGateway,
run: async () => ({ status: 56 }),
cleanupDeps: cleanupSkipped,
policyHint: {
now: () => 0,
env: {},
enableAudit,
probeLogs,
attempts: 1,
sleep: async () => {},
writeStderr: () => {},
},
},
),
).rejects.toThrow("__exit_56__");

expect(process.env.OPENSHELL_GATEWAY).toBe("drifted-sibling");
expect(enableAudit).toHaveBeenCalledWith("beta", "nemoclaw-8091");
expect(probeLogs).toHaveBeenCalledWith("beta", "nemoclaw-8091");
});

it("aborts before the workdir probe and exec when gateway selection fails", async () => {
const order: string[] = [];
const selectGateway = vi.fn(() => {
order.push("select");
return { outcome: "failed" as const, gatewayName: "nemoclaw-8091" };
});
const probeWorkdir = vi.fn(() => {
order.push("probe");
return { status: 0, error: undefined };
});
const run = vi.fn(async () => {
order.push("run");
return { status: 0 };
});
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`__exit_${code ?? 0}__`);
}) as never);
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(
execSandbox(
"beta",
["hostname"],
{ workdir: "/work" },
{
resolveBinary: () => "openshell",
selectGateway,
probeWorkdir,
run,
cleanupDeps: cleanupSkipped,
},
),
).rejects.toThrow("__exit_1__");

expect(order).toEqual(["select"]);
expect(probeWorkdir).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
});
});
17 changes: 9 additions & 8 deletions src/lib/actions/sandbox/exec-policy-hint-emission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const POLICY_HINT_PROBE_ATTEMPTS = 3;
export const POLICY_HINT_PROBE_RETRY_MS = 120;
export const POLICY_HINT_MAX_RUNTIME_TIMEOUT_MS = 1_000;

export type PolicyDenialLogProbe = (sandboxName: string) => string;
export type PolicyDenialAuditEnabler = (sandboxName: string) => void;
export type PolicyDenialLogProbe = (sandboxName: string, gatewayName?: string) => string;
export type PolicyDenialAuditEnabler = (sandboxName: string, gatewayName?: string) => void;

export type PolicyDenialHintDeps = {
probeLogs?: PolicyDenialLogProbe;
Expand All @@ -45,8 +45,8 @@ function runtimeTimeoutMs(): number {
return Math.min(getLogsProbeTimeoutMs(), POLICY_HINT_MAX_RUNTIME_TIMEOUT_MS);
}

function defaultEnableAudit(sandboxName: string): void {
const result = captureOpenshell(buildEnableSandboxAuditLogsArgs(sandboxName), {
function defaultEnableAudit(sandboxName: string, gatewayName?: string): void {
const result = captureOpenshell(buildEnableSandboxAuditLogsArgs(sandboxName, gatewayName), {
ignoreError: true,
includeStderr: true,
timeout: runtimeTimeoutMs(),
Expand All @@ -56,13 +56,13 @@ function defaultEnableAudit(sandboxName: string): void {
}
}

function defaultProbeLogs(sandboxName: string): string {
function defaultProbeLogs(sandboxName: string, gatewayName?: string): string {
const options: SandboxLogsOptions = {
follow: false,
lines: String(POLICY_HINT_TAIL_LINES),
since: null,
};
const result = captureOpenshell(buildSandboxLogsArgs(sandboxName, options), {
const result = captureOpenshell(buildSandboxLogsArgs(sandboxName, options, gatewayName), {
ignoreError: true,
includeStderr: true,
timeout: runtimeTimeoutMs(),
Expand Down Expand Up @@ -90,6 +90,7 @@ export async function maybeEmitPolicyDenialHint(
hadInvocationError: boolean,
commandStartedAtMs: number,
deps: PolicyDenialHintDeps = {},
gatewayName?: string,
): Promise<string | null> {
const env = deps.env ?? process.env;
if (!shouldProbePolicyDenial(commandCode, hadInvocationError, env)) return null;
Expand All @@ -101,7 +102,7 @@ export async function maybeEmitPolicyDenialHint(
const retryDelayMs = deps.retryDelayMs ?? POLICY_HINT_PROBE_RETRY_MS;

try {
enableAudit(sandboxName);
enableAudit(sandboxName, gatewayName);
} catch {
// Deliberately silent: audit setup is optional and retained logs may still
// contain the denial. Printing this diagnostic, even under a new debug
Expand All @@ -112,7 +113,7 @@ export async function maybeEmitPolicyDenialHint(
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let logOutput: string;
try {
logOutput = probeLogs(sandboxName);
logOutput = probeLogs(sandboxName, gatewayName);
} catch {
// Deliberately silent for the same output-preservation boundary: a failed
// optional probe must not append host diagnostics to the child's error.
Expand Down
2 changes: 2 additions & 0 deletions src/lib/actions/sandbox/exec-policy-hint-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function preparePolicyHint(
cliName: string,
sandboxName: string,
deps: ExecPolicyHintDeps = {},
gatewayName?: string,
): (completion: ExecPolicyDenialHintCompletion) => Promise<void> {
const { now = Date.now, ...hintDeps } = deps;
const commandStartedAtMs = now();
Expand All @@ -32,6 +33,7 @@ export function preparePolicyHint(
Boolean(completion.invocationError),
commandStartedAtMs,
hintDeps,
gatewayName,
);
};
}
24 changes: 22 additions & 2 deletions src/lib/actions/sandbox/exec-policy-hint-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,22 @@ describe("policy-denial hint runtime adapter integration (#5978)", () => {
env: {},
writeStderr: (line) => stderr.push(line),
},
"nemoclaw-8091",
);

expect(captureOpenshell).toHaveBeenNthCalledWith(
1,
["settings", "set", "runtime-sandbox", "--key", "ocsf_json_enabled", "--value", "true"],
[
"settings",
"set",
"-g",
"nemoclaw-8091",
"runtime-sandbox",
"--key",
"ocsf_json_enabled",
"--value",
"true",
],
expect.objectContaining({
ignoreError: true,
includeStderr: true,
Expand All @@ -52,7 +63,16 @@ describe("policy-denial hint runtime adapter integration (#5978)", () => {
);
expect(captureOpenshell).toHaveBeenNthCalledWith(
2,
["logs", "runtime-sandbox", "-n", String(POLICY_HINT_TAIL_LINES), "--source", "all"],
[
"logs",
"-g",
"nemoclaw-8091",
"runtime-sandbox",
"-n",
String(POLICY_HINT_TAIL_LINES),
"--source",
"all",
],
expect.objectContaining({
ignoreError: true,
includeStderr: true,
Expand Down
Loading