Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
151 changes: 151 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,151 @@
// 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.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 () => {
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();
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.
Resolved
});

it("selects the owning gateway before the workdir probe when a workdir is set", async () => {
const order: string[] = [];
const selectGateway = vi.fn((name: string) => {
order.push(`select:${name}`);
return { outcome: "selected" as const, gatewayName: name };
});
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,
policyHint: {
now: () => 0,
env: {},
probeLogs: () => "",
enableAudit: () => {},
sleep: async () => {},
attempts: 1,
writeStderr: () => {},
},
},
),
).rejects.toThrow("__exit_0__");

expect(order).toEqual(["select:beta", "probe", "run"]);
});

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();
});
});
16 changes: 16 additions & 0 deletions src/lib/actions/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import type { SandboxEntry } from "../../state/registry";
import { type ExecPolicyHintDeps, preparePolicyHint } from "./exec-policy-hint-integration";
import { buildSandboxExecStdio } from "./exec-stdio";
import type { GatewaySelectResult } from "./gateway-select";
import { wrapExecCommandWithRuntimeEnv } from "./runtime-env";

export { buildSandboxExecStdio, shouldInheritSandboxExecStdin } from "./exec-stdio";
Expand Down Expand Up @@ -329,6 +330,12 @@ function defaultResolveBinary(): string {
return getOpenshellBinary();
}

function defaultSelectGateway(sandboxName: string): GatewaySelectResult {
return (
require("./gateway-select") as typeof import("./gateway-select")
).selectSandboxOwningGateway(sandboxName);
}

// Test seams for execSandbox. All default to the production behavior; tests
// inject them so the dispatch path stays hermetic without spawning a real
// process or hitting the process-exiting OpenShell binary lookup.
Expand All @@ -340,6 +347,8 @@ export type ExecSandboxDeps = {
run?: SandboxExecRunner;
policyHint?: ExecPolicyHintDeps;
cleanupDeps?: SandboxExecCleanupDeps;
/** Select the sandbox's owning gateway before the exec talks to OpenShell. */
selectGateway?: (sandboxName: string) => GatewaySelectResult;
};

export async function execSandbox(
Expand All @@ -361,6 +370,13 @@ export async function execSandbox(
process.exit(2);
}
const binary = (deps.resolveBinary ?? defaultResolveBinary)();
const gatewaySelection = (deps.selectGateway ?? defaultSelectGateway)(sandboxName);
if (gatewaySelection.outcome === "failed") {
console.error(
` Failed to select gateway '${gatewaySelection.gatewayName}' for sandbox '${sandboxName}'.`,
);
process.exit(1);
}
if (options.workdir) {
validateWorkdirOrFail(binary, sandboxName, options.workdir, deps.probeWorkdir);
}
Expand Down
68 changes: 68 additions & 0 deletions src/lib/actions/sandbox/gateway-select.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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 * as registry from "../../state/registry";
import { selectSandboxOwningGateway } from "./gateway-select";

describe("selectSandboxOwningGateway", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("selects the owning non-default gateway for a registered sandbox", () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8091 } as never);
const run = vi.fn(() => ({ status: 0 }) as never);

const selected = selectSandboxOwningGateway("beta", run);

expect(selected).toEqual({ outcome: "selected", gatewayName: "nemoclaw-8091" });
expect(run).toHaveBeenCalledWith(
["gateway", "select", "nemoclaw-8091"],
expect.objectContaining({ ignoreError: true }),
);
});

it("keeps the bare default gateway name for a default-port sandbox", () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8080 } as never);
const run = vi.fn(() => ({ status: 0 }) as never);

expect(selectSandboxOwningGateway("alpha", run)).toEqual({
outcome: "selected",
gatewayName: "nemoclaw",
});
expect(run).toHaveBeenCalledWith(["gateway", "select", "nemoclaw"], expect.anything());
});

it("does not touch the active gateway for an unregistered sandbox", () => {
vi.spyOn(registry, "getSandbox").mockReturnValue(null);
const run = vi.fn(() => ({ status: 0 }) as never);

expect(selectSandboxOwningGateway("ghost", run)).toEqual({
outcome: "unregistered",
gatewayName: null,
});
expect(run).not.toHaveBeenCalled();
});

it("reports failure when the gateway select command exits nonzero", () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8091 } as never);
const run = vi.fn(() => ({ status: 1 }) as never);

expect(selectSandboxOwningGateway("beta", run)).toEqual({
outcome: "failed",
gatewayName: "nemoclaw-8091",
});
});

it("reports failure when the gateway select command errors on spawn", () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8091 } as never);
const run = vi.fn(() => ({ status: null, error: new Error("spawn failed") }) as never);

expect(selectSandboxOwningGateway("beta", run)).toEqual({
outcome: "failed",
gatewayName: "nemoclaw-8091",
});
});
});
29 changes: 29 additions & 0 deletions src/lib/actions/sandbox/gateway-select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { runOpenshell } from "../../adapters/openshell/runtime";
import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
import { getKnownSandboxTargetGatewayName } from "./gateway-target";

