Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
133 changes: 130 additions & 3 deletions src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const captureSandboxSshConfig = vi.hoisted(() => vi.fn());
const dockerSpawnSync = vi.hoisted(() => vi.fn());

vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawnSync: vi.fn(actual.spawnSync) };
});

vi.mock("../../adapters/openshell/runtime", () => ({
vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("../../adapters/openshell/runtime")>()),
captureOpenshell: vi.fn(),
captureOpenshellForStatus: vi.fn(),
captureSandboxSshConfig,
Expand All @@ -23,18 +25,143 @@ vi.mock("../../adapters/openshell/runtime", () => ({
runOpenshell: vi.fn(),
}));

vi.mock("../../adapters/docker/exec", async (importOriginal) => ({
...(await importOriginal<typeof import("../../adapters/docker/exec")>()),
dockerSpawnSync,
}));

vi.mock("../../runner", () => ({
ROOT: "/repo",
shellQuote: (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`,
}));

import { executeSandboxCommand } from "./process-recovery";
import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery";

describe("executeSandboxCommand temp SSH config", () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("pins SSH config and command execution to one authority-derived mTLS target (#10514)", () => {
vi.stubEnv("OPENSHELL_GATEWAY", "ambient-gateway");
vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://ambient.invalid");
vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true");
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/ambient/tls");
vi.stubEnv("OPENSHELL_TOKEN", "ambient-token");
vi.stubEnv("OPENSHELL_WORKSPACE", "ambient-workspace");
captureSandboxSshConfig.mockReturnValue({
status: 0,
output: "Host openshell-alpha.default\n HostName 127.0.0.1\n",
});
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: "ok\n",
stderr: "",
pid: 1234,
output: [],
signal: null,
});

expect(
executeSandboxCommand("alpha", "echo ok", {
runtimeSelection: {
gatewayName: "nemoclaw-8091",
localTlsDir: "/authority/tls",
workspace: "default",
},
}),
).toEqual({ status: 0, stdout: "ok", stderr: "" });

const captureOptions = captureSandboxSshConfig.mock.calls[0]?.[1];
expect(captureOptions).toMatchObject({
gatewayName: "nemoclaw-8091",
replaceEnv: true,
env: {
OPENSHELL_GATEWAY: "nemoclaw-8091",
OPENSHELL_LOCAL_TLS_DIR: "/authority/tls",
OPENSHELL_WORKSPACE: "default",
},
});
expect(captureOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT");
expect(captureOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE");
expect(captureOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN");
expect(vi.mocked(spawnSync).mock.calls[0]?.[2]?.env).toEqual(captureOptions?.env);
});

it("removes ambient mTLS when the selected gateway does not use it (#10514)", () => {
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/ambient/tls");
captureSandboxSshConfig.mockReturnValue({
status: 0,
output: "Host openshell-alpha.default\n HostName 127.0.0.1\n",
});
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: "ok\n",
stderr: "",
pid: 1234,
output: [],
signal: null,
});

executeSandboxCommand("alpha", "echo ok", {
runtimeSelection: { gatewayName: "external-http", workspace: "default" },
});

expect(captureSandboxSshConfig.mock.calls[0]?.[1]?.env).not.toHaveProperty(
"OPENSHELL_LOCAL_TLS_DIR",
);
expect(vi.mocked(spawnSync).mock.calls[0]?.[2]?.env).not.toHaveProperty(
"OPENSHELL_LOCAL_TLS_DIR",
);
});

it("pins strict OpenShell exec to the same authority-derived target (#10514)", () => {
vi.stubEnv("OPENSHELL_GATEWAY", "ambient-gateway");
vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://ambient.invalid");
vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true");
vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/ambient/tls");
vi.stubEnv("OPENSHELL_TOKEN", "ambient-token");
vi.stubEnv("OPENSHELL_WORKSPACE", "ambient-workspace");
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: "__NEMOCLAW_SANDBOX_EXEC_STARTED__\nrevision-1\n",
stderr: "",
pid: 1234,
output: [],
signal: null,
});

expect(
executeSandboxExecCommand("alpha", "printf revision-1", undefined, {
allowLocalDockerFallback: false,
runtimeSelection: {
gatewayName: "nemoclaw-8091",
localTlsDir: "/authority/tls",
workspace: "default",
},
}),
).toEqual({ status: 0, stdout: "revision-1", stderr: "" });

const [command, args, options] = vi.mocked(spawnSync).mock.calls[0] ?? [];
expect(command).toBe("openshell");
expect(args).toEqual(
expect.arrayContaining(["sandbox", "exec", "--name", "alpha", "-g", "nemoclaw-8091"]),
);
expect(options?.env).toMatchObject({
OPENSHELL_GATEWAY: "nemoclaw-8091",
OPENSHELL_LOCAL_TLS_DIR: "/authority/tls",
OPENSHELL_WORKSPACE: "default",
});
expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT");
expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE");
expect(options?.env).not.toHaveProperty("OPENSHELL_TOKEN");
expect(dockerSpawnSync).not.toHaveBeenCalled();
});

it("uses the exact legacy alias while backing up a pre-upgrade sandbox", () => {
captureSandboxSshConfig.mockReturnValue({
status: 0,
Expand Down
38 changes: 35 additions & 3 deletions src/lib/actions/sandbox/process-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import { randomBytes } from "node:crypto";
import { stripAnsi } from "../../adapters/openshell/client";
import {
buildOpenShellRuntimeSelectionEnv,
captureOpenshell,
captureOpenshellForStatus,
captureSandboxSshConfig,
getOpenshellBinary,
isCommandTimeout,
type OpenShellRuntimeSelection,
runOpenshell,
} from "../../adapters/openshell/runtime";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
Expand Down Expand Up @@ -119,6 +121,15 @@ export { buildSandboxExecMarkedCommand } from "./sandbox-exec-output";

export type { SandboxCommandResult, SandboxExecCommandOptions };

export type SandboxCommandExecutionOptions = {
runtimeSelection?: OpenShellRuntimeSelection;
timeout?: number;
};

export type SandboxExecCommandExecutionOptions = SandboxExecCommandOptions & {
runtimeSelection?: OpenShellRuntimeSelection;
};

type ProcessRecoveryProbeTiming = {
measure<T>(stage: "processes" | "forward", operation: () => T): T;
setForwardAction(action: "skipped" | "verified" | "restored" | "failed"): void;
Expand Down Expand Up @@ -176,13 +187,26 @@ function getSandboxHealthProbeUrl(sandboxName: string): string {
export function executeSandboxCommand(
sandboxName: string,
command: string,
timeout = DEFAULT_SANDBOX_EXEC_TIMEOUT_MS,
timeoutOrOptions: number | SandboxCommandExecutionOptions = DEFAULT_SANDBOX_EXEC_TIMEOUT_MS,
): SandboxCommandResult | null {
const timeout =
typeof timeoutOrOptions === "number"
? timeoutOrOptions
: (timeoutOrOptions.timeout ?? DEFAULT_SANDBOX_EXEC_TIMEOUT_MS);
const runtimeSelection =
typeof timeoutOrOptions === "number" ? undefined : timeoutOrOptions.runtimeSelection;
const runtimeEnv = runtimeSelection
? buildOpenShellRuntimeSelectionEnv(buildSubprocessEnv(), runtimeSelection)
: undefined;
return executeSandboxCommandTransport(
commandTransportDependencies(),
sandboxName,
command,
timeout,
{
...(runtimeSelection ? { gatewayName: runtimeSelection.gatewayName } : {}),
runtimeEnv,
},
);
}

Expand Down Expand Up @@ -214,14 +238,22 @@ export function executeSandboxExecCommand(
sandboxName: string,
command: string,
timeout = DEFAULT_SANDBOX_EXEC_TIMEOUT_MS,
options: SandboxExecCommandOptions = {},
options: SandboxExecCommandExecutionOptions = {},
): SandboxCommandResult | null {
const { runtimeSelection, ...transportOptions } = options;
const runtimeEnv = runtimeSelection
? buildOpenShellRuntimeSelectionEnv(buildSubprocessEnv(), runtimeSelection)
: options.runtimeEnv;
return executeSandboxExecCommandTransport(
commandTransportDependencies(),
sandboxName,
command,
timeout,
options,
{
...transportOptions,
Comment thread
prekshivyas marked this conversation as resolved.
Resolved
...(runtimeSelection ? { gatewayName: runtimeSelection.gatewayName } : {}),
...(runtimeEnv ? { runtimeEnv } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ const provider = createInMemoryRuntimeProviderBundle({
hostLocalInference: { services: ["vllm"], createOperation: () => operation },
});

vi.mock("../../adapters/openshell/runtime", () => ({
vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("../../adapters/openshell/runtime")>()),
captureOpenshell: vi.fn((args: string[]) => ({
status: 0,
output: args[0] === "policy" ? "version: 1\nnetwork_policies: {}\n" : "alpha Ready\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const mocks = vi.hoisted(() => ({
removeSandboxStateBackup: vi.fn(() => true),
}));

vi.mock("../../adapters/openshell/runtime", () => ({
vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("../../adapters/openshell/runtime")>()),
captureOpenshell: mocks.captureOpenshell,
getOpenshellBinary: vi.fn(() => "openshell"),
runOpenshell: vi.fn(),
Expand Down
2 changes: 2 additions & 0 deletions src/lib/adapters/openshell/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { processTreeBoundedOpenshellInvocation } from "./process-tree-timeout";
import { classifyManagedGatewayEndpointBinding } from "../../../../nemoclaw/dist/shared/openshell-gateway-endpoint-boundary.cjs";

export { classifyManagedGatewayEndpointBinding };
export { buildSelectedOpenShellSubprocessEnv } from "./command-argv";
export type { OpenShellRuntimeSelection } from "./runtime-selection";

export { isOpenShellSandboxPolicyCredentialFree } from "./policy-boundary";

Expand Down
41 changes: 41 additions & 0 deletions src/lib/adapters/openshell/command-argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,47 @@

// Namespace access keeps the resolver replaceable in focused command tests.
import * as openshellResolveModule from "./resolve";
import {
buildOpenShellRuntimeSelectionEnv,
type OpenShellRuntimeSelection,
} from "./runtime-selection";

export type { OpenShellRuntimeSelection } from "./runtime-selection";

/** Build the standard subprocess environment for one selected OpenShell target. */
export function buildSelectedOpenShellSubprocessEnv(
runtimeSelection: OpenShellRuntimeSelection,
extra?: Record<string, string>,
): Record<string, string> {
return buildOpenShellRuntimeSelectionEnv(
openshellResolveModule.buildOpenShellCommandBaseEnv(extra),
runtimeSelection,
);
}

/** Apply one selected OpenShell target to command options. */
export function withSelectedOpenShellCommandOptions<const T extends object>(
options: T,
runtimeSelection?: OpenShellRuntimeSelection,
): T & { env?: Record<string, string>; replaceEnv?: true } {
return runtimeSelection
? {
...options,
env: buildSelectedOpenShellSubprocessEnv(runtimeSelection),
replaceEnv: true,
}
: options;
}

/** Build a subprocess environment with optional explicit OpenShell target selection. */
export function buildOpenShellCommandEnv(
runtimeSelection?: OpenShellRuntimeSelection,
extra?: Record<string, string>,
): Record<string, string> {
return runtimeSelection
? buildSelectedOpenShellSubprocessEnv(runtimeSelection, extra)
: openshellResolveModule.buildOpenShellCommandBaseEnv(extra);
}

export function tryResolveOpenshellBinary(): string | null {
return openshellResolveModule.resolveOpenshell();
Expand Down
2 changes: 2 additions & 0 deletions src/lib/adapters/openshell/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { accessSync, constants } from "node:fs";

import { buildSubprocessEnv, isSubprocessEnvNameAllowed } from "../../subprocess-env";

export { buildSubprocessEnv as buildOpenShellCommandBaseEnv };

export interface ResolveOpenshellOptions {
/** Mock result for `command -v` (undefined = run real command). */
commandVResult?: string | null;
Expand Down
52 changes: 52 additions & 0 deletions src/lib/adapters/openshell/runtime-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

export type OpenShellRuntimeSelection = {
gatewayName: string;
localTlsDir?: string;
workspace: string;
};

/** Remove ambient OpenShell selectors and install one authority-derived target. */
export function replaceOpenShellRuntimeSelectionEnv(
env: Record<string, string | undefined>,
runtimeSelection: OpenShellRuntimeSelection,
): void {
for (const name of Object.keys(env)) {
if (name.startsWith("OPENSHELL_")) delete env[name];
}
env.OPENSHELL_GATEWAY = runtimeSelection.gatewayName;
env.OPENSHELL_WORKSPACE = runtimeSelection.workspace;
if (runtimeSelection.localTlsDir) {
env.OPENSHELL_LOCAL_TLS_DIR = runtimeSelection.localTlsDir;
}
}

/** Capture all OpenShell environment values and return an idempotent restore function. */
export function snapshotOpenShellEnv(env: NodeJS.ProcessEnv = process.env): () => void {
const previous = Object.fromEntries(
Object.entries(env).filter(
(entry): entry is [string, string] =>
entry[0].startsWith("OPENSHELL_") && entry[1] !== undefined,
),
);
let restored = false;
return () => {
if (restored) return;
restored = true;
for (const name of Object.keys(env)) {
if (name.startsWith("OPENSHELL_")) delete env[name];
}
Object.assign(env, previous);
};
}

/** Replace ambient OpenShell selectors with one authority-derived runtime target. */
export function buildOpenShellRuntimeSelectionEnv(
baseEnv: Record<string, string>,
runtimeSelection: OpenShellRuntimeSelection,
): Record<string, string> {
const env = { ...baseEnv };
replaceOpenShellRuntimeSelectionEnv(env, runtimeSelection);
return env;
}
Loading