export type GatewaySelectRunner = typeof runOpenshell;

export type GatewaySelectResult =
| { outcome: "selected"; gatewayName: string }
| { outcome: "failed"; gatewayName: string }
| { outcome: "unregistered"; gatewayName: null };

export function selectSandboxOwningGateway(
sandboxName: string,
run: GatewaySelectRunner = runOpenshell,
): GatewaySelectResult {
const targetGatewayName = getKnownSandboxTargetGatewayName(sandboxName);
if (!targetGatewayName) return { outcome: "unregistered", gatewayName: null };
const result = run(["gateway", "select", targetGatewayName], {
ignoreError: true,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
if (result.error || result.status !== 0) {
return { outcome: "failed", gatewayName: targetGatewayName };
}
return { outcome: "selected", gatewayName: targetGatewayName };
}
103 changes: 103 additions & 0 deletions src/lib/actions/sandbox/gateway-state-owning-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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 * as gatewayRuntime from "../../gateway-runtime-action";
import * as registry from "../../state/registry";
import * as gatewaySelect from "./gateway-select";
import { getReconciledSandboxGatewayState } from "./gateway-state";

describe("getReconciledSandboxGatewayState owning-gateway guard", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("reselects the owning gateway and re-queries when a present comes from another active gateway", async () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8091 } as never);
vi.spyOn(gatewayRuntime, "getNamedGatewayLifecycleState").mockReturnValue({
state: "connected_other",
status: "",
activeGateway: "nemoclaw",
} as never);
const selectSpy = vi
.spyOn(gatewaySelect, "selectSandboxOwningGateway")
.mockReturnValue({ outcome: "selected", gatewayName: "nemoclaw-8091" });

const getState = vi
.fn()
.mockResolvedValueOnce({ state: "present", output: "Phase: Provisioning" })
.mockResolvedValueOnce({ state: "present", output: "Phase: Ready" });

const result = await getReconciledSandboxGatewayState("beta", { getState });

expect(getState).toHaveBeenCalledTimes(2);
expect(selectSpy).toHaveBeenCalledWith("beta");
expect(result).toMatchObject({
state: "present",
output: "Phase: Ready",
recoveredGateway: true,
recoveryVia: "select",
});
});

it("reports wrong_gateway_active and does not re-query when owning-gateway selection fails", async () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8091 } as never);
vi.spyOn(gatewayRuntime, "getNamedGatewayLifecycleState").mockReturnValue({
state: "connected_other",
status: "some status",
activeGateway: "nemoclaw",
} as never);
const selectSpy = vi
.spyOn(gatewaySelect, "selectSandboxOwningGateway")
.mockReturnValue({ outcome: "failed", gatewayName: "nemoclaw-8091" });

const getState = vi
.fn()
.mockResolvedValueOnce({ state: "present", output: "Phase: Provisioning" });

const result = await getReconciledSandboxGatewayState("beta", { getState });

expect(getState).toHaveBeenCalledTimes(1);
expect(selectSpy).toHaveBeenCalledWith("beta");
expect(result).toMatchObject({
state: "wrong_gateway_active",
activeGateway: "nemoclaw",
output: "some status",
});
expect(result.recoveredGateway).toBeUndefined();
});

it("trusts a present from the owning gateway without reselecting", async () => {
vi.spyOn(registry, "getSandbox").mockReturnValue({ gatewayPort: 8091 } as never);
vi.spyOn(gatewayRuntime, "getNamedGatewayLifecycleState").mockReturnValue({
state: "healthy_named",
status: "",
} as never);
const selectSpy = vi.spyOn(gatewaySelect, "selectSandboxOwningGateway");

const getState = vi.fn().mockResolvedValue({ state: "present", output: "Phase: Ready" });

const result = await getReconciledSandboxGatewayState("beta", { getState });

expect(getState).toHaveBeenCalledTimes(1);
expect(selectSpy).not.toHaveBeenCalled();
expect(result).toMatchObject({ state: "present", output: "Phase: Ready" });
expect(result.recoveredGateway).toBeUndefined();
});

it("leaves an unregistered sandbox's present result untouched", async () => {
vi.spyOn(registry, "getSandbox").mockReturnValue(null);
const lifecycleSpy = vi.spyOn(gatewayRuntime, "getNamedGatewayLifecycleState");
const selectSpy = vi.spyOn(gatewaySelect, "selectSandboxOwningGateway");

const getState = vi.fn().mockResolvedValue({ state: "present", output: "Phase: Ready" });

const result = await getReconciledSandboxGatewayState("ghost", { getState });

expect(getState).toHaveBeenCalledTimes(1);
expect(lifecycleSpy).not.toHaveBeenCalled();
expect(selectSpy).not.toHaveBeenCalled();
expect(result).toMatchObject({ state: "present" });
});
});
Loading
Loading