From 7b9ef8f5b04eb43809a70c0c1cfab157159b3bbd Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 1 Sep 2026 13:05:10 -0700 Subject: [PATCH 1/8] refactor(openshell): add selected runtime foundation Signed-off-by: Apurv Kumaria --- .../sandbox/process-recovery-temp-ssh.test.ts | 133 +++++++++++++++++- src/lib/actions/sandbox/process-recovery.ts | 38 ++++- ...pshot-command-host-local-authority.test.ts | 3 +- .../snapshot-failed-create-cleanup.test.ts | 3 +- src/lib/adapters/openshell/client.ts | 2 + src/lib/adapters/openshell/command-argv.ts | 41 ++++++ src/lib/adapters/openshell/resolve.ts | 2 + .../adapters/openshell/runtime-selection.ts | 52 +++++++ src/lib/adapters/openshell/runtime.ts | 14 ++ src/lib/adapters/sandbox/command-transport.ts | 23 ++- src/lib/gateway-runtime-action.test.ts | 56 ++++++++ src/lib/gateway-runtime-action.ts | 88 ++++++++---- .../docker-driver-gateway-env-service.test.ts | 67 +++++++++ src/lib/onboard/docker-driver-gateway-env.ts | 8 ++ .../docker-driver-gateway-launch.test.ts | 55 ++++++++ .../onboard/docker-driver-gateway-launch.ts | 3 +- .../docker-driver-gateway-local-tls.test.ts | 22 ++- ...river-gateway-service-version-gate.test.ts | 40 ++++++ src/lib/onboard/gateway-recovery.test.ts | 57 ++++++++ src/lib/onboard/gateway-recovery.ts | 46 +++++- src/lib/onboard/gateway-reuse.test.ts | 50 ++++++- src/lib/onboard/gateway-reuse.ts | 37 ++++- .../onboard/gateway/docker-driver-start.ts | 61 ++++++-- src/lib/onboard/gateway/late-binding.test.ts | 128 ++++++++++++++++- src/lib/onboard/gateway/recovery.ts | 8 +- src/lib/onboard/gateway/registration.ts | 70 +++++++-- src/lib/onboard/gateway/start.ts | 38 ++++- src/lib/runner.ts | 48 +++++-- src/lib/state/paths.ts | 5 + src/lib/state/sandbox.ts | 10 +- test/e2e-runtime/runner.test.ts | 37 ++++- 31 files changed, 1146 insertions(+), 99 deletions(-) create mode 100644 src/lib/adapters/openshell/runtime-selection.ts diff --git a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts index 6eeac47b531..a928f998502 100644 --- a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts +++ b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts @@ -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(); return { ...actual, spawnSync: vi.fn(actual.spawnSync) }; }); -vi.mock("../../adapters/openshell/runtime", () => ({ +vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({ + ...(await importOriginal()), captureOpenshell: vi.fn(), captureOpenshellForStatus: vi.fn(), captureSandboxSshConfig, @@ -23,18 +25,143 @@ vi.mock("../../adapters/openshell/runtime", () => ({ runOpenshell: vi.fn(), })); +vi.mock("../../adapters/docker/exec", async (importOriginal) => ({ + ...(await importOriginal()), + 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, diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 47549515674..6d6c883a257 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -5,11 +5,13 @@ import { randomBytes } from "node:crypto"; import { dockerSpawnSync } from "../../adapters/docker"; 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"; @@ -116,6 +118,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(stage: "processes" | "forward", operation: () => T): T; setForwardAction(action: "skipped" | "verified" | "restored" | "failed"): void; @@ -174,13 +185,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, + }, ); } @@ -216,14 +240,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, + ...(runtimeSelection ? { gatewayName: runtimeSelection.gatewayName } : {}), + ...(runtimeEnv ? { runtimeEnv } : {}), + }, ); } diff --git a/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts b/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts index a15c6205bc9..8461e52a039 100644 --- a/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts +++ b/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts @@ -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()), captureOpenshell: vi.fn((args: string[]) => ({ status: 0, output: args[0] === "policy" ? "version: 1\nnetwork_policies: {}\n" : "alpha Ready\n", diff --git a/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts b/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts index a375d35b414..494a78f2278 100644 --- a/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts +++ b/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts @@ -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()), captureOpenshell: mocks.captureOpenshell, getOpenshellBinary: vi.fn(() => "openshell"), runOpenshell: vi.fn(), diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 80e83e9003c..a8cd954b0cc 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -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 { openshellSandboxSshHost, resolveOpenshellSandboxSshHost } from "./sandbox-ssh-host"; diff --git a/src/lib/adapters/openshell/command-argv.ts b/src/lib/adapters/openshell/command-argv.ts index 60251b5321e..534cddaceef 100644 --- a/src/lib/adapters/openshell/command-argv.ts +++ b/src/lib/adapters/openshell/command-argv.ts @@ -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, +): Record { + return buildOpenShellRuntimeSelectionEnv( + openshellResolveModule.buildOpenShellCommandBaseEnv(extra), + runtimeSelection, + ); +} + +/** Apply one selected OpenShell target to command options. */ +export function withSelectedOpenShellCommandOptions( + options: T, + runtimeSelection?: OpenShellRuntimeSelection, +): T & { env?: Record; 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, +): Record { + return runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(runtimeSelection, extra) + : openshellResolveModule.buildOpenShellCommandBaseEnv(extra); +} export function resolveOpenshellBinary(): string { return openshellResolveModule.resolveOpenshell() ?? "openshell"; diff --git a/src/lib/adapters/openshell/resolve.ts b/src/lib/adapters/openshell/resolve.ts index 1a733c4fc8b..1cf3326ecc2 100644 --- a/src/lib/adapters/openshell/resolve.ts +++ b/src/lib/adapters/openshell/resolve.ts @@ -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; diff --git a/src/lib/adapters/openshell/runtime-selection.ts b/src/lib/adapters/openshell/runtime-selection.ts new file mode 100644 index 00000000000..85a9a49c4a8 --- /dev/null +++ b/src/lib/adapters/openshell/runtime-selection.ts @@ -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, + 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, + runtimeSelection: OpenShellRuntimeSelection, +): Record { + const env = { ...baseEnv }; + replaceOpenShellRuntimeSelectionEnv(env, runtimeSelection); + return env; +} diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index c24e656996b..76831cee0d3 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -17,6 +17,18 @@ import { OPENSHELL_OPERATION_TIMEOUT_MS, OPENSHELL_PROBE_TIMEOUT_MS } from "./ti type CommandArgs = string[]; +export { + buildOpenShellRuntimeSelectionEnv, + replaceOpenShellRuntimeSelectionEnv, + snapshotOpenShellEnv, + type OpenShellRuntimeSelection, +} from "./runtime-selection"; +export { + buildOpenShellCommandEnv, + buildSelectedOpenShellSubprocessEnv, + withSelectedOpenShellCommandOptions, +} from "./command-argv"; + export { buildOpenShellSubprocessEnv, OPENSHELL_OPERATION_TIMEOUT_MS }; export { classifyManagedGatewayEndpointBinding } from "./client"; export { runCaptureEx } from "../../runner"; @@ -25,6 +37,7 @@ type RunnerOptions = { /** Exact canonical executable selected by the caller. */ openshellBinary?: string; env?: NodeJS.ProcessEnv; + gatewayName?: string; replaceEnv?: boolean; stdio?: StdioOptions; input?: string; @@ -117,6 +130,7 @@ export function captureSandboxSshConfig(sandboxName: string, opts: RunnerOptions return captureSandboxSshConfigCommand(getOpenshellBinary(), sandboxName, { cwd: ROOT, env: opts.env, + gatewayName: opts.gatewayName, replaceEnv: opts.replaceEnv, ignoreError: opts.ignoreError, includeStreams: opts.includeStreams, diff --git a/src/lib/adapters/sandbox/command-transport.ts b/src/lib/adapters/sandbox/command-transport.ts index 12f6a8997dd..23da2387ff1 100644 --- a/src/lib/adapters/sandbox/command-transport.ts +++ b/src/lib/adapters/sandbox/command-transport.ts @@ -14,6 +14,12 @@ export type SandboxCommandResult = { export type SandboxExecCommandOptions = { allowLocalDockerFallback?: boolean; gatewayName?: string; + runtimeEnv?: NodeJS.ProcessEnv; +}; + +export type SandboxSshCommandOptions = { + gatewayName?: string; + runtimeEnv?: NodeJS.ProcessEnv; }; export type CommandTransportDependencies = { @@ -21,7 +27,13 @@ export type CommandTransportDependencies = { buildSubprocessEnv: () => NodeJS.ProcessEnv; captureSandboxSshConfig: ( sandboxName: string, - options: { ignoreError: boolean; timeout: number }, + options: { + env?: NodeJS.ProcessEnv; + gatewayName?: string; + ignoreError: boolean; + replaceEnv?: boolean; + timeout: number; + }, ) => { output: string; status: number | null }; dockerSpawnSync: ( args: readonly string[], @@ -52,12 +64,17 @@ export function executeSandboxCommandTransport( sandboxName: string, command: string, timeout = DEFAULT_SANDBOX_EXEC_TIMEOUT_MS, + options: SandboxSshCommandOptions = {}, ): SandboxCommandResult | null { return deps.withPrivilegedSandboxExecutionLease( sandboxName, "sandbox SSH command transport", () => { const sshConfigResult = deps.captureSandboxSshConfig(sandboxName, { + ...(options.runtimeEnv + ? { env: options.runtimeEnv, replaceEnv: true } + : {}), + ...(options.gatewayName ? { gatewayName: options.gatewayName } : {}), ignoreError: true, timeout: deps.openshellProbeTimeoutMs, }); @@ -86,7 +103,7 @@ export function executeSandboxCommandTransport( ], { encoding: "utf-8", - env: deps.buildSubprocessEnv(), + env: options.runtimeEnv ?? deps.buildSubprocessEnv(), stdio: ["ignore", "pipe", "pipe"], timeout, }, @@ -184,7 +201,7 @@ export function executeSandboxExecCommandTransport( { cwd: deps.root, encoding: "utf-8", - env: deps.buildSubprocessEnv(), + env: options.runtimeEnv ?? deps.buildSubprocessEnv(), stdio: ["ignore", "pipe", "pipe"], timeout: effectiveTimeout, }, diff --git a/src/lib/gateway-runtime-action.test.ts b/src/lib/gateway-runtime-action.test.ts index 262cfffb9c5..8f94306dc9c 100644 --- a/src/lib/gateway-runtime-action.test.ts +++ b/src/lib/gateway-runtime-action.test.ts @@ -20,6 +20,7 @@ describe("gateway-runtime-action per-sandbox gateway routing", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); delete process.env.OPENSHELL_GATEWAY; }); @@ -272,6 +273,61 @@ describe("gateway-runtime-action per-sandbox gateway routing", () => { expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw-8090"); }); + it("keeps recovery probes and startup on the frozen OpenShell target (#10514)", async () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "1"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + const runtimeSelection = { + gatewayName: "nemoclaw-8090", + workspace: "default", + localTlsDir: "/recorded/tls", + }; + captureSpy + .mockReturnValueOnce({ status: 0, output: "Status: Disconnected\nGateway: nemoclaw\n" }) + .mockReturnValueOnce({ status: 0, output: "" }) + .mockReturnValueOnce({ status: 0, output: "Status: Disconnected\nGateway: nemoclaw\n" }) + .mockReturnValueOnce({ status: 0, output: "" }) + .mockReturnValueOnce({ + status: 0, + output: "Status: Connected\nGateway: nemoclaw-8090\n", + }) + .mockReturnValueOnce({ status: 0, output: "Gateway: nemoclaw-8090\n" }); + runSpy.mockReturnValue({ status: 0 } as never); + + await expect( + gatewayRuntime.recoverNamedGatewayRuntime({ + gatewayName: "nemoclaw-8090", + runtimeSelection, + }), + ).resolves.toMatchObject({ recovered: true, via: "start" }); + + const subprocessOptions = [ + ...captureSpy.mock.calls.map(([, options]) => options), + ...runSpy.mock.calls.map(([, options]) => options), + ]; + expect(subprocessOptions.length).toBeGreaterThan(0); + expect( + subprocessOptions.every( + (options) => + options.replaceEnv === true && + options.env.OPENSHELL_GATEWAY === "nemoclaw-8090" && + options.env.OPENSHELL_WORKSPACE === "default" && + options.env.OPENSHELL_LOCAL_TLS_DIR === "/recorded/tls" && + options.env.OPENSHELL_GATEWAY_ENDPOINT === undefined && + options.env.OPENSHELL_GATEWAY_INSECURE === undefined && + options.env.OPENSHELL_TOKEN === undefined, + ), + ).toBe(true); + expect(startGatewaySpy).toHaveBeenCalledWith({ + gatewayName: "nemoclaw-8090", + gatewayPort: 8090, + runtimeSelection, + }); + }); + it.each([ { state: "connected_other", diff --git a/src/lib/gateway-runtime-action.ts b/src/lib/gateway-runtime-action.ts index e5af1807d11..ef8847189eb 100644 --- a/src/lib/gateway-runtime-action.ts +++ b/src/lib/gateway-runtime-action.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { stripAnsi } from "./adapters/openshell/client"; +import { withSelectedOpenShellCommandOptions } from "./adapters/openshell/command-argv"; import * as openshellRuntime from "./adapters/openshell/runtime"; import { classifyCliOpenShellCommandError, @@ -20,9 +21,14 @@ import { export { resolveGatewayName, resolveSandboxGatewayName }; +export const replaceOpenShellRuntimeSelectionEnv = + openshellRuntime.replaceOpenShellRuntimeSelectionEnv; +export const snapshotOpenShellEnv = openshellRuntime.snapshotOpenShellEnv; + type StartGatewayForRecoveryOptions = { gatewayName?: string; gatewayPort?: number; + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; }; type LegacyOnboardModule = { @@ -90,7 +96,10 @@ export type NamedGatewayLifecycleState = { */ export function getNamedGatewayLifecycleState( gatewayName: string = resolveGatewayName(GATEWAY_PORT), - opts: { ignoreProbeErrors?: boolean } = {}, + opts: { + ignoreProbeErrors?: boolean; + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; + } = {}, ): NamedGatewayLifecycleState { // #5714: callers that must stay non-fatal (e.g. plain `nemoclaw list` // recovery) opt into `ignoreProbeErrors` so a hung/timed-out `openshell @@ -101,18 +110,27 @@ export function getNamedGatewayLifecycleState( // When ignoring probe errors we must still capture stderr — OpenShell writes // the `Status:`/`Gateway:` lines there, and `ignoreError` would otherwise // drop stderr and break the healthy/connected classification. - const status = gatewayRuntimeDependencies.captureOpenshell(["status"], { - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - ignoreError, - includeStderr: ignoreError, - }); + const status = gatewayRuntimeDependencies.captureOpenshell( + ["status"], + withSelectedOpenShellCommandOptions( + { + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + ignoreError, + includeStderr: ignoreError, + }, + opts.runtimeSelection, + ), + ); const gatewayInfo = gatewayRuntimeDependencies.captureOpenshell( ["gateway", "info", "-g", gatewayName], - { - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - ignoreError, - includeStderr: ignoreError, - }, + withSelectedOpenShellCommandOptions( + { + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + ignoreError, + includeStderr: ignoreError, + }, + opts.runtimeSelection, + ), ); const cleanStatus = stripAnsi(status.output); const activeGateway = getActiveGatewayName(status.output); @@ -170,11 +188,20 @@ type NamedGatewayLifecycleStateName = NamedGatewayLifecycleState["state"]; export type RecoverNamedGatewayRuntimeOptions = { recoverableStates?: readonly NamedGatewayLifecycleStateName[]; gatewayName?: string; + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; }; /** Attempt to recover the named NemoClaw gateway after a restart or connectivity loss. */ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRuntimeOptions = {}) { const gatewayName = options.gatewayName ?? resolveGatewayName(GATEWAY_PORT); + if (options.runtimeSelection && options.runtimeSelection.gatewayName !== gatewayName) { + throw new Error( + `Gateway recovery target '${gatewayName}' does not match runtime selection '${options.runtimeSelection.gatewayName}'`, + ); + } + const lifecycleOptions = options.runtimeSelection + ? { runtimeSelection: options.runtimeSelection } + : {}; const recoverableStates = new Set( options.recoverableStates ?? [ "missing_named", @@ -183,7 +210,7 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun "connected_other", ], ); - const before = getNamedGatewayLifecycleState(gatewayName); + const before = getNamedGatewayLifecycleState(gatewayName, lifecycleOptions); if (before.recoveryBlocked) { return { recovered: false, before, after: before, attempted: false }; } @@ -194,12 +221,18 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun return { recovered: false, before, after: before, attempted: false }; } - gatewayRuntimeDependencies.runOpenshell(["gateway", "select", gatewayName], { - ignoreError: true, - stdio: "ignore", - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }); - let after = getNamedGatewayLifecycleState(gatewayName); + gatewayRuntimeDependencies.runOpenshell( + ["gateway", "select", gatewayName], + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + stdio: "ignore", + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }, + options.runtimeSelection, + ), + ); + let after = getNamedGatewayLifecycleState(gatewayName, lifecycleOptions); if (after.recoveryBlocked) { return { recovered: false, before, after, attempted: true }; } @@ -217,17 +250,24 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun await gatewayRuntimeDependencies.startGatewayForRecovery({ gatewayName, gatewayPort: resolveGatewayPortFromName(gatewayName) ?? undefined, + ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), }); } catch { // Fall through to the lifecycle re-check below so we preserve the // existing recovery result shape and emit the correct classification. } - gatewayRuntimeDependencies.runOpenshell(["gateway", "select", gatewayName], { - ignoreError: true, - stdio: "ignore", - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }); - after = getNamedGatewayLifecycleState(gatewayName); + gatewayRuntimeDependencies.runOpenshell( + ["gateway", "select", gatewayName], + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + stdio: "ignore", + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }, + options.runtimeSelection, + ), + ); + after = getNamedGatewayLifecycleState(gatewayName, lifecycleOptions); if (after.state === "healthy_named") { process.env.OPENSHELL_GATEWAY = gatewayName; return { recovered: true, before, after, attempted: true, via: "start" }; diff --git a/src/lib/onboard/docker-driver-gateway-env-service.test.ts b/src/lib/onboard/docker-driver-gateway-env-service.test.ts index 4cc1fdb69dc..52a09485303 100644 --- a/src/lib/onboard/docker-driver-gateway-env-service.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env-service.test.ts @@ -8,12 +8,79 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { writeSafeGatewayAuthConfig } from "../../../test/support/docker-driver-gateway-env-test-support"; import { startPackageManagedDockerDriverGatewayWithEnvOverride } from "./docker-driver-gateway-env"; +import type { OpenShellGatewayUserServiceOptions } from "./docker-driver-gateway-service"; function homeEnv(home: string, xdgConfigHome = ""): NodeJS.ProcessEnv { return { HOME: home, XDG_CONFIG_HOME: xdgConfigHome } as NodeJS.ProcessEnv; } describe("package-managed Docker-driver gateway env service", () => { + it("passes the selected OpenShell env to package service startup (#10514)", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-")); + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_DISABLE_TLS", "1"); + vi.stubEnv("OPENSHELL_DISABLE_GATEWAY_AUTH", "1"); + const selectedEnv: NodeJS.ProcessEnv = { + HOME: tempHome, + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "nemoclaw", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + OPENSHELL_WORKSPACE: "default", + }; + let observedEnv: NodeJS.ProcessEnv | undefined; + const startService = vi.fn((options) => { + const serviceOptions = options as OpenShellGatewayUserServiceOptions; + observedEnv = serviceOptions.env; + serviceOptions.prepareServiceEnv?.(); + return { attempted: true, started: true }; + }); + + try { + await expect( + startPackageManagedDockerDriverGatewayWithEnvOverride({ + clearDockerDriverGatewayRuntimeFiles: vi.fn(), + env: selectedEnv, + exitOnFailure: false, + gatewayEnv: { + OPENSHELL_BIND_ADDRESS: "127.0.0.1", + OPENSHELL_GATEWAY_CONFIG: writeSafeGatewayAuthConfig(tempHome), + OPENSHELL_SERVER_PORT: "8080", + }, + gatewayName: "nemoclaw", + hasOpenShellGatewayUserService: () => true, + isDockerDriverGatewayReady: async () => true, + registerDockerDriverGatewayEndpoint: () => true, + runCaptureOpenshell: (args) => + args[0] === "status" + ? "Gateway: nemoclaw\nConnected" + : "Gateway: nemoclaw\nGateway endpoint: https://127.0.0.1:8080/", + skipSandboxBridgeReachability: false, + startOpenShellGatewayUserService: startService, + verifySandboxBridgeGatewayReachableOrExit: async () => undefined, + }), + ).resolves.toBe(true); + + expect(observedEnv).toBe(selectedEnv); + expect(observedEnv).toEqual( + expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + OPENSHELL_WORKSPACE: "default", + }), + ); + expect(observedEnv?.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(observedEnv?.OPENSHELL_TOKEN).toBeUndefined(); + expect(observedEnv?.OPENSHELL_DISABLE_TLS).toBeUndefined(); + expect(observedEnv?.OPENSHELL_DISABLE_GATEWAY_AUTH).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + it("stages the service and writes its env under one XDG config root (#6903)", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-")); const configHome = path.join(tempHome, "xdg-config"); diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index aef1882076a..5fb6d01f3e7 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -27,6 +27,7 @@ import { OpenShellGatewayServiceEnvironmentError, type PackageManagedDockerDriverGatewayOptions, startPackageManagedDockerDriverGateway, + startOpenShellGatewayUserService, stopOpenShellGatewayUserService, } from "./docker-driver-gateway-service"; import { @@ -406,6 +407,7 @@ export function startPackageManagedDockerDriverGatewayWithEnvOverride( if (gatewayPort !== DEFAULT_GATEWAY_PORT) return Promise.resolve(false); assertDockerDriverGatewayAuthConfigSafe(gatewayEnv); const effectiveHome = home ?? optionsWithEnv.env?.HOME ?? os.homedir(); + const startService = options.startOpenShellGatewayUserService ?? startOpenShellGatewayUserService; return startPackageManagedDockerDriverGateway({ ...options, hasOpenShellGatewayUserService: @@ -427,6 +429,12 @@ export function startPackageManagedDockerDriverGatewayWithEnvOverride( throw new OpenShellGatewayServiceEnvironmentError(error); } }, + startOpenShellGatewayUserService: (serviceOptions) => + startService({ + ...serviceOptions, + env, + home: effectiveHome, + }), stopOpenShellGatewayUserService: options.stopOpenShellGatewayUserService ?? (() => stopOpenShellGatewayUserService({ env, home: effectiveHome })), diff --git a/src/lib/onboard/docker-driver-gateway-launch.test.ts b/src/lib/onboard/docker-driver-gateway-launch.test.ts index e86abae5524..59b77a4b00c 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.test.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.test.ts @@ -20,6 +20,7 @@ import { resolveDriftGatewayBin, shouldUseContainerizedGateway, } from "./docker-driver-gateway-launch"; +import * as dockerDriverGatewayLocalTls from "./docker-driver-gateway-local-tls"; import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile"; import { gatewayProcessCmdlineMatches } from "./gateway-process-identity"; @@ -191,6 +192,60 @@ describe("docker-driver-gateway-launch", () => { }).toThrow(/not supported for the OpenShell Docker-driver gateway/); }); + it("uses the selected OpenShell env for certificate generation and the gateway process (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_DISABLE_TLS", "1"); + vi.stubEnv("OPENSHELL_DISABLE_GATEWAY_AUTH", "1"); + const selectedEnv: NodeJS.ProcessEnv = { + HOME: "/home/tester", + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "nemoclaw-8090", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + OPENSHELL_WORKSPACE: "default", + }; + const ensureTls = vi + .spyOn(dockerDriverGatewayLocalTls, "ensureDockerDriverGatewayLocalTlsBundle") + .mockImplementation(({ env, stateDir }) => { + expect(env).toBe(selectedEnv); + return dockerDriverGatewayLocalTls.getDockerDriverGatewayLocalTlsBundle(stateDir); + }); + + try { + withTempBinaries(({ dir, gatewayBin }) => { + const stateDir = path.join(dir, "state"); + const launch = buildDockerDriverGatewayLaunch({ + ensureLocalTlsBundle: true, + env: selectedEnv, + gatewayBin, + gatewayEnv: { OPENSHELL_DRIVERS: "docker" }, + hostGlibcVersion: "2.39", + platform: "linux", + requiredGlibcVersions: ["2.39"], + stateDir, + }); + + expect(ensureTls).toHaveBeenCalledOnce(); + expect(launch.env).toEqual( + expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-8090", + OPENSHELL_LOCAL_TLS_DIR: path.join(stateDir, "tls"), + OPENSHELL_WORKSPACE: "default", + }), + ); + expect(launch.env.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(launch.env.OPENSHELL_TOKEN).toBeUndefined(); + expect(launch.env.OPENSHELL_DISABLE_TLS).toBeUndefined(); + expect(launch.env.OPENSHELL_DISABLE_GATEWAY_AUTH).toBeUndefined(); + }); + } finally { + ensureTls.mockRestore(); + vi.unstubAllEnvs(); + } + }); + it("uses the host binary as the drift binary outside compatibility mode", () => { withTempBinaries(({ dir, gatewayBin, sandboxBin }) => { const identity = buildDockerDriverGatewayRuntimeIdentity({ diff --git a/src/lib/onboard/docker-driver-gateway-launch.ts b/src/lib/onboard/docker-driver-gateway-launch.ts index 7523b01d0a6..f4a041673d6 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.ts @@ -136,9 +136,11 @@ function buildGatewayProcessEnv( export function buildDockerDriverGatewayLaunch( options: BuildGatewayLaunchOptions, ): DockerDriverGatewayLaunch { + const baseEnv = options.env ?? process.env; const gatewayEnv = { ...options.gatewayEnv }; if (options.ensureLocalTlsBundle) { ensureDockerDriverGatewayLocalTlsBundle({ + env: baseEnv, gatewayBin: options.gatewayBin, stateDir: options.stateDir, }); @@ -160,7 +162,6 @@ export function buildDockerDriverGatewayLaunch( }, ); assertDockerDriverGatewayAuthConfigSafe(gatewayEnv); - const baseEnv = options.env ?? process.env; const compat = shouldUseContainerizedGateway(options); if (!compat.useContainer) { const env = buildGatewayProcessEnv(baseEnv, gatewayEnv); diff --git a/src/lib/onboard/docker-driver-gateway-local-tls.test.ts b/src/lib/onboard/docker-driver-gateway-local-tls.test.ts index af5ccde28ac..9ce1acbea60 100644 --- a/src/lib/onboard/docker-driver-gateway-local-tls.test.ts +++ b/src/lib/onboard/docker-driver-gateway-local-tls.test.ts @@ -181,13 +181,24 @@ describe("docker-driver-gateway-local-tls", () => { vi.useRealTimers(); }); - it("runs OpenShell certgen into the NemoClaw-owned gateway TLS directory", () => { + it("runs certificate generation with the selected OpenShell env (#10514)", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-tls-")); const calls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_DISABLE_TLS", "1"); + vi.stubEnv("OPENSHELL_DISABLE_GATEWAY_AUTH", "1"); useTestCertificateClock(); try { const bundle = ensureDockerDriverGatewayLocalTlsBundle({ - env: { PATH: "/usr/bin" }, + env: { + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "nemoclaw-8090", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + OPENSHELL_WORKSPACE: "default", + }, gatewayBin: "/opt/openshell/openshell-gateway", stateDir, spawnSyncImpl: (( @@ -220,7 +231,14 @@ describe("docker-driver-gateway-local-tls", () => { ], }); expect(calls[0]?.env?.OPENSHELL_LOCAL_TLS_DIR).toBe(path.join(stateDir, "tls")); + expect(calls[0]?.env?.OPENSHELL_GATEWAY).toBe("nemoclaw-8090"); + expect(calls[0]?.env?.OPENSHELL_WORKSPACE).toBe("default"); + expect(calls[0]?.env?.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(calls[0]?.env?.OPENSHELL_TOKEN).toBeUndefined(); + expect(calls[0]?.env?.OPENSHELL_DISABLE_TLS).toBeUndefined(); + expect(calls[0]?.env?.OPENSHELL_DISABLE_GATEWAY_AUTH).toBeUndefined(); } finally { + vi.unstubAllEnvs(); fs.rmSync(stateDir, { recursive: true, force: true }); } }); diff --git a/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts b/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts index 8dd7de43023..da81bacfdc8 100644 --- a/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts +++ b/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts @@ -67,6 +67,46 @@ describe("package-managed gateway version gate (#8094)", () => { ); }); + it("probes the package gateway with the selected OpenShell env (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_DISABLE_TLS", "1"); + vi.stubEnv("OPENSHELL_DISABLE_GATEWAY_AUTH", "1"); + const selectedEnv: NodeJS.ProcessEnv = { + HOME: "/home/tester", + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "nemoclaw", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + OPENSHELL_WORKSPACE: "default", + }; + let observedEnv: NodeJS.ProcessEnv | undefined; + + try { + const verdict = checkUpstreamGatewayVersion(PACKAGE_BINARY, { + env: selectedEnv, + getUpstreamGatewayVersionBounds: () => BOUNDS, + platform: "linux", + spawnSyncImpl: (_command, _args, options) => { + observedEnv = options?.env; + return { status: 0, stdout: "openshell-gateway 0.0.85" }; + }, + }); + + expect(verdict.supported).toBe(true); + expect(observedEnv).toBe(selectedEnv); + expect(observedEnv?.OPENSHELL_GATEWAY).toBe("nemoclaw"); + expect(observedEnv?.OPENSHELL_WORKSPACE).toBe("default"); + expect(observedEnv?.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(observedEnv?.OPENSHELL_TOKEN).toBeUndefined(); + expect(observedEnv?.OPENSHELL_DISABLE_TLS).toBeUndefined(); + expect(observedEnv?.OPENSHELL_DISABLE_GATEWAY_AUTH).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); + it("declines the package gateway when its version cannot be determined (#8926)", () => { const verdict = checkUpstreamGatewayVersion( PACKAGE_BINARY, diff --git a/src/lib/onboard/gateway-recovery.test.ts b/src/lib/onboard/gateway-recovery.test.ts index 1bdf58ea7e5..68b68770018 100644 --- a/src/lib/onboard/gateway-recovery.test.ts +++ b/src/lib/onboard/gateway-recovery.test.ts @@ -68,6 +68,22 @@ describe("gateway recovery", () => { expect(deps.runOpenshell).not.toHaveBeenCalled(); }); + it("passes the frozen target into the managed gateway starter (#10514)", async () => { + const deps = createDeps(); + const runtimeSelection = { + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/recorded/tls", + }; + + await startGatewayForRecovery({ runtimeSelection }, deps); + + expect(deps.startGatewayWithOptions).toHaveBeenCalledWith(undefined, { + exitOnFailure: false, + runtimeSelection, + }); + }); + it("starts and selects the named gateway using the port encoded in its name", async () => { vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "1"); const deps = createDeps(); @@ -174,6 +190,47 @@ describe("gateway recovery", () => { expect(deps.runCaptureOpenshell).toHaveBeenCalledTimes(3); }); + it("replaces ambient OpenShell selectors during targeted recovery (#10514)", async () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "1"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + const runtimeSelection = { + gatewayName: "nemoclaw-8091", + workspace: "default", + localTlsDir: "/recorded/tls", + }; + const deps = createDeps({ + runCaptureOpenshell: vi.fn(() => "Connected"), + isGatewayHealthy: () => true, + isGatewayHttpReady: async () => true, + }); + + await startGatewayForRecovery({ gatewayPort: 8091, runtimeSelection }, deps); + + const subprocessOptions = [ + ...(deps.runOpenshell as ReturnType).mock.calls.map(([, options]) => options), + ...(deps.runCaptureOpenshell as ReturnType).mock.calls.map( + ([, options]) => options, + ), + ]; + expect(subprocessOptions.length).toBeGreaterThan(0); + expect( + subprocessOptions.every( + (options) => + options.replaceEnv === true && + options.env.OPENSHELL_GATEWAY === "nemoclaw-8091" && + options.env.OPENSHELL_WORKSPACE === "default" && + options.env.OPENSHELL_LOCAL_TLS_DIR === "/recorded/tls" && + options.env.OPENSHELL_GATEWAY_ENDPOINT === undefined && + options.env.OPENSHELL_GATEWAY_INSECURE === undefined && + options.env.OPENSHELL_TOKEN === undefined, + ), + ).toBe(true); + }); + it("succeeds after retrying past unhealthy probes and still sets OPENSHELL_GATEWAY (#3768)", async () => { vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "3"); vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "2"); diff --git a/src/lib/onboard/gateway-recovery.ts b/src/lib/onboard/gateway-recovery.ts index c48a2a535b8..596ccc0fc5b 100644 --- a/src/lib/onboard/gateway-recovery.ts +++ b/src/lib/onboard/gateway-recovery.ts @@ -5,6 +5,10 @@ import path from "node:path"; import { dockerContainerInspectFormat } from "../adapters/docker"; import { getGatewayClusterContainerName } from "../adapters/openshell/gateway-drift"; +import { + type OpenShellRuntimeSelection, + withSelectedOpenShellCommandOptions, +} from "../adapters/openshell/command-argv"; import { getGatewayHttpEndpoint } from "../core/gateway-address"; import { BEDROCK_RUNTIME_ADAPTER_PORT, @@ -39,16 +43,20 @@ import { export type StartGatewayForRecoveryOptions = { gatewayName?: string; gatewayPort?: number; + runtimeSelection?: OpenShellRuntimeSelection; }; type RunOpenshellOptions = { ignoreError?: boolean; env?: Record; + replaceEnv?: boolean; suppressOutput?: boolean; }; type RunCaptureOpenshellOptions = { ignoreError?: boolean; + env?: Record; + replaceEnv?: boolean; }; type GatewayStartResult = { @@ -67,7 +75,13 @@ export type GatewayRecoveryDeps = { getGatewayClusterContainerState?(gatewayName: string): string; runCaptureOpenshell(args: string[], opts?: RunCaptureOpenshellOptions): string; runOpenshell(args: string[], opts?: RunOpenshellOptions): GatewayStartResult; - startGatewayWithOptions(gpu: never, options: { exitOnFailure: false }): Promise; + startGatewayWithOptions( + gpu: never, + options: { + exitOnFailure: false; + runtimeSelection?: OpenShellRuntimeSelection; + }, + ): Promise; isLinuxDockerDriverGatewayEnabled?(): boolean; sleepSeconds?(seconds: number): void; // Injected so caller-level tests can exercise the success + retry-success @@ -162,8 +176,13 @@ function getGatewayRecoveryWaitBudgetMs(pollCount: number, pollIntervalSeconds: async function startTargetGatewayForRecovery( { gatewayName, gatewayPort }: { gatewayName: string; gatewayPort: number }, deps: GatewayRecoveryDeps, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise { - deps.runOpenshell(["gateway", "select", gatewayName], { ignoreError: true }); + const runtimeOptions = withSelectedOpenShellCommandOptions({}, runtimeSelection); + deps.runOpenshell(["gateway", "select", gatewayName], { + ...runtimeOptions, + ignoreError: true, + }); const recoveryWait = getGatewayHealthWaitConfig( 0, @@ -191,11 +210,18 @@ async function startTargetGatewayForRecovery( const healthy = waitOptions !== null && (await waitUntilAsync(async () => { - const status = deps.runCaptureOpenshell(["status"], { ignoreError: true }); + const status = deps.runCaptureOpenshell(["status"], { + ...runtimeOptions, + ignoreError: true, + }); const namedInfo = deps.runCaptureOpenshell(["gateway", "info", "-g", gatewayName], { + ...runtimeOptions, + ignoreError: true, + }); + const currentInfo = deps.runCaptureOpenshell(["gateway", "info"], { + ...runtimeOptions, ignoreError: true, }); - const currentInfo = deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); return ( status.includes("Connected") && gatewayHealthyImpl(status, namedInfo, currentInfo, gatewayName) && @@ -232,6 +258,11 @@ export async function startGatewayForRecovery( deps: GatewayRecoveryDeps, ): Promise { const target = resolveGatewayRecoveryTarget(options); + if (options.runtimeSelection && options.runtimeSelection.gatewayName !== target.gatewayName) { + throw new Error( + `Gateway recovery target '${target.gatewayName}' does not match runtime selection '${options.runtimeSelection.gatewayName}'`, + ); + } // Guard every recovery branch. The cross-port / non-default-name path below // bypasses startGatewayWithOptions. It reselects an already-running gateway // and waits for health instead of starting a gateway process. Resolve and @@ -249,7 +280,10 @@ export async function startGatewayForRecovery( // case where the user re-runs with the same NEMOCLAW_GATEWAY_PORT). if (target.gatewayPort === GATEWAY_PORT) { if (target.gatewayName === resolveDefaultGatewayName() || linuxDockerDriverEnabled) { - return deps.startGatewayWithOptions(undefined as never, { exitOnFailure: false }); + return deps.startGatewayWithOptions(undefined as never, { + exitOnFailure: false, + ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), + }); } } // Cross-port recovery on a Linux Docker-driver gateway cannot share this @@ -265,5 +299,5 @@ export async function startGatewayForRecovery( `Re-run with NEMOCLAW_GATEWAY_PORT=${target.gatewayPort} so the docker-driver setup can restamp the runtime marker, registration, and sandbox bridge.`, ); } - return startTargetGatewayForRecovery(target, deps); + return startTargetGatewayForRecovery(target, deps, options.runtimeSelection); } diff --git a/src/lib/onboard/gateway-reuse.test.ts b/src/lib/onboard/gateway-reuse.test.ts index 0090e1a0666..ddbcfedb2d0 100644 --- a/src/lib/onboard/gateway-reuse.test.ts +++ b/src/lib/onboard/gateway-reuse.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { @@ -77,6 +77,10 @@ describe("Docker-driver network inspection", () => { }); describe("gateway reuse snapshot", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("bounds OpenShell gateway inspection probes (#6752)", () => { const runCaptureOpenshell = vi.fn(() => ""); const helpers = createGatewayReuseHelpers({ @@ -103,6 +107,50 @@ describe("gateway reuse snapshot", () => { }); }); + it("replaces ambient OpenShell selectors for a frozen reuse target (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "1"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + const runCaptureOpenshell = vi.fn( + (_args: string[], _options?: Record) => "", + ); + const helpers = createGatewayReuseHelpers({ + gatewayName: "nemoclaw", + runCaptureOpenshell, + runOpenshell: vi.fn(() => ({ status: 0 })), + cliDisplayName: () => "NemoClaw", + }); + + helpers.getGatewayReuseSnapshot({ + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/recorded/tls", + }); + + expect(runCaptureOpenshell).toHaveBeenCalledTimes(3); + const statusOptions = runCaptureOpenshell.mock.calls[0]?.[1] as + | { env?: Record; replaceEnv?: boolean } + | undefined; + const namedInfoOptions = runCaptureOpenshell.mock.calls[1]?.[1] as typeof statusOptions; + const activeInfoOptions = runCaptureOpenshell.mock.calls[2]?.[1] as typeof statusOptions; + expect(statusOptions).toMatchObject({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + }), + replaceEnv: true, + }); + expect(namedInfoOptions?.env).toBe(statusOptions?.env); + expect(activeInfoOptions?.env).toBe(statusOptions?.env); + expect(statusOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(statusOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE"); + expect(statusOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + }); + it("classifies status stderr connection refusals as stale when gateway info is unavailable (#7087)", () => { const statusOutput = [ "Error: × client error (Connect)", diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts index c825c4fbc37..ed4e88a7980 100644 --- a/src/lib/onboard/gateway-reuse.ts +++ b/src/lib/onboard/gateway-reuse.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; +import { + type OpenShellRuntimeSelection, + withSelectedOpenShellCommandOptions, +} from "../adapters/openshell/command-argv"; import { getGatewayReuseState, type GatewayReuseState, @@ -26,8 +30,11 @@ export interface GatewayReuseDeps { } export interface GatewayReuseHelpers { - getGatewayReuseSnapshot(): GatewayReuseSnapshot; - selectNamedGatewayForReuseIfNeeded(snapshot: GatewayReuseSnapshot): GatewayReuseSnapshot; + getGatewayReuseSnapshot(runtimeSelection?: OpenShellRuntimeSelection): GatewayReuseSnapshot; + selectNamedGatewayForReuseIfNeeded( + snapshot: GatewayReuseSnapshot, + runtimeSelection?: OpenShellRuntimeSelection, + ): GatewayReuseSnapshot; } export interface DockerDriverGatewayReuseApplicationDeps { @@ -245,9 +252,21 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH const currentGatewayName = () => typeof deps.gatewayName === "function" ? deps.gatewayName() : deps.gatewayName; - function getGatewayReuseSnapshot(): GatewayReuseSnapshot { + function getGatewayReuseSnapshot( + runtimeSelection?: OpenShellRuntimeSelection, + ): GatewayReuseSnapshot { const gatewayName = currentGatewayName(); - const probeOptions = { ignoreError: true, timeout: OPENSHELL_PROBE_TIMEOUT_MS }; + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + throw new Error( + `Gateway reuse target '${gatewayName}' does not match runtime selection '${runtimeSelection.gatewayName}'`, + ); + } + const runtimeOptions = withSelectedOpenShellCommandOptions({}, runtimeSelection); + const probeOptions = { + ...runtimeOptions, + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }; // OpenShell 0.0.99 omits the gateway name when connection setup fails, so // bind the probe explicitly and carry that authority into classification. const gatewayStatus = deps.runCaptureOpenshell(["status", "-g", gatewayName], { @@ -274,8 +293,14 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH function selectNamedGatewayForReuseIfNeeded( snapshot: GatewayReuseSnapshot, + runtimeSelection?: OpenShellRuntimeSelection, ): GatewayReuseSnapshot { const gatewayName = currentGatewayName(); + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + throw new Error( + `Gateway reuse target '${gatewayName}' does not match runtime selection '${runtimeSelection.gatewayName}'`, + ); + } if ( !shouldSelectNamedGatewayForReuse( snapshot.gatewayStatus, @@ -287,7 +312,9 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH return snapshot; } + const runtimeOptions = withSelectedOpenShellCommandOptions({}, runtimeSelection); const selectResult = deps.runOpenshell(["gateway", "select", gatewayName], { + ...runtimeOptions, ignoreError: true, suppressOutput: true, }); @@ -295,7 +322,7 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH return snapshot; } - const refreshed = getGatewayReuseSnapshot(); + const refreshed = getGatewayReuseSnapshot(runtimeSelection); if (refreshed.gatewayReuseState === "healthy") { process.env.OPENSHELL_GATEWAY = gatewayName; console.log(` ✓ Selected existing ${deps.cliDisplayName()} gateway`); diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index 2c635784e0d..dc0e798879a 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -4,6 +4,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; + +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../../adapters/openshell/command-argv"; import { trackChildExit } from "../child-exit-tracker"; import * as dockerDriverGatewayCutover from "../docker-driver-gateway-cutover"; import { reportDockerDriverGatewayStartFailure } from "../docker-driver-gateway-failure"; @@ -49,11 +54,18 @@ export interface DockerDriverGatewayStartDeps { isGatewayTcpReady: DynamicGatewayHelpers["isGatewayTcpReady"]; isPidAlive: GatewayRuntimeHelpers["isPidAlive"]; logDockerDriverGatewayRestart(reason: string): void; - registerDockerDriverGatewayEndpoint(): boolean; + registerDockerDriverGatewayEndpoint(runtimeSelection?: OpenShellRuntimeSelection): boolean; rememberDockerDriverGatewayPid: GatewayRuntimeHelpers["rememberDockerDriverGatewayPid"]; resolveOpenShellGatewayBinary: GatewayRuntimeHelpers["resolveOpenShellGatewayBinary"]; resolveOpenShellSandboxBinary: GatewayRuntimeHelpers["resolveOpenShellSandboxBinary"]; - runCaptureOpenshell(args: string[], options?: { ignoreError?: boolean }): string; + runCaptureOpenshell( + args: string[], + options?: { + env?: Record; + ignoreError?: boolean; + replaceEnv?: boolean; + }, + ): string; sleepSeconds: typeof import("../../core/wait").sleepSeconds; verifySandboxBridgeGatewayReachableOrExit?: typeof verifySandboxBridgeGatewayReachableOrExit; } @@ -61,6 +73,7 @@ export interface DockerDriverGatewayStartDeps { export interface DockerDriverGatewayStart { startDockerDriverGateway(options?: { exitOnFailure?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; }): Promise; } @@ -70,11 +83,33 @@ export function createDockerDriverGatewayStart( ): DockerDriverGatewayStart { async function startDockerDriverGateway({ exitOnFailure = true, + runtimeSelection, skipSandboxBridgeReachability = false, }: { exitOnFailure?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; } = {}): Promise { + if (runtimeSelection && runtimeSelection.gatewayName !== deps.gatewayName()) { + throw new Error( + `Docker-driver gateway target '${deps.gatewayName()}' does not match runtime selection '${runtimeSelection.gatewayName}'`, + ); + } + const selectedRuntimeEnv = runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(runtimeSelection) + : undefined; + const runtimeOptions = selectedRuntimeEnv + ? { + env: selectedRuntimeEnv, + replaceEnv: true, + } + : {}; + const runCaptureOpenshell: DockerDriverGatewayStartDeps["runCaptureOpenshell"] = ( + args, + options = {}, + ) => deps.runCaptureOpenshell(args, { ...options, ...runtimeOptions }); + const registerDockerDriverGatewayEndpoint = () => + deps.registerDockerDriverGatewayEndpoint(runtimeSelection); const verifyReachability = deps.verifySandboxBridgeGatewayReachableOrExit ?? verifySandboxBridgeGatewayReachableOrExit; const stateDir = deps.gatewayBinding.resolveGatewayStateDirForPort({ @@ -101,7 +136,7 @@ export function createDockerDriverGatewayStart( ); } const gatewayBin = deps.resolveOpenShellGatewayBinary(); - const openshellVersionOutput = deps.runCaptureOpenshell(["--version"], { ignoreError: true }); + const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ @@ -113,6 +148,7 @@ export function createDockerDriverGatewayStart( compatContainerName: deps.gatewayBinding.resolveGatewayCompatContainerName( deps.gatewayPort(), ), + ...(selectedRuntimeEnv ? { env: selectedRuntimeEnv } : {}), ensureLocalTlsBundle: true, }) : null; @@ -139,14 +175,15 @@ export function createDockerDriverGatewayStart( () => deps.dockerDriverGatewayEnv.startPackageManagedDockerDriverGatewayWithEnvOverride({ clearDockerDriverGatewayRuntimeFiles: deps.clearDockerDriverGatewayRuntimeFiles, + ...(selectedRuntimeEnv ? { env: selectedRuntimeEnv } : {}), exitOnFailure, gatewayEnv: driftGatewayEnv, gatewayName: deps.gatewayName(), isDockerDriverGatewayReady: () => deps.isDockerDriverGatewayHttpReady(undefined, undefined, driftGatewayEnv), - registerDockerDriverGatewayEndpoint: deps.registerDockerDriverGatewayEndpoint, + registerDockerDriverGatewayEndpoint, preparePortForOpenShellGatewayUserServiceStart: servicePortOwnership.preparePort, - runCaptureOpenshell: deps.runCaptureOpenshell, + runCaptureOpenshell, skipSandboxBridgeReachability, validatePortOwnerForOpenShellGatewayUserServiceStart: servicePortOwnership.validatePortOwner, @@ -172,7 +209,7 @@ export function createDockerDriverGatewayStart( ), pidFileGatewayPid: deps.getDockerDriverGatewayPid(), initialHealth: dockerDriverGatewayCutover.readDockerDriverGatewayHealth( - deps.runCaptureOpenshell, + runCaptureOpenshell, deps.gatewayName(), ), }, @@ -181,7 +218,7 @@ export function createDockerDriverGatewayStart( isGatewayHealthy: deps.isGatewayHealthy, getDockerDriverGatewayRuntimeDrift: deps.getDockerDriverGatewayRuntimeDrift, logDockerDriverGatewayRestart: deps.logDockerDriverGatewayRestart, - registerDockerDriverGatewayEndpoint: deps.registerDockerDriverGatewayEndpoint, + registerDockerDriverGatewayEndpoint, isDockerDriverGatewayHttpReady: () => deps.isDockerDriverGatewayHttpReady(undefined, undefined, driftGatewayEnv), verifySandboxBridgeGatewayReachableOrExit: (fail, options) => @@ -190,11 +227,11 @@ export function createDockerDriverGatewayStart( port: deps.gatewayPort(), }), readGatewayHealth: () => ({ - status: deps.runCaptureOpenshell(["status"], { ignoreError: true }), - namedInfo: deps.runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName()], { + status: runCaptureOpenshell(["status"], { ignoreError: true }), + namedInfo: runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName()], { ignoreError: true, }), - activeInfo: deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true }), + activeInfo: runCaptureOpenshell(["gateway", "info"], { ignoreError: true }), }), rememberDockerDriverGatewayPid: deps.rememberDockerDriverGatewayPid, reapDuplicateHostGatewaysExceptOrFail, @@ -258,8 +295,8 @@ export function createDockerDriverGatewayStart( port: deps.gatewayPort(), }); }, - registerGatewayEndpoint: deps.registerDockerDriverGatewayEndpoint, - runCaptureOpenshell: deps.runCaptureOpenshell, + registerGatewayEndpoint: registerDockerDriverGatewayEndpoint, + runCaptureOpenshell, sleepSeconds: deps.sleepSeconds, }); if (startup === "healthy") { diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index 70a904e1b98..3703714694b 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -10,6 +10,7 @@ import { ensureDockerDriverGatewayJwtBundle, gatewayIdForStateDir, } from "../docker-driver-gateway-config"; +import * as dockerDriverGatewayLaunch from "../docker-driver-gateway-launch"; import * as gatewayBinding from "../gateway-binding"; import { createDockerDriverGatewayStart } from "./docker-driver-start"; import { createGatewayRecoveryOrchestration } from "./recovery"; @@ -54,6 +55,69 @@ describe("gateway lifecycle late binding", () => { ); }); + it("keeps Docker-driver registration on the frozen OpenShell target (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_DISABLE_TLS", "1"); + vi.stubEnv("OPENSHELL_DISABLE_GATEWAY_AUTH", "1"); + const runCaptureOpenshell = vi.fn( + (_args: string[], _options?: Record) => "Connected", + ); + const runOpenshell = vi.fn( + (_args: string[], _options?: Record) => runResult(), + ); + const runQuietOpenshell = vi.fn(() => runResult()); + const registration = createGatewayRegistration({ + gatewayName: () => "nemoclaw-8090", + getDockerDriverGatewayEndpointArg: () => "https://127.0.0.1:8090", + getGatewayLocalEndpoint: () => "https://127.0.0.1:8090", + hasStaleGateway: () => false, + isGatewayHealthy: () => true, + isLinuxDockerDriverGatewayEnabled: () => true, + removeDockerDriverGatewayRegistration: () => true, + runCaptureOpenshell, + runOpenshell, + runQuietOpenshell, + }); + + try { + expect( + registration.registerDockerDriverGatewayEndpoint({ + gatewayName: "nemoclaw-8090", + workspace: "default", + localTlsDir: "/recorded/tls", + }), + ).toBe(true); + + expect(runQuietOpenshell).not.toHaveBeenCalled(); + expect(runOpenshell).toHaveBeenCalledTimes(1); + expect(runCaptureOpenshell).toHaveBeenCalledTimes(3); + const selectedOptions = runOpenshell.mock.calls[0]?.[1] as + | { env?: Record; replaceEnv?: boolean } + | undefined; + expect(selectedOptions).toMatchObject({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-8090", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/recorded/tls", + }), + replaceEnv: true, + }); + expect(runCaptureOpenshell.mock.calls[0]?.[1]?.env).toBe(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[1]?.[1]?.env).toBe(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[2]?.[1]?.env).toBe(selectedOptions?.env); + expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_TLS"); + expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_GATEWAY_AUTH"); + } finally { + vi.unstubAllEnvs(); + } + }); + it("uses the current binding for recovery select and health commands", async () => { let name = "initial"; const runOpenshell = vi.fn(() => runResult()); @@ -109,6 +173,7 @@ describe("gateway lifecycle late binding", () => { typeof import("../docker-driver-gateway-env").startPackageManagedDockerDriverGatewayWithEnvOverride >[0], ) => { + options.runCaptureOpenshell(["status"], { ignoreError: true }); await options.verifySandboxBridgeGatewayReachableOrExit(false, {}); return true; }, @@ -129,6 +194,17 @@ describe("gateway lifecycle late binding", () => { ).toBeNull(); return { OPENSHELL_SERVER_PORT: String(port) }; }); + const runCaptureOpenshell = vi.fn( + (_args: string[], _options?: Record) => "", + ); + const runtimeIdentitySpy = vi + .spyOn(dockerDriverGatewayLaunch, "buildDockerDriverGatewayRuntimeIdentity") + .mockImplementation((options) => ({ + launch: null, + desiredEnv: {}, + driftGatewayBin: null, + identityGatewayBin: options.gatewayBin, + })); const start = createDockerDriverGatewayStart({ SUPPORTED_OPENSHELL_FALLBACK_VERSION: "0.0.0", checkGatewayPortAvailable: async () => ({ ok: true }), @@ -166,9 +242,9 @@ describe("gateway lifecycle late binding", () => { logDockerDriverGatewayRestart: vi.fn(), registerDockerDriverGatewayEndpoint: () => true, rememberDockerDriverGatewayPid: vi.fn(), - resolveOpenShellGatewayBinary: () => null, + resolveOpenShellGatewayBinary: () => "/opt/openshell/openshell-gateway", resolveOpenShellSandboxBinary: () => null, - runCaptureOpenshell: () => "", + runCaptureOpenshell, sleepSeconds: vi.fn(), verifySandboxBridgeGatewayReachableOrExit: verifyReachability, }); @@ -195,12 +271,57 @@ describe("gateway lifecycle late binding", () => { fs.existsSync(path.join(stateDir, gatewayBinding.MANAGED_GATEWAY_STATE_ROOT_MARKER)), ).toBe(false); vi.stubEnv("NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR", ` ${stateDir} `); + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_DISABLE_TLS", "1"); + vi.stubEnv("OPENSHELL_DISABLE_GATEWAY_AUTH", "1"); try { - await start.startDockerDriverGateway(); + await start.startDockerDriverGateway({ + runtimeSelection: { + gatewayName: "resumed", + workspace: "default", + localTlsDir: path.join(stateDir, "tls"), + }, + }); expect(managedStart).toHaveBeenCalledWith( expect.objectContaining({ gatewayName: "resumed" }), ); + const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; + const managedOptions = managedStart.mock.calls[0]?.[0]; + expect(runtimeIdentityOptions?.env).toBe(managedOptions?.env); + expect(runtimeIdentityOptions?.env).toEqual( + expect.objectContaining({ + OPENSHELL_GATEWAY: "resumed", + OPENSHELL_LOCAL_TLS_DIR: path.join(stateDir, "tls"), + OPENSHELL_WORKSPACE: "default", + }), + ); + expect(runtimeIdentityOptions?.env?.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(runtimeIdentityOptions?.env?.OPENSHELL_TOKEN).toBeUndefined(); + expect(runtimeIdentityOptions?.env?.OPENSHELL_DISABLE_TLS).toBeUndefined(); + expect(runtimeIdentityOptions?.env?.OPENSHELL_DISABLE_GATEWAY_AUTH).toBeUndefined(); + expect(runCaptureOpenshell).toHaveBeenCalledTimes(2); + const versionOptions = runCaptureOpenshell.mock.calls[0]?.[1] as + | { env?: Record; replaceEnv?: boolean } + | undefined; + const statusOptions = runCaptureOpenshell.mock.calls[1]?.[1] as typeof versionOptions; + expect(versionOptions).toMatchObject({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "resumed", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: path.join(stateDir, "tls"), + }), + replaceEnv: true, + }); + expect(statusOptions?.env).toBe(versionOptions?.env); + expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_TLS"); + expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_GATEWAY_AUTH"); expect(verifyReachability).toHaveBeenCalledWith( false, expect.objectContaining({ port: 9777 }), @@ -244,6 +365,7 @@ describe("gateway lifecycle late binding", () => { expect(getDockerDriverGatewayEnv).toHaveBeenCalledTimes(1); expect(managedStart).toHaveBeenCalledTimes(1); } finally { + runtimeIdentitySpy.mockRestore(); vi.unstubAllEnvs(); fs.rmSync(root, { force: true, recursive: true }); } diff --git a/src/lib/onboard/gateway/recovery.ts b/src/lib/onboard/gateway/recovery.ts index 63d2da5f90f..eb90ff55beb 100644 --- a/src/lib/onboard/gateway/recovery.ts +++ b/src/lib/onboard/gateway/recovery.ts @@ -41,7 +41,13 @@ export interface GatewayRecoveryOrchestrationDeps { shouldPatchCoredns: typeof import("../../platform").shouldPatchCoredns; sleepSeconds: typeof import("../../core/wait").sleepSeconds; startDockerDriverGateway(options?: { exitOnFailure?: boolean }): Promise; - startGatewayWithOptions(gpu: OnboardGpu, options: { exitOnFailure: false }): Promise; + startGatewayWithOptions( + gpu: OnboardGpu, + options: { + exitOnFailure: false; + runtimeSelection?: StartGatewayForRecoveryOptions["runtimeSelection"]; + }, + ): Promise; } export interface GatewayRecoveryOrchestration { diff --git a/src/lib/onboard/gateway/registration.ts b/src/lib/onboard/gateway/registration.ts index d8dbbfa0161..7cc72033468 100644 --- a/src/lib/onboard/gateway/registration.ts +++ b/src/lib/onboard/gateway/registration.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + type OpenShellRuntimeSelection, + withSelectedOpenShellCommandOptions, +} from "../../adapters/openshell/command-argv"; + type RunResult = ReturnType; export interface GatewayRegistrationDeps { @@ -14,32 +19,69 @@ export interface GatewayRegistrationDeps { runCaptureOpenshell(args: string[], options?: { ignoreError?: boolean }): string; runOpenshell( args: string[], - options?: { ignoreError?: boolean; suppressOutput?: boolean }, + options?: { + env?: Record; + ignoreError?: boolean; + replaceEnv?: boolean; + stdio?: ["ignore", "pipe", "pipe"]; + suppressOutput?: boolean; + }, ): RunResult; runQuietOpenshell(args: string[]): { status: number | null }; } export interface GatewayRegistration { attachGatewayMetadataIfNeeded(options?: { forceRefresh?: boolean }): boolean; - registerDockerDriverGatewayEndpoint(): boolean; + registerDockerDriverGatewayEndpoint(runtimeSelection?: OpenShellRuntimeSelection): boolean; } export function createGatewayRegistration(deps: GatewayRegistrationDeps): GatewayRegistration { - function registerDockerDriverGatewayEndpoint(): boolean { - const selectExisting = deps.runQuietOpenshell(["gateway", "select", deps.gatewayName()]); + function registerDockerDriverGatewayEndpoint( + runtimeSelection?: OpenShellRuntimeSelection, + ): boolean { + if (runtimeSelection && runtimeSelection.gatewayName !== deps.gatewayName()) { + throw new Error( + `Gateway registration target '${deps.gatewayName()}' does not match runtime selection '${runtimeSelection.gatewayName}'`, + ); + } + const runtimeOptions = withSelectedOpenShellCommandOptions({}, runtimeSelection); + const runCaptureOpenshell: GatewayRegistrationDeps["runCaptureOpenshell"] = ( + args, + options = {}, + ) => + deps.runCaptureOpenshell(args, { ...options, ...runtimeOptions }); + const runOpenshell: GatewayRegistrationDeps["runOpenshell"] = (args, options = {}) => + deps.runOpenshell(args, { ...options, ...runtimeOptions }); + const runQuietOpenshell = (args: string[]) => + runtimeSelection + ? runOpenshell(args, { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, + }) + : deps.runQuietOpenshell(args); + const removeRegistration = (): boolean => { + if (!runtimeSelection) return deps.removeDockerDriverGatewayRegistration(); + const removeResult = runQuietOpenshell(["gateway", "remove", deps.gatewayName()]); + if (removeResult.status === 0) return true; + return ( + runQuietOpenshell(["gateway", "destroy", "-g", deps.gatewayName()]).status === 0 + ); + }; + const selectExisting = runQuietOpenshell(["gateway", "select", deps.gatewayName()]); if (selectExisting.status === 0) { - const status = deps.runCaptureOpenshell(["status"], { ignoreError: true }); - const namedInfo = deps.runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName()], { + const status = runCaptureOpenshell(["status"], { ignoreError: true }); + const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName()], { ignoreError: true, }); - const currentInfo = deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + const currentInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); if (deps.isGatewayHealthy(status, namedInfo, currentInfo)) { process.env.OPENSHELL_GATEWAY = deps.gatewayName(); return true; } } - let addResult = deps.runOpenshell( + let addResult = runOpenshell( [ "gateway", "add", @@ -51,8 +93,8 @@ export function createGatewayRegistration(deps: GatewayRegistrationDeps): Gatewa { ignoreError: true, suppressOutput: true }, ); if (addResult.status !== 0) { - deps.removeDockerDriverGatewayRegistration(); - addResult = deps.runOpenshell( + removeRegistration(); + addResult = runOpenshell( [ "gateway", "add", @@ -64,7 +106,7 @@ export function createGatewayRegistration(deps: GatewayRegistrationDeps): Gatewa { ignoreError: true, suppressOutput: true }, ); } - const selectResult = deps.runOpenshell(["gateway", "select", deps.gatewayName()], { + const selectResult = runOpenshell(["gateway", "select", deps.gatewayName()], { ignoreError: true, suppressOutput: true, }); @@ -72,11 +114,11 @@ export function createGatewayRegistration(deps: GatewayRegistrationDeps): Gatewa (addResult.status === 0 && selectResult.status === 0) || (selectResult.status === 0 && deps.isGatewayHealthy( - deps.runCaptureOpenshell(["status"], { ignoreError: true }), - deps.runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName()], { + runCaptureOpenshell(["status"], { ignoreError: true }), + runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName()], { ignoreError: true, }), - deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true }), + runCaptureOpenshell(["gateway", "info"], { ignoreError: true }), )); if (ok) { process.env.OPENSHELL_GATEWAY = deps.gatewayName(); diff --git a/src/lib/onboard/gateway/start.ts b/src/lib/onboard/gateway/start.ts index 78e19469532..c3ca3c24bf0 100644 --- a/src/lib/onboard/gateway/start.ts +++ b/src/lib/onboard/gateway/start.ts @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + type OpenShellRuntimeSelection, + withSelectedOpenShellCommandOptions, +} from "../../adapters/openshell/command-argv"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; import { normalizeGatewayStartError } from "../gateway-start-failure"; @@ -23,10 +27,18 @@ export interface GatewayStartDeps { isGatewayHealthy(status: string, namedInfo: string, activeInfo: string): boolean; isGatewayHttpReady: DynamicGatewayHelpers["isGatewayHttpReady"]; isLinuxDockerDriverGatewayEnabled(): boolean; - runOpenshell(args: string[], options?: { ignoreError?: boolean }): unknown; + runOpenshell( + args: string[], + options?: { + env?: Record; + ignoreError?: boolean; + replaceEnv?: boolean; + }, + ): unknown; selectNamedGatewayForReuseIfNeeded: GatewayReuseHelpers["selectNamedGatewayForReuseIfNeeded"]; startDockerDriverGateway(options?: { exitOnFailure?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; }): Promise; step: typeof import("../prompt-helpers").step; @@ -36,7 +48,11 @@ export interface GatewayStart { startGateway(gpu: OnboardGpu, options?: { gpuPassthrough?: boolean }): Promise; startGatewayWithOptions( gpu: OnboardGpu, - options?: { exitOnFailure?: boolean; gpuPassthrough?: boolean }, + options?: { + exitOnFailure?: boolean; + gpuPassthrough?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; + }, ): Promise; } @@ -46,7 +62,12 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { { exitOnFailure = true, gpuPassthrough = false, - }: { exitOnFailure?: boolean; gpuPassthrough?: boolean } = {}, + runtimeSelection, + }: { + exitOnFailure?: boolean; + gpuPassthrough?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; + } = {}, ): Promise { deps.assertGatewayStartAllowed(exitOnFailure); deps.step(2, 8, "Starting OpenShell gateway"); @@ -62,6 +83,7 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { ); return deps.startDockerDriverGateway({ exitOnFailure, + ...(runtimeSelection ? { runtimeSelection } : {}), skipSandboxBridgeReachability: deps.dockerGpuLocalInference.shouldSkipGpuBridgeProbe( gpuPassthrough, gpu?.platform, @@ -70,7 +92,10 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { }); } - const snapshot = deps.selectNamedGatewayForReuseIfNeeded(deps.getGatewayReuseSnapshot()); + const snapshot = deps.selectNamedGatewayForReuseIfNeeded( + deps.getGatewayReuseSnapshot(runtimeSelection), + runtimeSelection, + ); if ( deps.isGatewayHealthy(snapshot.gatewayStatus, snapshot.gwInfo, snapshot.activeGatewayInfo) ) { @@ -78,7 +103,10 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { // prevent a later connection failure (#3258). if (await deps.isGatewayHttpReady()) { console.log(" ✓ Reusing existing gateway"); - deps.runOpenshell(["gateway", "select", deps.gatewayName()], { ignoreError: true }); + deps.runOpenshell( + ["gateway", "select", deps.gatewayName()], + withSelectedOpenShellCommandOptions({ ignoreError: true }, runtimeSelection), + ); process.env.OPENSHELL_GATEWAY = deps.gatewayName(); return; } diff --git a/src/lib/runner.ts b/src/lib/runner.ts index 9b2e135f909..36497150f0c 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -27,11 +27,15 @@ const SCRIPTS = path.join(ROOT, "scripts"); type RunnerOptions = SpawnSyncOptions & { ignoreError?: boolean; + /** Use only opts.env instead of merging the sanitized parent environment. */ + replaceEnv?: boolean; suppressOutput?: boolean; }; type CaptureOptions = Omit & { ignoreError?: boolean; + /** Use only opts.env instead of merging the sanitized parent environment. */ + replaceEnv?: boolean; /** * Append captured stderr to the returned stdout. This opt-in output is raw * and unredacted; callers must not log it without applying redaction first. @@ -49,13 +53,18 @@ if (dockerHost) { } } -function buildRunnerEnv(extraEnv?: NodeJS.ProcessEnv, executable?: string): Record { +function buildRunnerEnv( + extraEnv?: NodeJS.ProcessEnv, + executable?: string, + replaceEnv = false, +): Record { const normalizedExtra: Record = {}; if (extraEnv) { for (const [key, value] of Object.entries(extraEnv)) { if (value !== undefined) normalizedExtra[key] = value; } } + if (replaceEnv) return normalizedExtra; const usesDockerDefaultAuthority = executable !== undefined && path.basename(executable) === "docker" && @@ -123,6 +132,7 @@ function spawnAndHandle( ): SpawnResult { const safeFile = normalizeSpawnFile(file, "spawnAndHandle"); const safeArgs = normalizeSpawnArgs(args, "spawnAndHandle"); + const { ignoreError, replaceEnv, suppressOutput, env: extraEnv, ...spawnOpts } = opts; const effectiveStdio = redirectInheritedChildStdoutToStderr(stdio); // All non-shell runner paths pass argv arrays and force shell=false; runShell // and runInteractiveShell enter here with a literal `bash -c` executable and @@ -131,22 +141,22 @@ function spawnAndHandle( // lgtm[js/indirect-command-line-injection] // lgtm[js/shell-command-injection-from-environment] const result = spawnSync(safeFile, safeArgs, { - ...opts, + ...spawnOpts, shell: false, stdio: effectiveStdio, cwd: ROOT, - env: buildRunnerEnv(opts.env, safeFile), + env: buildRunnerEnv(extraEnv, safeFile, replaceEnv), }); - if (!opts.suppressOutput) { + if (!suppressOutput) { writeRedactedResult(result, effectiveStdio); } - if (result.error && !opts.ignoreError) { + if (result.error && !ignoreError) { console.error( ` Command failed: ${redact(renderedCommand).slice(0, 80)}: ${result.error.message}`, ); process.exit(1); } - if (result.status !== 0 && !opts.ignoreError) { + if (result.status !== 0 && !ignoreError) { console.error( ` Command failed (exit ${result.status}): ${redact(renderedCommand).slice(0, 80)}`, ); @@ -191,7 +201,14 @@ function runArrayCmd( callerName = "run", ): SpawnResult { const [exe, args] = normalizeArgv(cmd, callerName); - const { ignoreError, suppressOutput, env: extraEnv, stdio: stdioCfg, ...spawnOpts } = opts; + const { + ignoreError, + replaceEnv, + suppressOutput, + env: extraEnv, + stdio: stdioCfg, + ...spawnOpts + } = opts; // Guard: re-enabling shell interpretation defeats the purpose of argv arrays. if (spawnOpts.shell) { @@ -209,7 +226,7 @@ function runArrayCmd( shell: false, stdio, cwd: ROOT, - env: buildRunnerEnv(extraEnv, exe), + env: buildRunnerEnv(extraEnv, exe, replaceEnv), }); if (!suppressOutput) { writeRedactedResult(result, stdio); @@ -298,7 +315,14 @@ function runCapture(cmd: readonly string[], opts: CaptureOptions = {}): string { throw new Error("runCapture no longer accepts shell strings; pass an argv array instead"); } const [exe, args] = normalizeArgv(cmd, "runCapture"); - const { ignoreError, includeStderr, env: extraEnv, stdio: _stdio, ...spawnOpts } = opts; + const { + ignoreError, + includeStderr, + replaceEnv, + env: extraEnv, + stdio: _stdio, + ...spawnOpts + } = opts; // Guard: re-enabling shell interpretation defeats the purpose of argv arrays. if (spawnOpts.shell) { @@ -315,7 +339,7 @@ function runCapture(cmd: readonly string[], opts: CaptureOptions = {}): string { ...spawnOpts, shell: false, cwd: ROOT, - env: buildRunnerEnv(extraEnv, exe), + env: buildRunnerEnv(extraEnv, exe, replaceEnv), stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", }); @@ -365,7 +389,7 @@ function runCaptureEx( throw new Error("runCaptureEx: cmd must be a non-empty argv array"); } const [exe, args] = normalizeArgv(cmd, "runCaptureEx"); - const { env: extraEnv, stdio: _stdio, ...spawnOpts } = opts as CaptureOptions; + const { replaceEnv, env: extraEnv, stdio: _stdio, ...spawnOpts } = opts as CaptureOptions; try { // runCaptureEx() follows the same argv-only, shell=false boundary as // runCapture(), while returning structured timeout diagnostics. @@ -379,7 +403,7 @@ function runCaptureEx( // NO_PROXY=localhost,127.0.0.1 is injected when HTTP_PROXY is set. // Otherwise curl probes against localhost (Ollama validation, etc.) // tunnel through the user's host proxy and fail with HTTP 500. - env: buildRunnerEnv(extraEnv, exe), + env: buildRunnerEnv(extraEnv, exe, replaceEnv), stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", }); diff --git a/src/lib/state/paths.ts b/src/lib/state/paths.ts index a918b2ecbe4..17c0bfcf91c 100644 --- a/src/lib/state/paths.ts +++ b/src/lib/state/paths.ts @@ -27,3 +27,8 @@ export function resolveNemoclawStateDir(homeDir?: string): string { } return path.join(resolveNemoclawHomeDir(homeDir), "state"); } + +/** Return the validated gateway port that selects this process's NemoClaw state root. */ +export function resolveNemoclawStateGatewayPort(): number { + return GATEWAY_PORT; +} diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 49f91ceab03..23ad182d02f 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -695,11 +695,19 @@ export function safeTarExtract(tarArchive: TarArchiveSource, targetDir: string): // ── Helpers ──────────────────────────────────────────────────────── -export function getSshConfig(sandboxName: string): string | null { +export function getSshConfig( + sandboxName: string, + runtimeOptions: { + env?: NodeJS.ProcessEnv; + gatewayName?: string; + replaceEnv?: boolean; + } = {}, +): string | null { const openshellBinary = resolveOpenshell(); if (!openshellBinary) return null; const result = captureSandboxSshConfigCommand(openshellBinary, sandboxName, { + ...runtimeOptions, ignoreError: true, timeout: OPENSHELL_PROBE_TIMEOUT_MS, }); diff --git a/test/e2e-runtime/runner.test.ts b/test/e2e-runtime/runner.test.ts index 92d8deb078a..944b4bb9203 100644 --- a/test/e2e-runtime/runner.test.ts +++ b/test/e2e-runtime/runner.test.ts @@ -9,7 +9,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { redact, runCapture } from "../../src/lib/runner"; +import { redact, run, runCapture, runCaptureEx } from "../../src/lib/runner"; const require = createRequire(import.meta.url); const runnerPath = path.join(import.meta.dirname, "..", "..", "src", "lib", "runner.ts"); @@ -177,6 +177,41 @@ describe("runner helpers", () => { }); describe("runner env merging", () => { + it("uses only the explicit environment when replaceEnv is true", () => { + const inheritedName = "OPENSHELL_RUNNER_REPLACE_ENV_LEAK"; + const selectedName = "OPENSHELL_RUNNER_REPLACE_ENV_SELECTED"; + const command = [ + process.execPath, + "-e", + `process.stdout.write(JSON.stringify({ inherited: process.env.${inheritedName} ?? null, selected: process.env.${selectedName} ?? null }))`, + ]; + const selectedEnv = { [selectedName]: "selected-value" }; + + try { + vi.stubEnv(inheritedName, "ambient-value"); + const runResult = run(command, { + env: selectedEnv, + replaceEnv: true, + suppressOutput: true, + }); + const captureOutput = runCapture(command, { + env: selectedEnv, + replaceEnv: true, + }); + const captureExOutput = runCaptureEx(command, { + env: selectedEnv, + replaceEnv: true, + }); + + const expected = { inherited: null, selected: "selected-value" }; + expect(JSON.parse(String(runResult.stdout))).toEqual(expected); + expect(JSON.parse(captureOutput)).toEqual(expected); + expect(JSON.parse(captureExOutput.stdout)).toEqual(expected); + } finally { + vi.unstubAllEnvs(); + } + }); + it("clears a named context when initialization selects a socket fallback (#8816)", () => { const platform = require(platformPath); const detectDockerHostSpy = vi.spyOn(platform, "detectDockerHost").mockReturnValue({ From 69d87705ad335a3b62fd5619e0078576abdf7531 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 1 Sep 2026 13:41:36 -0700 Subject: [PATCH 2/8] refactor(mcp): bind lifecycle to recorded runtime Signed-off-by: Apurv Kumaria --- ci/source-architecture-budget.json | 2 +- src/lib/actions/sandbox/gateway-target.ts | 15 + ...cp-bridge-adapter-deepagents-capability.ts | 10 +- .../mcp-bridge-adapter-deepagents-command.ts | 4 +- ...cp-bridge-adapter-deepagents-inspection.ts | 3 + ...-bridge-adapter-deepagents-registration.ts | 13 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 3 + ...mcp-bridge-adapter-hermes-branding.test.ts | 79 +++- .../sandbox/mcp-bridge-adapter-hermes.ts | 34 +- .../sandbox/mcp-bridge-adapter-inspection.ts | 4 +- .../mcp-bridge-adapter-openclaw.test.ts | 5 +- .../sandbox/mcp-bridge-adapter-openclaw.ts | 24 +- .../mcp-bridge-adapter-registration.test.ts | 86 +++- .../mcp-bridge-adapter-teardown.test.ts | 42 +- .../sandbox/mcp-bridge-adapter-teardown.ts | 10 +- .../actions/sandbox/mcp-bridge-adapters.ts | 41 +- .../actions/sandbox/mcp-bridge-add-restart.ts | 141 ++++-- .../mcp-bridge-destroy-forced-scrub.test.ts | 54 +++ .../mcp-bridge-destroy-preflight.test.ts | 92 ++++ .../sandbox/mcp-bridge-destroy-preflight.ts | 57 ++- src/lib/actions/sandbox/mcp-bridge-destroy.ts | 115 +++-- .../mcp-bridge-hermes-reconciliation.test.ts | 7 +- .../mcp-bridge-hermes-reconciliation.ts | 8 + .../sandbox/mcp-bridge-input-targets.test.ts | 7 +- ...mcp-bridge-openclaw-shields-config.test.ts | 35 +- .../actions/sandbox/mcp-bridge-policy.test.ts | 43 +- src/lib/actions/sandbox/mcp-bridge-policy.ts | 28 +- .../mcp-bridge-private-lifecycle.test.ts | 2 + .../mcp-bridge-provider-attachments.ts | 38 +- .../mcp-bridge-provider-inspection.test.ts | 212 ++++++++++ .../sandbox/mcp-bridge-provider-inspection.ts | 122 +++++- .../sandbox/mcp-bridge-provider-mutation.ts | 67 ++- .../mcp-bridge-provider-profile.test.ts | 20 +- .../sandbox/mcp-bridge-provider-readiness.ts | 25 +- .../sandbox/mcp-bridge-provider.test.ts | 400 ++++++++++++++++-- .../actions/sandbox/mcp-bridge-provider.ts | 2 + .../mcp-bridge-rebuild-exec-unavailable.ts | 63 ++- src/lib/actions/sandbox/mcp-bridge-rebuild.ts | 157 +++++-- .../actions/sandbox/mcp-bridge-recovery.ts | 10 +- src/lib/actions/sandbox/mcp-bridge-remove.ts | 49 ++- .../mcp-bridge-resolution-probe.test.ts | 49 ++- .../sandbox/mcp-bridge-resolution-probe.ts | 6 +- src/lib/actions/sandbox/mcp-bridge-restart.ts | 133 ++++-- .../mcp-bridge-runtime-capabilities.ts | 10 +- src/lib/actions/sandbox/mcp-bridge-state.ts | 14 +- .../mcp-bridge-status-boundaries.test.ts | 137 +++++- .../mcp-bridge-status-resolution.test.ts | 61 ++- .../sandbox/mcp-bridge-status-state.test.ts | 17 +- src/lib/actions/sandbox/mcp-bridge-status.ts | 44 +- .../sandbox/mcp-bridge-tool-discovery.ts | 4 +- src/lib/actions/sandbox/mcp-bridge.ts | 37 +- src/lib/actions/sandbox/policy-get.ts | 7 +- .../adapters/openshell/policy-state.test.ts | 111 +++++ src/lib/adapters/openshell/policy-state.ts | 89 +++- .../openshell/provider-command.test.ts | 62 ++- .../adapters/openshell/provider-command.ts | 19 +- src/lib/policy/commands.ts | 13 +- src/lib/policy/index.ts | 191 +++++++-- src/lib/policy/policy-live-state.test.ts | 63 ++- src/lib/shields/flow.test.ts | 200 +++++---- src/lib/shields/index.ts | 342 ++++++++++++--- src/lib/shields/legacy-hermes-compat.test.ts | 137 +----- src/lib/shields/transition-lock.ts | 10 +- .../deepagents-mcp-legacy-lifecycle.test.ts | 19 +- .../deepagents-mcp-runtime-capability.test.ts | 33 +- .../hermes/hermes-mcp-startup-probe.test.ts | 48 ++- ...ermes-shields-provider-consumer-harness.ts | 130 +++++- test/helpers/shields-flow-harness.ts | 106 +++++ .../mcp/mcp-adapter-teardown-rollback.test.ts | 20 +- test/mcp/mcp-add-crash-consistency.test.ts | 45 +- ...mcp-bridge-destroy-marker-recovery.test.ts | 11 +- test/mcp/mcp-destroy-lifecycle.test.ts | 36 +- test/mcp/mcp-provider-detach-retry.test.ts | 11 +- test/mcp/mcp-provider-ownership.test.ts | 41 +- test/mcp/mcp-restart-policy-order.test.ts | 59 ++- 75 files changed, 3674 insertions(+), 770 deletions(-) create mode 100644 src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts create mode 100644 src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 31473c3d408..ef544a63ea5 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -29,7 +29,7 @@ "src/lib/state/onboard-session.ts": 35, "src/lib/state/registry.ts": 97, "src/lib/state/state-root.ts": 21, - "src/lib/subprocess-env.ts": 24, + "src/lib/subprocess-env.ts": 23, "src/lib/validation.ts": 24 } }, diff --git a/src/lib/actions/sandbox/gateway-target.ts b/src/lib/actions/sandbox/gateway-target.ts index a36b2cfbf9c..be9c8441455 100644 --- a/src/lib/actions/sandbox/gateway-target.ts +++ b/src/lib/actions/sandbox/gateway-target.ts @@ -4,6 +4,7 @@ import { GATEWAY_PORT } from "../../core/ports"; import { resolveGatewayName, + resolveGatewayPortFromName, resolveSandboxGatewayName, type SandboxGatewayBinding, } from "../../onboard/gateway-binding"; @@ -31,6 +32,20 @@ export function getPersistedSandboxTargetGatewayName(sandbox: SandboxGatewayBind return resolveSandboxGatewayName(sandbox); } +/** Resolve the complete canonical gateway binding from one persisted sandbox row. */ +export function getPersistedSandboxTargetGateway(sandbox: SandboxGatewayBinding): { + gatewayName: string; + gatewayPort: number; + selectedInProcess: boolean; +} { + const gatewayName = getPersistedSandboxTargetGatewayName(sandbox); + const gatewayPort = resolveGatewayPortFromName(gatewayName); + if (gatewayPort === null) { + throw new Error(`Invalid persisted OpenShell gateway '${gatewayName}'.`); + } + return { gatewayName, gatewayPort, selectedInProcess: gatewayPort === GATEWAY_PORT }; +} + export function gatewayNamePattern(gatewayName: string): RegExp { return new RegExp( `Gateway:\\s+${gatewayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts index b6bbcaa64f9..80d8a57860a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts @@ -2,14 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 import { McpBridgeError } from "./mcp-bridge-contracts"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { executeSandboxCommand } from "./process-recovery"; const DEEPAGENTS_MCP_CAPABILITY_MARKER = "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2"; const DEEPAGENTS_MCP_CAPABILITY_COMMAND = "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability"; -export function assertDeepAgentsMcpMutationRuntimeCapability(sandboxName: string): void { - const result = executeSandboxCommand(sandboxName, DEEPAGENTS_MCP_CAPABILITY_COMMAND); +export function assertDeepAgentsMcpMutationRuntimeCapability( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + const result = executeSandboxCommand(sandboxName, DEEPAGENTS_MCP_CAPABILITY_COMMAND, { + runtimeSelection, + }); if (result?.status !== 0 || result.stdout.trim() !== DEEPAGENTS_MCP_CAPABILITY_MARKER) { throw new McpBridgeError( `LangChain Deep Agents Code sandbox '${sandboxName}' does not contain managed MCP capability v2. Rebuild the sandbox before changing authenticated MCP state.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts index f7994e40eb9..4d03b6ba4cb 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts @@ -5,6 +5,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import type { AdapterMutationOptions } from "./mcp-bridge-adapter-inspection"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { executeSandboxCommand } from "./process-recovery"; export function runDeepAgentsAdapterCommand( @@ -12,9 +13,10 @@ export function runDeepAgentsAdapterCommand( entry: Pick, command: string, failureMessage: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): string { - const result = executeSandboxCommand(sandboxName, command); + const result = executeSandboxCommand(sandboxName, command, { runtimeSelection }); const output = redactBridgeSecretsForDisplay( [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), entry, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts index bbe998a330d..3dc30f4dd9c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { type AdapterRegistrationInspection, inspectAdapterRegistrationCommand, @@ -12,11 +13,13 @@ import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; export function inspectDeepAgentsAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): AdapterRegistrationInspection { return inspectAdapterRegistrationCommand( sandboxName, entry, buildDeepAgentsMcpStatusCommand(entry, credentialRevision), + runtimeSelection, ); } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 0b9365de1b0..20ed008d3b8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -17,6 +17,7 @@ import { pythonJsonLiteral, } from "./mcp-bridge-adapter-status"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { McpBridgeError } from "./mcp-bridge-contracts"; export function buildDeepAgentsMcpRegisterCommand( @@ -118,9 +119,15 @@ function registryOwnedDeepAgentsEntries( function verifyDeepAgentsAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): void { - const inspection = inspectDeepAgentsAdapterRegistration(sandboxName, entry, credentialRevision); + const inspection = inspectDeepAgentsAdapterRegistration( + sandboxName, + entry, + runtimeSelection, + credentialRevision, + ); if (inspection.state === "registered") return; const detail = inspection.state === "error" ? inspection.detail : inspection.state; throw new McpBridgeError( @@ -131,6 +138,7 @@ function verifyDeepAgentsAdapterRegistration( export function registerDeepAgentsAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, teardownRollback = false, @@ -147,6 +155,7 @@ export function registerDeepAgentsAdapter( credentialRevision, ), `Deep Agents Code MCP config registration failed for '${entry.server}'.`, + runtimeSelection, { envValues }, ); if (teardownRollback) { @@ -156,6 +165,6 @@ export function registerDeepAgentsAdapter( ); } } else { - verifyDeepAgentsAdapterRegistration(sandboxName, entry, credentialRevision); + verifyDeepAgentsAdapterRegistration(sandboxName, entry, runtimeSelection, credentialRevision); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index c27d89dc2f3..9899c12f83c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import { runDeepAgentsAdapterCommand } from "./mcp-bridge-adapter-deepagents-command"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, @@ -180,6 +181,7 @@ export function buildDeepAgentsMcpRemoveCommand( export function unregisterDeepAgentsAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): AdapterRemovalOutcome { const stdout = runDeepAgentsAdapterCommand( @@ -187,6 +189,7 @@ export function unregisterDeepAgentsAdapter( entry, buildDeepAgentsMcpRemoveCommand(entry, options.force === true, options.teardown === true), `Deep Agents Code MCP config removal failed for '${entry.server}'.`, + runtimeSelection, options, ); const marker = stdout.match(/NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=(removed|absent|unowned)/); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts index 4b6b027af83..9cd4f886a07 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts @@ -3,39 +3,102 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; +import type { McpBridgeEntry } from "../../state/registry"; + const mocks = vi.hoisted(() => ({ + getSandboxOrThrow: vi.fn(), isShieldsDown: vi.fn(), - runOpenshellProviderCommand: vi.fn(), -})); - -vi.mock("../../adapters/openshell/provider-command", () => ({ - runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, + runOpenshell: vi.fn(), })); vi.mock("../../shields", () => ({ isShieldsDown: mocks.isShieldsDown, })); -import { assertHermesMcpMutationRuntimeCapability } from "./mcp-bridge-adapter-hermes"; +vi.mock("./mcp-bridge-state", () => ({ + getSandboxOrThrow: mocks.getSandboxOrThrow, +})); + +import { + assertHermesMcpMutationRuntimeCapability, + unregisterHermesAdapter, +} from "./mcp-bridge-adapter-hermes"; + +const entry: McpBridgeEntry = { + server: "github", + agent: "hermes", + adapter: "hermes-config", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: new Date(0).toISOString(), +}; + +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; describe("Hermes MCP recovery guidance", () => { beforeEach(() => { vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemohermes"); + mocks.getSandboxOrThrow.mockReset().mockReturnValue({ + agent: "hermes", + gatewayName: "nemoclaw-8091", + name: "alpha", + }); mocks.isShieldsDown.mockReset().mockReturnValue(true); - mocks.runOpenshellProviderCommand.mockReset().mockReturnValue({ + mocks.runOpenshell.mockReset().mockReturnValue({ status: 1, stdout: "", stderr: "Hermes gateway is not running under the managed service lifecycle", }); + setProviderCommandRuntimeHooksForTest({ runOpenshell: mocks.runOpenshell }); }); afterEach(() => { + setProviderCommandRuntimeHooksForTest({}); vi.unstubAllEnvs(); }); it("uses the invoked CLI name when the managed lifecycle is unavailable", () => { - expect(() => assertHermesMcpMutationRuntimeCapability("alpha")).toThrow( + expect(() => assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection)).toThrow( "Run `nemohermes alpha recover` and retry.", ); }); + + it("pins Hermes MCP lifecycle commands to the recorded runtime 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"); + mocks.runOpenshell.mockImplementation((_args, options) => { + expect(options).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-8091", + OPENSHELL_WORKSPACE: "default", + }), + replaceEnv: true, + }), + ); + expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE"); + expect(options?.env).not.toHaveProperty("OPENSHELL_LOCAL_TLS_DIR"); + expect(options?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + return { + status: 0, + stdout: JSON.stringify({ changed: true, ok: true, reloaded: true }), + stderr: "", + }; + }); + + expect(() => + assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection), + ).not.toThrow(); + expect(() => unregisterHermesAdapter("alpha", entry, runtimeSelection)).not.toThrow(); + expect(mocks.runOpenshell).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts index 51dbd276644..4861745ceac 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts @@ -22,6 +22,7 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { executeGatewaySupervisorAction } from "./process-recovery"; @@ -83,12 +84,14 @@ export function buildHermesMcpProbeCommand(): string[] { export function inspectHermesAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): AdapterRegistrationInspection { return inspectAdapterRegistrationCommand( sandboxName, entry, buildHermesMcpStatusCommand(entry, credentialRevision), + runtimeSelection, ); } @@ -107,8 +110,11 @@ function parseLastJsonObject(output: string): Record | null { } /** Refuse an in-sandbox Hermes config mutation while config is locked. */ -export function assertHermesMcpConfigMutationAllowed(sandboxName: string): void { - if (isShieldsDown(sandboxName, false)) return; +export function assertHermesMcpConfigMutationAllowed( + sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, +): void { + if (isShieldsDown(sandboxName, false, runtimeSelection)) return; throw new McpBridgeError( `Hermes sandbox '${sandboxName}' has shields up or an unreadable shields posture. Run \`nemohermes ${sandboxName} shields down --timeout 15m --reason "MCP maintenance"\` before changing MCP configuration.`, ); @@ -119,8 +125,11 @@ export function assertHermesMcpConfigMutationAllowed(sandboxName: string): void * and can invoke it through OpenShell current main's ordinary exec path before * changing a global provider, policy, attachment, or adapter. */ -export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): void { - assertHermesMcpConfigMutationAllowed(sandboxName); +export function assertHermesMcpMutationRuntimeCapability( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + assertHermesMcpConfigMutationAllowed(sandboxName, runtimeSelection); let lastDetail = ""; const probe = (): boolean => { let result: ReturnType; @@ -133,6 +142,7 @@ export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): v ), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], timeout: 45_000, }, @@ -224,6 +234,7 @@ function runHermesAdapterCommand( entry: McpBridgeEntry, command: readonly string[], failureMessage: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions & { requireReload?: boolean } = {}, ): void { // OpenShell current main executes this fixed helper argv with ordinary @@ -234,6 +245,7 @@ function runHermesAdapterCommand( try { result = runOpenshellProviderCommand(buildHermesMcpExecArgs(sandboxName, command), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], // The remote supervisor enforces 620s; keep a small transport margin so // remote termination is observed before this local subprocess is killed. @@ -281,9 +293,15 @@ function runHermesAdapterCommand( function verifyHermesAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): void { - const inspection = inspectHermesAdapterRegistration(sandboxName, entry, credentialRevision); + const inspection = inspectHermesAdapterRegistration( + sandboxName, + entry, + runtimeSelection, + credentialRevision, + ); if (inspection.state === "registered") return; const detail = inspection.state === "error" ? inspection.detail : inspection.state; throw new McpBridgeError( @@ -294,6 +312,7 @@ function verifyHermesAdapterRegistration( export function registerHermesAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, credentialRevision?: McpAttachedCredentialRevision, @@ -303,14 +322,16 @@ export function registerHermesAdapter( entry, buildHermesMcpRegisterCommand(entry, replaceExisting, credentialRevision), `Hermes MCP config registration failed for '${entry.server}'.`, + runtimeSelection, { envValues, requireReload: true }, ); - verifyHermesAdapterRegistration(sandboxName, entry, credentialRevision); + verifyHermesAdapterRegistration(sandboxName, entry, runtimeSelection, credentialRevision); } export function unregisterHermesAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): void { runHermesAdapterCommand( @@ -318,6 +339,7 @@ export function unregisterHermesAdapter( entry, buildHermesMcpRemoveCommand(entry, options.force === true), `Hermes MCP config removal failed for '${entry.server}'.`, + runtimeSelection, options, ); } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts index 773e87eace0..b5236992462 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { executeSandboxCommand, type SandboxCommandResult } from "./process-recovery"; export type AdapterRegistrationInspection = @@ -50,8 +51,9 @@ export function inspectAdapterRegistrationCommand( sandboxName: string, entry: McpBridgeEntry, command: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): AdapterRegistrationInspection { - const result = executeSandboxCommand(sandboxName, command); + const result = executeSandboxCommand(sandboxName, command, { runtimeSelection }); if (!result) return { state: "error", detail: "sandbox unreachable" }; return parseAdapterRegistrationInspection(result, entry); } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts index 20816107c41..b83a839963c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts @@ -547,8 +547,9 @@ processRecovery.executeSandboxCommand = (_sandboxName, command) => { }; const adapter = require("./src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.js"); const entry = ${JSON.stringify(baseEntry)}; -adapter.registerOpenClawAdapter("custom-root-lifecycle", entry); -adapter.unregisterOpenClawAdapter("custom-root-lifecycle", entry); +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; +adapter.registerOpenClawAdapter("custom-root-lifecycle", entry, runtimeSelection); +adapter.unregisterOpenClawAdapter("custom-root-lifecycle", entry, runtimeSelection); process.stdout.write(JSON.stringify(commands)); `; const result = spawnSync(process.execPath, ["-e", script], { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts index d8ea4f8d5a0..db0872266c7 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts @@ -20,6 +20,7 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { quoteMcpBridgeShellArg } from "./mcp-bridge-runtime-command"; import { getAgentConfigDir } from "./mcp-bridge-state"; @@ -54,15 +55,21 @@ function mcporterRootForEntry(entry: McpBridgeEntry): string { * This mirrors `assertHermesMcpConfigMutationAllowed`, which already fails * closed the same way for the Hermes adapter. */ -export function assertOpenClawMcpConfigMutationAllowed(sandboxName: string): void { - if (isShieldsDown(sandboxName, false)) return; +export function assertOpenClawMcpConfigMutationAllowed( + sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, +): void { + if (isShieldsDown(sandboxName, false, runtimeSelection)) return; throw new McpBridgeError( `OpenClaw sandbox '${sandboxName}' has shields up or an unreadable shields posture. The managed Mcporter config is locked together with the rest of the workspace state root. Run \`nemoclaw ${sandboxName} shields down --timeout 15m --reason "MCP maintenance"\` before changing MCP configuration.`, ); } -function ensureMcporter(sandboxName: string): void { - const check = executeSandboxCommand(sandboxName, "command -v mcporter"); +function ensureMcporter( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + const check = executeSandboxCommand(sandboxName, "command -v mcporter", { runtimeSelection }); if (check?.status === 0 && check.stdout.trim()) return; throw new McpBridgeError( `mcporter is not available in sandbox '${sandboxName}'. Rebuild with a NemoClaw image that includes mcporter@${MCPORTER_VERSION}.`, @@ -152,27 +159,31 @@ export function buildOpenClawMcporterRemoveCommand( export function inspectOpenClawAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): AdapterRegistrationInspection { const root = mcporterRootForEntry(entry); return inspectAdapterRegistrationCommand( sandboxName, entry, buildOpenClawMcporterInspectCommand(entry, false, root), + runtimeSelection, ); } export function registerOpenClawAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, credentialRevision?: McpAttachedCredentialRevision, ): void { - ensureMcporter(sandboxName); + ensureMcporter(sandboxName, runtimeSelection); const root = mcporterRootForEntry(entry); const result = executeSandboxCommand( sandboxName, buildOpenClawMcporterRegisterCommand(entry, replaceExisting, root, credentialRevision), + { runtimeSelection }, ); const output = redactBridgeSecretsForDisplay( [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), @@ -190,6 +201,7 @@ export function registerOpenClawAdapter( const verification = executeSandboxCommand( sandboxName, buildOpenClawMcporterInspectCommand(entry, true, root, credentialRevision), + { runtimeSelection }, ); const verificationOutput = redactBridgeSecretsForDisplay( [verification?.stdout, verification?.stderr].filter(Boolean).join("\n").trim(), @@ -210,12 +222,14 @@ export function registerOpenClawAdapter( export function unregisterOpenClawAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): void { const root = mcporterRootForEntry(entry); const result = executeSandboxCommand( sandboxName, buildOpenClawMcporterRemoveCommand(entry, options.force === true, root), + { runtimeSelection }, ); const output = redactBridgeSecretsForDisplay( [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts index e1198dfff85..38b0da9b376 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts @@ -46,6 +46,7 @@ import { buildHermesMcpStatusCommand, registerAgentAdapter, registerAgentAdapterAtCurrentCredentialRevision, + unregisterAgentAdapter, } from "./mcp-bridge-adapters"; import { registerOpenClawAdapter } from "./mcp-bridge-adapter-openclaw"; import { entryHeaders, mcporterHeadersMatchExpected } from "./mcp-bridge-adapter-status"; @@ -70,6 +71,8 @@ const lifecycleSuccess = { const commandSuccess = { status: 0, stdout: "", stderr: "" }; const registered = { status: 0, stdout: "registered\n", stderr: "" }; const mismatch = { status: 0, stdout: "mismatch\n", stderr: "" }; +const sandbox = { name: "alpha", agent: "hermes", gatewayName: "nemoclaw-8091" }; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; interface AdapterCase { name: string; @@ -163,14 +166,14 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { mocks.executeSandboxCommand.mockReset(); mocks.executeGatewaySupervisorAction.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("re-reads the persisted definition before registration succeeds", () => { adapterCase.arrangeInspection(registered); expect(() => - registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, { + registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, runtimeSelection, { GITHUB_TOKEN: "host-only-secret", }), ).not.toThrow(); @@ -178,6 +181,10 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { expect(mocks.executeSandboxCommand).toHaveBeenLastCalledWith( "alpha", adapterCase.statusCommand(adapterCase.entry), + { runtimeSelection }, + ); + expect(mocks.executeSandboxCommand.mock.calls.map((call) => call[2])).toEqual( + Array(mocks.executeSandboxCommand.mock.calls.length).fill({ runtimeSelection }), ); }); @@ -185,7 +192,7 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { adapterCase.arrangeInspection(mismatch); expect(() => - registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, { + registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, runtimeSelection, { GITHUB_TOKEN: "host-only-secret", }), ).toThrow(`${adapterCase.adapter} config verification failed after adding 'github': mismatch.`); @@ -195,7 +202,7 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { describe("OpenClaw MCP adapter registration", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("rejects a v11 post-write observation after registering the readiness-proven v12", () => { @@ -216,7 +223,14 @@ describe("OpenClaw MCP adapter registration", () => { .mockReturnValueOnce(verification); expect(() => - registerOpenClawAdapter("alpha", entry, { GITHUB_TOKEN: "host-only-secret" }, false, "v12"), + registerOpenClawAdapter( + "alpha", + entry, + runtimeSelection, + { GITHUB_TOKEN: "host-only-secret" }, + false, + "v12", + ), ).toThrow("mcporter config verification failed after adding 'github': mismatch"); expect(mocks.executeSandboxCommand.mock.calls[1]?.[1]).toContain( @@ -225,13 +239,16 @@ describe("OpenClaw MCP adapter registration", () => { expect(mocks.executeSandboxCommand.mock.calls[2]?.[1]).toContain( "Bearer openshell:resolve:env:v12_GITHUB_TOKEN", ); + expect(mocks.executeSandboxCommand.mock.calls.map((call) => call[2])).toEqual( + Array(mocks.executeSandboxCommand.mock.calls.length).fill({ runtimeSelection }), + ); }); }); describe("Deep Agents MCP adapter credential revision", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("writes and verifies the readiness-proven revision", () => { @@ -247,6 +264,7 @@ describe("Deep Agents MCP adapter credential revision", () => { "alpha", "deepagents-config", entry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, { credentialRevision: "v12" }, ), @@ -268,7 +286,7 @@ describe("Hermes MCP adapter credential revision", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("writes and verifies the readiness-proven revision", () => { @@ -280,6 +298,7 @@ describe("Hermes MCP adapter credential revision", () => { "alpha", "hermes-config", baseEntry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, { credentialRevision: "v12" }, ), @@ -302,6 +321,7 @@ describe.each(reconciliationCases)("$name MCP credential revision reconciliation mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReturnValue(sandbox); mocks.observeMcpCredentialRevision.mockReset(); mocks.observeMcpCredentialRevision.mockReturnValue("v12"); }); @@ -315,6 +335,7 @@ describe.each(reconciliationCases)("$name MCP credential revision reconciliation "alpha", adapterCase.adapter, adapterCase.entry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, "v11", ), @@ -331,10 +352,56 @@ describe("MCP adapter credential revision reconciliation failures", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); mocks.observeMcpCredentialRevision.mockReset(); }); + it("keeps one operation target after the registry target changes (#10514)", () => { + const operationSelection = { + gatewayName: "nemoclaw-8091", + localTlsDir: "/authority/gateway-8091/tls", + workspace: "default", + } as const; + const entry: McpBridgeEntry = { + ...baseEntry, + agent: "openclaw", + adapter: "mcporter", + }; + mocks.executeSandboxCommand.mockImplementation((_sandbox, command: string) => + command === "command -v mcporter" + ? { status: 0, stdout: "/usr/bin/mcporter\n", stderr: "" } + : command.includes("config' 'add") + ? commandSuccess + : registered, + ); + mocks.observeMcpCredentialRevision.mockImplementation(() => { + mocks.getSandbox.mockReturnValue({ + agent: "openclaw", + gatewayName: "foreign-gateway", + name: "alpha", + }); + return "v11"; + }); + + expect( + registerAgentAdapterAtCurrentCredentialRevision( + "alpha", + "mcporter", + entry, + operationSelection, + {}, + "v11", + ), + ).toBe("v11"); + expect(mocks.getSandbox()).toMatchObject({ gatewayName: "foreign-gateway" }); + expect( + mocks.executeSandboxCommand.mock.calls.map((call) => call[2]?.runtimeSelection), + ).toEqual(Array(mocks.executeSandboxCommand.mock.calls.length).fill(operationSelection)); + expect(mocks.observeMcpCredentialRevision.mock.calls.map((call) => call[2])).toEqual( + Array(mocks.observeMcpCredentialRevision.mock.calls.length).fill(operationSelection), + ); + }); + it.each(["absent", "canonical"] as const)( "fails closed when reconciliation observes %s credential authority", (observation) => { @@ -352,6 +419,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v11", ), @@ -375,6 +443,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v10", ), @@ -400,6 +469,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v10", ), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts index 7f4ce98b358..0f82a56fa6a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ getSandboxAgent: vi.fn(), getSandboxPolicy: vi.fn(), getSandboxOrThrow: vi.fn(), + inspectExactMcpDestroyProvider: vi.fn(), inspectMcpProvider: vi.fn(), observeMcpCredentialRevision: vi.fn(), removeGeneratedPolicy: vi.fn(), @@ -42,6 +43,10 @@ vi.mock("./mcp-bridge-provider", () => ({ assertNoProviderCredentialCollisions: vi.fn(), assertNoRegisteredProviderCredentialCollisions: vi.fn(), detachProvider: vi.fn(), + getMcpProviderInspectionRuntimeSelection: vi.fn(() => ({ + gatewayName: "nemoclaw-8091", + workspace: "default", + })), inspectMcpProvider: mocks.inspectMcpProvider, preflightMcpEntryTargets: vi.fn(), waitForDetachedMcpCredential: vi.fn(), @@ -50,7 +55,7 @@ vi.mock("./mcp-bridge-provider", () => ({ vi.mock("./mcp-bridge-destroy-preflight", () => ({ cloneMcpBridgeEntry: vi.fn((entry: McpBridgeEntry) => ({ ...entry, env: [...entry.env] })), discardSafeIncompleteMcpAdds: mocks.discardSafeIncompleteMcpAdds, - inspectExactMcpDestroyProvider: vi.fn(), + inspectExactMcpDestroyProvider: mocks.inspectExactMcpDestroyProvider, })); vi.mock("./mcp-bridge-policy", () => ({ @@ -94,6 +99,7 @@ import { prepareMcpBridgesForRebuild } from "./mcp-bridge-rebuild"; import { scrubManagedMcpAdapterOrThrow } from "./mcp-bridge-adapter-teardown"; const sandbox = { agent: "hermes" } as SandboxEntry; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" } as const; const entry: McpBridgeEntry = { server: "github", agent: "hermes", @@ -118,6 +124,13 @@ describe("MCP adapter teardown rollback", () => { yaml: "version: 1\nnetwork_policies:\n mcp_bridge_github: {}\n", }); mocks.getSandboxOrThrow.mockReset().mockReturnValue(sandbox); + mocks.inspectExactMcpDestroyProvider.mockReset().mockReturnValue({ + credentialKeys: ["GITHUB_TOKEN"], + exists: true, + id: entry.providerId, + resourceVersion: 12, + type: "nemoclaw-mcp-v1", + }); mocks.inspectMcpProvider.mockReset().mockReturnValue({ exists: false }); mocks.observeMcpCredentialRevision.mockReset().mockReturnValue("v12"); mocks.removeGeneratedPolicy.mockReset().mockImplementation(() => { @@ -148,6 +161,7 @@ describe("MCP adapter teardown rollback", () => { "alpha", "hermes-config", expect.objectContaining({ ...entry, credentialRevision: "v12" }), + runtimeSelection, {}, "v13", { @@ -169,11 +183,35 @@ describe("MCP adapter teardown rollback", () => { type: "nemoclaw-mcp-v1", }); - expect(() => scrubManagedMcpAdapterOrThrow("alpha", sandbox, entry)).toThrow( + expect(() => + scrubManagedMcpAdapterOrThrow("alpha", sandbox, entry, runtimeSelection), + ).toThrow( "Could not prove a revision-scoped credential before removing the managed adapter entry for MCP server 'github'.", ); expect(mocks.inspectMcpProvider).not.toHaveBeenCalled(); expect(mocks.unregisterAgentAdapter).not.toHaveBeenCalled(); expect(mocks.registerAgentAdapterAtCurrentCredentialRevision).not.toHaveBeenCalled(); }); + + it("recovers the recorded gateway before initial destroy provider inspection (#10514)", async () => { + const events: string[] = []; + mocks.ensureSandboxGatewaySelected.mockImplementation(async () => { + events.push("gateway-selected"); + }); + mocks.inspectExactMcpDestroyProvider.mockImplementation(() => { + events.push("provider-inspected"); + return { + credentialKeys: ["GITHUB_TOKEN"], + exists: true, + id: entry.providerId, + resourceVersion: 12, + type: "nemoclaw-mcp-v1", + }; + }); + + await expect(prepareMcpBridgesForDestroy("alpha")).rejects.toThrow( + "forced lifecycle failure after adapter scrub", + ); + expect(events.slice(0, 2)).toEqual(["gateway-selected", "provider-inspected"]); + }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts index c219ba7aa66..4a5cd0d3a63 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts @@ -12,6 +12,7 @@ import { observeMcpCredentialRevision, type McpAttachedCredentialRevision, } from "./mcp-bridge-provider-readiness"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { getBridgeAdapter, getSandboxAgent } from "./mcp-bridge-state"; export type McpScrubbedAdapterEntry = McpBridgeEntry & { @@ -33,8 +34,9 @@ export function scrubManagedMcpAdapterOrThrow( sandboxName: string, sandbox: SandboxEntry, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): McpScrubbedAdapterEntry { - const observation = observeMcpCredentialRevision(sandboxName, entry); + const observation = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); if (observation === "absent" || observation === "canonical") { throw new McpBridgeError( `Could not prove a revision-scoped credential before removing the managed adapter entry for MCP server '${entry.server}'.`, @@ -42,7 +44,7 @@ export function scrubManagedMcpAdapterOrThrow( } const credentialRevision: McpAttachedCredentialRevision = observation; const adapter = resolveManagedMcpAdapter(sandbox, entry); - const removal = unregisterAgentAdapter(sandboxName, adapter, entry, { + const removal = unregisterAgentAdapter(sandboxName, adapter, entry, runtimeSelection, { envValues: {}, teardown: true, }); @@ -62,12 +64,13 @@ export function rollbackScrubbedMcpAdapters( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpScrubbedAdapterEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): string[] { const failures: string[] = []; for (const entry of entries) { let credentialRevision: McpAttachedCredentialRevision | undefined; try { - const current = observeMcpCredentialRevision(sandboxName, entry); + const current = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); if (current !== "absent" && current !== "canonical") credentialRevision = current; } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); @@ -84,6 +87,7 @@ export function rollbackScrubbedMcpAdapters( sandboxName, resolveManagedMcpAdapter(sandbox, entry), entry, + runtimeSelection, {}, credentialRevision, { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapters.ts b/src/lib/actions/sandbox/mcp-bridge-adapters.ts index a4fec84fc61..358d139f9ac 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -33,6 +33,9 @@ import { type McpAttachedCredentialRevision, observeMcpCredentialRevision, } from "./mcp-bridge-provider-readiness"; +import { + type McpProviderInspectionRuntimeSelection, +} from "./mcp-bridge-provider-inspection"; import { waitForMcpBridgeCondition } from "./mcp-bridge/timing"; const STABLE_CREDENTIAL_REVISION_OBSERVATIONS = 3; @@ -70,14 +73,15 @@ export function inspectAgentAdapterRegistration( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): AdapterRegistrationInspection { switch (adapter) { case "mcporter": - return inspectOpenClawAdapterRegistration(sandboxName, entry); + return inspectOpenClawAdapterRegistration(sandboxName, entry, runtimeSelection); case "hermes-config": - return inspectHermesAdapterRegistration(sandboxName, entry); + return inspectHermesAdapterRegistration(sandboxName, entry, runtimeSelection); case "deepagents-config": - return inspectDeepAgentsAdapterRegistration(sandboxName, entry); + return inspectDeepAgentsAdapterRegistration(sandboxName, entry, runtimeSelection); } } @@ -102,13 +106,14 @@ export function inspectAgentAdapterRegistration( export function assertAgentMcpConfigMutationAllowed( sandboxName: string, adapter: AgentMcpAdapter, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): void { switch (adapter) { case "hermes-config": - assertHermesMcpConfigMutationAllowed(sandboxName); + assertHermesMcpConfigMutationAllowed(sandboxName, runtimeSelection); return; case "mcporter": - assertOpenClawMcpConfigMutationAllowed(sandboxName); + assertOpenClawMcpConfigMutationAllowed(sandboxName, runtimeSelection); return; case "deepagents-config": return; @@ -118,13 +123,14 @@ export function assertAgentMcpConfigMutationAllowed( export function assertAgentMcpMutationRuntimeCapability( sandboxName: string, adapter: AgentMcpAdapter, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { switch (adapter) { case "deepagents-config": - assertDeepAgentsMcpMutationRuntimeCapability(sandboxName); + assertDeepAgentsMcpMutationRuntimeCapability(sandboxName, runtimeSelection); return; case "hermes-config": - assertHermesMcpMutationRuntimeCapability(sandboxName); + assertHermesMcpMutationRuntimeCapability(sandboxName, runtimeSelection); return; case "mcporter": return; @@ -141,10 +147,11 @@ export function assertAgentMcpMutationRuntimeCapability( export function assertAgentMcpTeardownRuntimeCapability( sandboxName: string, adapter: AgentMcpAdapter, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { - assertAgentMcpConfigMutationAllowed(sandboxName, adapter); + assertAgentMcpConfigMutationAllowed(sandboxName, adapter, runtimeSelection); if (adapter === "hermes-config") { - assertAgentMcpMutationRuntimeCapability(sandboxName, adapter); + assertAgentMcpMutationRuntimeCapability(sandboxName, adapter, runtimeSelection); } } @@ -152,6 +159,7 @@ export function registerAgentAdapter( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, options: { replaceExisting?: boolean; @@ -164,6 +172,7 @@ export function registerAgentAdapter( registerOpenClawAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.credentialRevision, @@ -173,6 +182,7 @@ export function registerAgentAdapter( registerHermesAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.credentialRevision, @@ -182,6 +192,7 @@ export function registerAgentAdapter( registerDeepAgentsAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.teardownRollback === true, @@ -196,6 +207,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record, initialCredentialRevision: McpAttachedCredentialRevision, options: { replaceExisting?: boolean; teardownRollback?: boolean } = {}, @@ -211,7 +223,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( registration <= MAX_CREDENTIAL_REVISION_REGISTRATIONS; registration += 1 ) { - registerAgentAdapter(sandboxName, adapter, entry, envValues, { + registerAgentAdapter(sandboxName, adapter, entry, runtimeSelection, envValues, { replaceExisting, teardownRollback: options.teardownRollback === true, credentialRevision, @@ -221,7 +233,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( let observedRevision: McpAttachedCredentialRevision | undefined; const stable = waitForMcpBridgeCondition( () => { - const observation = observeMcpCredentialRevision(sandboxName, entry); + const observation = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); if (observation === "absent" || observation === "canonical") { throw mcpAdapterCredentialRevisionUnavailableError(entry.server); } @@ -259,16 +271,17 @@ export function unregisterAgentAdapter( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): AdapterRemovalOutcome { switch (adapter) { case "mcporter": - unregisterOpenClawAdapter(sandboxName, entry, options); + unregisterOpenClawAdapter(sandboxName, entry, runtimeSelection, options); return "removed"; case "hermes-config": - unregisterHermesAdapter(sandboxName, entry, options); + unregisterHermesAdapter(sandboxName, entry, runtimeSelection, options); return "removed"; case "deepagents-config": - return unregisterDeepAgentsAdapter(sandboxName, entry, options); + return unregisterDeepAgentsAdapter(sandboxName, entry, runtimeSelection, options); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index a07134f04ff..ca9bd888554 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -39,8 +39,10 @@ import { detachMissingProviderReference, detachProvider, ensureMcpBridgeProviderProfile, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, type McpCredentialRevisionObservation, + type McpProviderInspectionRuntimeSelection, observeMcpCredentialRevision, providerMatchesCredential, providerShapeDetail, @@ -96,8 +98,14 @@ function assertPreparedMcpAddResourcesAbsent( adapter: AgentMcpAdapter, entry: McpBridgeEntry, target: McpBridgeTargetValidation, + providerRuntimeSelection: ReturnType, ): void { - const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry); + const adapterInspection = inspectAgentAdapterRegistration( + sandboxName, + adapter, + entry, + providerRuntimeSelection, + ); if (adapterInspection.state !== "absent") { const detail = adapterInspection.state === "error" @@ -108,7 +116,7 @@ function assertPreparedMcpAddResourcesAbsent( ); } - const providerInspection = inspectMcpProvider(entry.providerName); + const providerInspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (providerInspection.exists !== false) { const detail = providerInspection.exists === null @@ -126,7 +134,12 @@ function assertPreparedMcpAddResourcesAbsent( target, entry.providerName ?? "", ); - const policyState = policies.getPresetContentGatewayState(sandboxName, policyContent); + const policyState = policies.getPresetContentGatewayState( + sandboxName, + policyContent, + undefined, + providerRuntimeSelection, + ); if (policyState !== "absent") { throw new McpBridgeError( `MCP add preflight for '${entry.server}' could not prove generated policy key '${buildMcpBridgePolicyKey(entry.server)}' absent (state: ${policyState ?? "unreachable"}). The durable add manifest was preserved without claiming it.`, @@ -137,7 +150,7 @@ function assertPreparedMcpAddResourcesAbsent( export async function addMcpBridge( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise { return withMcpLifecycleLock(sandboxName, () => { assertHermesPortableCommandUnavailable(sandboxName, "sandbox:mcp:add"); return addMcpBridgeUnlocked(sandboxName, options); @@ -147,7 +160,7 @@ export async function addMcpBridge( async function addMcpBridgeUnlocked( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise { validateSandboxName(sandboxName); validateMcpServerName(options.server); assertAuthenticatedCredentialReference(options.env); @@ -298,20 +311,21 @@ async function addMcpBridgeUnlocked( 1, ); } + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); // Hermes config posture is host-visible, so reject before even the durable // prepared manifest is written. The in-sandbox helper repeats the check at // the actual config write so a concurrent posture change still fails closed. - assertAgentMcpConfigMutationAllowed(sandboxName, adapter); + assertAgentMcpConfigMutationAllowed(sandboxName, adapter, providerRuntimeSelection); // Bind the static credential-name deny-list to the OpenShell binary before // persisting ownership or mutating a provider, policy, or adapter. assertMcpCredentialBoundaryRuntimeVersion(); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); if (!existingEntry) { await withMcpCredentialOwnershipLock(() => { // Publish the durable MCP reservation under the same cross-command lock // used by credentials add. Neither command can pass its collision check // before the other records its credential-key reservation. - assertNoProviderCredentialCollisions(sandboxName, [entry]); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); writeBridgeEntry(sandboxName, entry); }); } @@ -323,7 +337,7 @@ async function addMcpBridgeUnlocked( try { let detachedMissingProviderReference = false; if (resumingPreflightedAdd) { - const providerInspection = inspectMcpProvider(entry.providerName); + const providerInspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (providerInspection.exists === null) { throw new McpBridgeError( providerInspection.error ?? @@ -337,35 +351,49 @@ async function addMcpBridgeUnlocked( // one recovery side effect that must precede the image capability // probe. It neither reads nor replaces credential material, and the // durable add manifest retains ownership if the later probe fails. - detachMissingProviderReference(sandboxName, entry); + detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); detachedMissingProviderReference = true; } } - assertAgentMcpMutationRuntimeCapability(sandboxName, adapter); + assertAgentMcpMutationRuntimeCapability(sandboxName, adapter, providerRuntimeSelection); if (detachedMissingProviderReference) { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); } if (resumingPreflightedAdd && !Object.hasOwn(adapterEnvValues, entry.env[0])) { try { // A retry may reuse an exact provider without re-exporting its secret, // but recreating a missing provider cannot. This check and any owned // policy cleanup happen only after the running-image capability probe. - assertMcpProviderRecoverable(entry); + assertMcpProviderRecoverable(entry, providerRuntimeSelection); } catch (error) { - removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); + removeGeneratedPolicy(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); throw error; } } if (entry.addState === "prepared") { - assertPreparedMcpAddResourcesAbsent(sandboxName, adapter, entry, target); + assertPreparedMcpAddResourcesAbsent( + sandboxName, + adapter, + entry, + target, + providerRuntimeSelection, + ); entry = { ...entry, addState: "preflighted" }; // This second durable boundary proves the derived resource names and the // adapter slot were absent before any side effect. After a crash, retries // may therefore reuse only missing or exact resources, never drift. writeBridgeEntry(sandboxName, entry); } - const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry); + const adapterInspection = inspectAgentAdapterRegistration( + sandboxName, + adapter, + entry, + providerRuntimeSelection, + ); if ( adapterInspection.state !== "absent" && !(resumingPreflightedAdd && adapterInspection.state === "registered") @@ -381,13 +409,16 @@ async function addMcpBridgeUnlocked( // Credential keys are sandbox-global. Prove this key is not already // supplied by a foreign attachment before opening its MCP route, then check // again after provider creation to close the intervening race. - assertNoProviderCredentialCollisions(sandboxName, [entry]); - ensureMcpBridgeProviderProfile(); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); // Load the real protocol:mcp policy without a credential binding before // provider mutation. OpenShell requires the endpointless provider to be // attached before it accepts credential_binding.provider, and withholds // that provider's static credential until the bound policy is active. - applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false }); + applyGeneratedPolicy(sandboxName, entry, target, { + bindCredential: false, + runtimeSelection: providerRuntimeSelection, + }); policyApplied = true; const providerResult = upsertMcpProvider(providerName ?? "", options.env, { // A first mutation must still observe the absence proven above. Only a @@ -395,12 +426,17 @@ async function addMcpBridgeUnlocked( // provider whose immutable ID was already persisted by this add. allowExisting: resumingPreflightedAdd, expectedProviderId: entry.providerId, + runtimeSelection: providerRuntimeSelection, prepareMutation: (action) => { // A fresh create has no prior revision to compare. Observe only the // bounded placeholder classification for an actual update, after the // running supervisor has accepted the authenticated MCP policy. if (action === "update") { - previousCredentialRevision = observeMcpCredentialRevision(sandboxName, entry); + previousCredentialRevision = observeMcpCredentialRevision( + sandboxName, + entry, + providerRuntimeSelection, + ); } }, }); @@ -418,17 +454,23 @@ async function addMcpBridgeUnlocked( // adapter mutations. A process death before this write fails closed. writeBridgeEntry(sandboxName, entry); } - assertNoProviderCredentialCollisions(sandboxName, [entry]); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); if (providerResult.action === "updated" && previousCredentialRevision === undefined) { throw new McpBridgeError( `Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`, ); } providerAttachAttempted = true; - attachProvider(sandboxName, entry); - applyGeneratedPolicy(sandboxName, entry, target); + attachProvider(sandboxName, entry, providerRuntimeSelection); + applyGeneratedPolicy(sandboxName, entry, target, { + runtimeSelection: providerRuntimeSelection, + }); let refreshedAfterObservedAbsence = false; - let credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { + let credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { ...(providerResult.action === "updated" ? { previousRevision: previousCredentialRevision, @@ -455,10 +497,14 @@ async function addMcpBridgeUnlocked( allowExisting: true, expectedProviderId: entry.providerId, requireExisting: true, + runtimeSelection: providerRuntimeSelection, }); - if (republished.action !== "updated") refreshMcpProviderEnvironment(entry); + if (republished.action !== "updated") { + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); + } }, - }); + }, + ); if (Object.hasOwn(adapterEnvValues, entry.env[0]) && !refreshedAfterObservedAbsence) { // OpenShell 0.0.106 polls provider state every ten seconds. First prove // the pre-republish generation is installed, then republish while the @@ -469,15 +515,19 @@ async function addMcpBridgeUnlocked( allowExisting: true, expectedProviderId: entry.providerId, requireExisting: true, + runtimeSelection: providerRuntimeSelection, }); - credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { - previousRevision: credentialRevision, - }); + credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { previousRevision: credentialRevision }, + ); } // The adapter was proven absent above, so cleanup is safe even when a // command commits config and then fails during its runtime reload. adapterMutationAttempted = true; - registerAgentAdapter(sandboxName, adapter, entry, adapterEnvValues, { + registerAgentAdapter(sandboxName, adapter, entry, providerRuntimeSelection, adapterEnvValues, { // An exact adapter entry is evidence of a post-commit process death. // Replacing it is idempotent and, for Hermes, re-verifies runtime reload. // The wait above already proved the same revision stable in consecutive @@ -486,43 +536,57 @@ async function addMcpBridgeUnlocked( replaceExisting: resumingPreflightedAdd && adapterInspection.state === "registered", credentialRevision, }); - if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + if (adapter === "hermes-config") { + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } const { addState: _completedAddState, ...committedEntry } = entry; writeBridgeEntry(sandboxName, committedEntry); } catch (error) { const rollbackProviderInspection = (providerAttachAttempted || providerCreated) && entry.providerId - ? inspectMcpProvider(providerName) + ? inspectMcpProvider(providerName, providerRuntimeSelection) : undefined; const rollbackProviderOwned = !!rollbackProviderInspection && providerMatchesCredential(rollbackProviderInspection, entry.env[0], entry.providerId); if (adapterMutationAttempted) { - unregisterAgentAdapter(sandboxName, adapter, entry, { + unregisterAgentAdapter(sandboxName, adapter, entry, providerRuntimeSelection, { force: false, bestEffort: true, envValues: adapterEnvValues, }); } if (policyApplied) { - removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); + removeGeneratedPolicy(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); } const detachOutcome = providerAttachAttempted - ? detachProvider(sandboxName, entry, { bestEffort: true }) + ? detachProvider(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }) : "absent"; let reservationCleanupProved = !providerAttachAttempted; if (providerAttachAttempted && detachOutcome !== "unknown") { try { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); reservationCleanupProved = true; } catch { reservationCleanupProved = false; } } if (providerCreated && rollbackProviderOwned && reservationCleanupProved) { - const beforeDelete = inspectMcpProvider(providerName); + const beforeDelete = inspectMcpProvider(providerName, providerRuntimeSelection); if (providerMatchesCredential(beforeDelete, entry.env[0], entry.providerId)) { - deleteProvider(entry, { allowMissing: true, bestEffort: true }); + deleteProvider(entry, { + allowMissing: true, + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); } } // Exception rollback is best-effort and process death skips it entirely. @@ -530,4 +594,5 @@ async function addMcpBridgeUnlocked( // proves and cleans each exact resource. throw error; } + return providerRuntimeSelection; } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-forced-scrub.test.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-forced-scrub.test.ts index 7794b74bc3e..2110122699e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy-forced-scrub.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-forced-scrub.test.ts @@ -11,9 +11,14 @@ const mocks = vi.hoisted(() => ({ assertMcpDestroySnapshotCurrent: vi.fn(), detachProvider: vi.fn(), discardSafeIncompleteMcpAdds: vi.fn(), + getMcpProviderInspectionRuntimeSelection: vi.fn(), getSandboxOrThrow: vi.fn(), removeGeneratedPolicy: vi.fn(), restoreExistingMcpBridgeRuntime: vi.fn(), + runtimeSelection: { + gatewayName: "nemoclaw-19080", + workspace: "default", + }, scrubManagedMcpAdapterOrThrow: vi.fn(), updateSandbox: vi.fn(), waitForDetachedMcpCredential: vi.fn(), @@ -45,6 +50,12 @@ const PENDING_SANDBOX = { ...SANDBOX, mcp: { bridges: { github: ENTRY }, destroyPendingAt: "2026-08-27T00:00:00Z" }, }; +const PREPARED_ADD_ENTRY = { ...ENTRY, addState: "prepared" as const }; +const PREPARED_ADD_SANDBOX = { + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: PREPARED_ADD_ENTRY } }, +}; vi.mock("./mcp-bridge-adapter-teardown", () => ({ rollbackScrubbedMcpAdapters: vi.fn(() => []), @@ -66,6 +77,7 @@ vi.mock("./mcp-bridge-destroy-preflight", () => ({ vi.mock("./mcp-bridge-provider", () => ({ deleteProvider: vi.fn(), detachProvider: mocks.detachProvider, + getMcpProviderInspectionRuntimeSelection: mocks.getMcpProviderInspectionRuntimeSelection, inspectMcpProvider: vi.fn(() => ({ exists: false })), waitForDetachedMcpCredential: mocks.waitForDetachedMcpCredential, })); @@ -120,9 +132,31 @@ describe("prepareMcpBridgesForDestroy adapter-scrub refusal", () => { mocks.waitForDetachedMcpCredential.mockReset(); mocks.updateSandbox.mockReset().mockReturnValue(SANDBOX); mocks.getSandboxOrThrow.mockReset().mockReturnValue(SANDBOX); + mocks.getMcpProviderInspectionRuntimeSelection + .mockReset() + .mockReturnValue(mocks.runtimeSelection); mocks.discardSafeIncompleteMcpAdds.mockReset().mockResolvedValue(SANDBOX); }); + it("cancels a prepared-only add before resolving runtime authority (#10514)", async () => { + mocks.getSandboxOrThrow.mockReturnValue(PREPARED_ADD_SANDBOX); + mocks.discardSafeIncompleteMcpAdds.mockResolvedValue({ name: "alpha", agent: "openclaw" }); + mocks.getMcpProviderInspectionRuntimeSelection.mockImplementation(() => { + throw new Error("runtime selection resolved for prepared-only destroy cleanup"); + }); + + await expect(prepareMcpBridgesForDestroy("alpha")).resolves.toMatchObject({ + entries: [], + runtimeSelection: undefined, + }); + + expect(mocks.getMcpProviderInspectionRuntimeSelection).not.toHaveBeenCalled(); + expect(mocks.assertMcpAdapterConfigMutationsAllowed).not.toHaveBeenCalled(); + expect(mocks.discardSafeIncompleteMcpAdds).toHaveBeenCalledWith("alpha", PREPARED_ADD_SANDBOX, { + runtimeSelection: undefined, + }); + }); + it("rethrows a config-mutation refusal without --force and touches nothing", async () => { mocks.assertMcpAdapterConfigMutationsAllowed.mockImplementation(() => { throw new Error(SHIELDS_REFUSAL); @@ -138,6 +172,13 @@ describe("prepareMcpBridgesForDestroy adapter-scrub refusal", () => { throw new Error(SHIELDS_REFUSAL); }); const preparation = await prepareMcpBridgesForDestroy("alpha", { force: true }); + expect(preparation.runtimeSelection).toEqual(mocks.runtimeSelection); + expect(mocks.assertMcpAdapterConfigMutationsAllowed).toHaveBeenCalledWith( + "alpha", + SANDBOX, + [ENTRY], + mocks.runtimeSelection, + ); expect(preparation.adapterScrubSkipped).toBe(true); expect(preparation.scrubbedAdapterEntries).toEqual([]); expect(preparation.detachedProviderEntries).toEqual([]); @@ -205,6 +246,19 @@ describe("prepareMcpBridgesForDestroy adapter-scrub refusal", () => { // Regression lock: --force must not silently degrade a destroy that can // still clean the retained volume properly. const preparation = await prepareMcpBridgesForDestroy("alpha", { force: true }); + expect(preparation.runtimeSelection).toEqual(mocks.runtimeSelection); + expect(mocks.assertMcpAdapterConfigMutationsAllowed).toHaveBeenCalledWith( + "alpha", + SANDBOX, + [ENTRY], + mocks.runtimeSelection, + ); + expect(mocks.assertMcpAdapterTeardownRuntimeCapabilities).toHaveBeenCalledWith( + "alpha", + SANDBOX, + [ENTRY], + mocks.runtimeSelection, + ); expect(preparation.adapterScrubSkipped).toBeUndefined(); expect(mocks.scrubManagedMcpAdapterOrThrow).toHaveBeenCalledTimes(1); expect(preparation.scrubbedAdapterEntries).toHaveLength(1); diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts new file mode 100644 index 00000000000..243041fa06a --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; + +const mocks = vi.hoisted(() => ({ + assertGeneratedPolicyRegistrationMutationSafe: vi.fn(), + ensureSandboxGatewaySelected: vi.fn(), + getMcpProviderInspectionRuntimeSelection: vi.fn(), + getSandboxOrThrow: vi.fn(), + inspectMcpProvider: vi.fn(), + removeGeneratedPolicy: vi.fn(), + setBridgeState: vi.fn(), +})); + +vi.mock("./mcp-bridge-policy", () => ({ + assertGeneratedPolicyRegistrationMutationSafe: + mocks.assertGeneratedPolicyRegistrationMutationSafe, + removeGeneratedPolicy: mocks.removeGeneratedPolicy, +})); + +vi.mock("./mcp-bridge-provider", () => ({ + getMcpProviderInspectionRuntimeSelection: mocks.getMcpProviderInspectionRuntimeSelection, + inspectMcpProvider: mocks.inspectMcpProvider, + providerMatchesManagedCredential: vi.fn(), + providerShapeDetail: vi.fn(), +})); + +vi.mock("./mcp-bridge-state", () => ({ + bridgeState: (sandbox: SandboxEntry) => sandbox.mcp?.bridges ?? {}, + ensureSandboxGatewaySelected: mocks.ensureSandboxGatewaySelected, + getSandboxOrThrow: mocks.getSandboxOrThrow, + setBridgeState: mocks.setBridgeState, +})); + +vi.mock("./mcp-bridge-validation", () => ({ + assertAuthenticatedBridgeEntry: vi.fn(), + validateSandboxName: vi.fn(), +})); + +import { discardSafeIncompleteMcpAdds } from "./mcp-bridge-destroy-preflight"; + +const preparedEntry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + policyName: "mcp-bridge-github", + addedAt: "2026-09-01T00:00:00.000Z", + addState: "prepared", +}; + +describe("incomplete MCP add discard", () => { + beforeEach(() => { + mocks.assertGeneratedPolicyRegistrationMutationSafe.mockReset(); + mocks.ensureSandboxGatewaySelected.mockReset(); + mocks.getMcpProviderInspectionRuntimeSelection.mockReset().mockImplementation(() => { + throw new Error("runtime selection resolved for a prepared-only discard"); + }); + mocks.getSandboxOrThrow.mockReset().mockReturnValue({ + name: "alpha", + agent: "openclaw", + }); + mocks.inspectMcpProvider.mockReset(); + mocks.removeGeneratedPolicy.mockReset(); + mocks.setBridgeState.mockReset(); + }); + + it("drops a prepared-only manifest before resolving runtime authority (#10514)", async () => { + const sandbox: SandboxEntry = { + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: preparedEntry } }, + }; + + await expect(discardSafeIncompleteMcpAdds("alpha", sandbox)).resolves.toEqual({ + name: "alpha", + agent: "openclaw", + }); + + expect(mocks.setBridgeState).toHaveBeenCalledWith("alpha", {}); + expect(mocks.getMcpProviderInspectionRuntimeSelection).not.toHaveBeenCalled(); + expect(mocks.ensureSandboxGatewaySelected).not.toHaveBeenCalled(); + expect(mocks.inspectMcpProvider).not.toHaveBeenCalled(); + expect(mocks.removeGeneratedPolicy).not.toHaveBeenCalled(); + expect(mocks.assertGeneratedPolicyRegistrationMutationSafe).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts index b2570134d11..4a224e298de 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts @@ -10,7 +10,9 @@ import { removeGeneratedPolicy, } from "./mcp-bridge-policy"; import { + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, providerMatchesManagedCredential, providerShapeDetail, @@ -31,6 +33,8 @@ export interface McpDestroyPreparation { destroyAlreadyPrepared: boolean; /** True when a previous destroy already confirmed the sandbox was absent. */ destroyAlreadyPending: boolean; + /** One authority-derived OpenShell target frozen for this destroy attempt. */ + runtimeSelection?: McpProviderInspectionRuntimeSelection; /** True when `--force` continued without scrubbing the retained-volume adapter entry. */ adapterScrubSkipped?: true; } @@ -66,20 +70,29 @@ function mcpBridgeEntriesEqual(left: McpBridgeEntry, right: McpBridgeEntry): boo export async function discardSafeIncompleteMcpAdds( sandboxName: string, sandbox: SandboxEntry, - options: { sandboxAbsent?: boolean } = {}, + options: { + runtimeSelection?: McpProviderInspectionRuntimeSelection; + sandboxAbsent?: boolean; + } = {}, ): Promise { const bridges = bridgeState(sandbox); const providerlessCandidates = Object.values(bridges).filter( (entry) => entry.addState === "preflighted" && !entry.providerId, ); - if (providerlessCandidates.length > 0) await ensureSandboxGatewaySelected(sandboxName); + const providerRuntimeSelection = + providerlessCandidates.length > 0 + ? (options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox)) + : undefined; + if (providerRuntimeSelection) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const remainingEntries: Array<[string, McpBridgeEntry]> = []; const providerlessPreflighted: McpBridgeEntry[] = []; for (const [server, entry] of Object.entries(bridges)) { if (entry.addState === "prepared") continue; if (entry.addState === "preflighted" && !entry.providerId) { assertAuthenticatedBridgeEntry(entry); - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection!); if (inspection.exists === false) { providerlessPreflighted.push(entry); continue; @@ -93,7 +106,9 @@ export async function discardSafeIncompleteMcpAdds( if (options.sandboxAbsent) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } else { - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection!, + }); } } // A prepared add precedes all external side effects, so destroy drops only @@ -124,7 +139,11 @@ export function assertMcpDestroySnapshotCurrent( export function inspectExactMcpDestroyProvider( entry: McpBridgeEntry, - options: { allowMissing: boolean; force?: boolean }, + options: { + allowMissing: boolean; + force?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): McpProviderInspection { assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { @@ -132,7 +151,7 @@ export function inspectExactMcpDestroyProvider( `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing destructive cleanup of same-name provider '${entry.providerName}'. Remove the legacy bridge with --force only after independently cleaning that provider.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${entry.providerName}'.`, @@ -162,17 +181,36 @@ export function inspectExactMcpDestroyProvider( /** Build cleanup state after a gateway-pinned list proves the sandbox absent. */ export async function prepareMcpBridgesForAbsentSandboxDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; + } = {}, ): Promise { validateSandboxName(sandboxName); - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, getSandboxOrThrow(sandboxName), { + const currentSandbox = getSandboxOrThrow(sandboxName); + const entriesRequiringExternalCleanup = Object.values(bridgeState(currentSandbox)).filter( + (entry) => entry.addState !== "prepared", + ); + let providerRuntimeSelection = options.runtimeSelection; + if (entriesRequiringExternalCleanup.length > 0) { + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(currentSandbox); + } + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + runtimeSelection: providerRuntimeSelection, sandboxAbsent: true, }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; + if (entries.length > 0) { + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(sandbox); + } for (const entry of entries) { - inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force }); + inspectExactMcpDestroyProvider(entry, { + allowMissing: true, + force: options.force, + runtimeSelection: providerRuntimeSelection!, + }); } return { entries, @@ -180,5 +218,6 @@ export async function prepareMcpBridgesForAbsentSandboxDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }; } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index c53a6105c49..fd757fc34d2 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -21,6 +21,7 @@ import { import { deleteProvider, detachProvider, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; @@ -107,7 +108,10 @@ function classifyForcedAdapterScrubRefusal( */ export async function prepareMcpBridgesForDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpDestroyPreparation["runtimeSelection"]; + } = {}, ): Promise { const force = options.force === true; validateSandboxName(sandboxName); @@ -115,6 +119,10 @@ export async function prepareMcpBridgesForDestroy( const entriesRequiringExternalCleanup = Object.values(bridgeState(currentSandbox)).filter( (entry) => entry.addState !== "prepared", ); + let providerRuntimeSelection = options.runtimeSelection; + if (entriesRequiringExternalCleanup.length > 0) { + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(currentSandbox); + } // A destroy that already completed phase one, or that reached the // confirmed-delete pending marker, scrubs no adapter entry on this pass. The // posture that legitimately refuses a first attempt must not strand that @@ -125,19 +133,23 @@ export async function prepareMcpBridgesForDestroy( // discardSafeIncompleteMcpAdds, which may remove the generated live policy key for a // providerless preflighted add. That cleanup has no adapter/provider to // probe; complete entries get the teardown runtime probe after retry markers. - let adapterScrubSkipped = adapterScrubAlreadySettled - ? false - : classifyForcedAdapterScrubRefusal( - sandboxName, - () => - assertMcpAdapterConfigMutationsAllowed( - sandboxName, - currentSandbox, - entriesRequiringExternalCleanup, - ), - force && canForceSkipAdapterScrub(currentSandbox, entriesRequiringExternalCleanup), - ); - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox); + let adapterScrubSkipped = + adapterScrubAlreadySettled || entriesRequiringExternalCleanup.length === 0 + ? false + : classifyForcedAdapterScrubRefusal( + sandboxName, + () => + assertMcpAdapterConfigMutationsAllowed( + sandboxName, + currentSandbox, + entriesRequiringExternalCleanup, + providerRuntimeSelection!, + ), + force && canForceSkipAdapterScrub(currentSandbox, entriesRequiringExternalCleanup), + ); + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + runtimeSelection: providerRuntimeSelection, + }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; @@ -154,15 +166,21 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }; } + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(sandbox); + + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + // A pending marker is written only after OpenShell confirmed deletion. On // retry, a provider may therefore already be absent due to partial cleanup; // the retained entries are the durable, idempotent cleanup manifest. for (const entry of entries) { inspectExactMcpDestroyProvider(entry, { allowMissing: destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }); } if (destroyAlreadyPending) { @@ -172,6 +190,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending: true, + runtimeSelection: providerRuntimeSelection, }; } if (destroyAlreadyPrepared) { @@ -184,13 +203,22 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: entries.map(cloneMcpBridgeEntry), destroyAlreadyPrepared: true, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, }; } - await ensureSandboxGatewaySelected(sandboxName); + // The gateway was selected before provider inspection above; preserve that + // selection while allowing --force only for OpenClaw's locked Mcporter + // adapter configuration. adapterScrubSkipped ||= classifyForcedAdapterScrubRefusal( sandboxName, - () => assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries), + () => + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ), force && canForceSkipAdapterScrub(sandbox, entries), ); if (adapterScrubSkipped) { @@ -200,6 +228,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared: false, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, adapterScrubSkipped: true, }; } @@ -208,21 +237,31 @@ export async function prepareMcpBridgesForDestroy( const removedPolicies: McpBridgeEntry[] = []; try { for (const entry of entries) { - scrubbedAdapters.push(scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry)); + scrubbedAdapters.push( + scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry, providerRuntimeSelection), + ); } for (const entry of entries) { - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); removedPolicies.push(entry); } for (const entry of entries) { - inspectExactMcpDestroyProvider(entry, { allowMissing: false }); - const detachOutcome = detachProvider(sandboxName, entry, { allowLegacyGeneric: true }); + inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); + const detachOutcome = detachProvider(sandboxName, entry, { + allowLegacyGeneric: true, + runtimeSelection: providerRuntimeSelection, + }); if (detachOutcome === "unknown") { throw new McpBridgeError( `Could not prove provider detach for MCP server '${entry.server}'.`, ); } - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); // Both an acknowledged detach and a freshly-proven absent binding are // rollback responsibilities until destroyPreparedAt is durable. This // closes retry-after-process-death gaps where an earlier attempt already @@ -252,6 +291,7 @@ export async function prepareMcpBridgesForDestroy( try { await restoreExistingMcpBridgeRuntime(sandboxName, removedPolicies, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (rollbackError) { @@ -261,7 +301,14 @@ export async function prepareMcpBridgesForDestroy( } } if (!runtimeRestored) { - rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); + rollbackFailures.push( + ...rollbackScrubbedMcpAdapters( + sandboxName, + sandbox, + scrubbedAdapters, + providerRuntimeSelection, + ), + ); } const current = registry.getSandbox(sandboxName); if (current?.mcp?.destroyPreparedAt) { @@ -295,6 +342,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: scrubbedAdapters, destroyAlreadyPrepared: false, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, }; } @@ -311,6 +359,8 @@ export async function restoreMcpBridgesAfterDestroyAbort( return; } const preparedSandbox = assertMcpDestroySnapshotCurrent(sandboxName, preparation.entries); + const providerRuntimeSelection = + preparation.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(preparedSandbox); const destroyPreparedAt = preparedSandbox.mcp?.destroyPreparedAt ?? nowIso(); const cleared = registry.updateSandbox(sandboxName, { mcp: { @@ -331,9 +381,13 @@ export async function restoreMcpBridgesAfterDestroyAbort( // Reattach only the exact existing providers. This restoration path never // reads host secret values and therefore cannot rotate preserved credentials. for (const entry of preparation.entries) - inspectExactMcpDestroyProvider(entry, { allowMissing: false }); + inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); await restoreExistingMcpBridgeRuntime(sandboxName, preparation.entries, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); } catch (error) { let markerRestoreFailure = ""; @@ -376,9 +430,10 @@ export async function finalizeMcpBridgesAfterSandboxDelete( const entries = preparation.entries; if (entries.length === 0) return; - await ensureSandboxGatewaySelected(sandboxName); - const sandbox = assertMcpDestroySnapshotCurrent(sandboxName, entries); + const providerRuntimeSelection = + preparation.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); if (!sandbox.mcp?.destroyPendingAt) { const marked = registry.updateSandbox(sandboxName, { mcp: { @@ -406,6 +461,7 @@ export async function finalizeMcpBridgesAfterSandboxDelete( inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force, + runtimeSelection: providerRuntimeSelection, }), ); for (const [index, entry] of entries.entries()) { @@ -413,10 +469,15 @@ export async function finalizeMcpBridgesAfterSandboxDelete( const beforeDelete = inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force, + runtimeSelection: providerRuntimeSelection, }); if (!beforeDelete.exists) continue; - deleteProvider(entry, { allowLegacyGeneric: true, allowMissing: true }); - const after = inspectMcpProvider(entry.providerName); + deleteProvider(entry, { + allowLegacyGeneric: true, + allowMissing: true, + runtimeSelection: providerRuntimeSelection, + }); + const after = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (after.exists !== false) { throw new McpBridgeError( after.error ?? diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts index b485c35c6c1..008ee4c18f0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts @@ -45,6 +45,7 @@ function sandbox(overrides: Partial = {}): SandboxEntry { return { name: "alpha", agent: "hermes", + gatewayName: "nemoclaw-8091", mcp: { bridges: { github: entry }, managedServerNames: ["github", "retired"], @@ -101,7 +102,11 @@ describe("Hermes MCP host reconciliation", () => { absent: ["retired"], }); expect(JSON.stringify(args)).not.toContain("host-only-secret"); - expect(options).toMatchObject({ ignoreError: true, timeout: 60_000 }); + expect(options).toMatchObject({ + ignoreError: true, + runtimeSelection: { gatewayName: "nemoclaw-8091", workspace: "default" }, + timeout: 60_000, + }); }); it("requires the observed credential revision during status reconciliation (#10079)", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts index 901916878f6..3e3c26dc402 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts @@ -11,6 +11,10 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import { + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, +} from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { sleepMcpBridgeRetry } from "./mcp-bridge/timing"; @@ -33,6 +37,7 @@ export interface HermesMcpReconciliationOptions { entries?: readonly McpBridgeEntry[]; managedServerNames?: readonly string[]; credentialRevisions?: ReadonlyMap; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } export function hermesMcpReconciliationRemediationLines(sandboxName: string): readonly string[] { @@ -161,10 +166,13 @@ export function inspectHermesMcpRuntimeIntent( managedServerNames, options.credentialRevisions, ); + const runtimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); let result: ReturnType; try { result = runOpenshellProviderCommand(buildInspectArgs(sandboxName, JSON.stringify(payload)), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], timeout: HERMES_MCP_INSPECT_TIMEOUT_MS, }); diff --git a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts index 67bd2660d55..802948c31d7 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts @@ -209,7 +209,12 @@ replace(processRecovery, "executeSandboxExecCommand", () => ({ stdout: "v1\\n", stderr: "", })); -registry.registerSandbox({ name: "alpha", agent: "openclaw" }); +registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, +}); require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { server: "local", url: "https://mcp.corp.example/mcp", diff --git a/src/lib/actions/sandbox/mcp-bridge-openclaw-shields-config.test.ts b/src/lib/actions/sandbox/mcp-bridge-openclaw-shields-config.test.ts index 6bfc2c84198..25abf690422 100644 --- a/src/lib/actions/sandbox/mcp-bridge-openclaw-shields-config.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-openclaw-shields-config.test.ts @@ -30,6 +30,11 @@ import { } from "./mcp-bridge-adapters"; import { assertOpenClawMcpConfigMutationAllowed } from "./mcp-bridge-adapter-openclaw"; +const runtimeSelection = { + gatewayName: "nemoclaw-19080", + workspace: "default", +}; + // #10469: Mcporter's managed project config is // `/sandbox/.openclaw/workspace/config/mcporter.json`, and `workspace` is a // `readOnlyRoots` entry in `agents/openclaw/state-lock-plan.json`. With shields @@ -44,21 +49,21 @@ describe("OpenClaw MCP config mutation posture (#10469)", () => { it("allows a mutation while the config is mutable", () => { mocks.isShieldsDown.mockReturnValue(true); - expect(() => assertOpenClawMcpConfigMutationAllowed("alpha")).not.toThrow(); - expect(mocks.isShieldsDown).toHaveBeenCalledWith("alpha", false); + expect(() => assertOpenClawMcpConfigMutationAllowed("alpha", runtimeSelection)).not.toThrow(); + expect(mocks.isShieldsDown).toHaveBeenCalledWith("alpha", false, runtimeSelection); }); it("refuses a mutation while Shields are up and keeps the posture probe read-only (#10469)", () => { mocks.isShieldsDown.mockReturnValue(false); - expect(() => assertOpenClawMcpConfigMutationAllowed("alpha")).toThrow( + expect(() => assertOpenClawMcpConfigMutationAllowed("alpha", runtimeSelection)).toThrow( /shields up or an unreadable shields posture/, ); - expect(() => assertOpenClawMcpConfigMutationAllowed("alpha")).toThrow( + expect(() => assertOpenClawMcpConfigMutationAllowed("alpha", runtimeSelection)).toThrow( '`nemoclaw alpha shields down --timeout 15m --reason "MCP maintenance"`', ); // `false` keeps the probe read-only: a mutation preflight must never repair // the posture it is inspecting. - expect(mocks.isShieldsDown).toHaveBeenCalledWith("alpha", false); + expect(mocks.isShieldsDown).toHaveBeenCalledWith("alpha", false, runtimeSelection); }); }); @@ -69,19 +74,25 @@ describe("assertAgentMcpConfigMutationAllowed adapter routing", () => { it("routes the mcporter adapter through the OpenClaw posture check", () => { mocks.isShieldsDown.mockReturnValue(false); - expect(() => assertAgentMcpConfigMutationAllowed("alpha", "mcporter")).toThrow( + expect(() => + assertAgentMcpConfigMutationAllowed("alpha", "mcporter", runtimeSelection), + ).toThrow( /OpenClaw sandbox 'alpha' has shields up/, ); }); it("lets the mcporter adapter through once the config is mutable", () => { mocks.isShieldsDown.mockReturnValue(true); - expect(() => assertAgentMcpConfigMutationAllowed("alpha", "mcporter")).not.toThrow(); + expect(() => + assertAgentMcpConfigMutationAllowed("alpha", "mcporter", runtimeSelection), + ).not.toThrow(); }); it("keeps the Hermes adapter on its own posture check", () => { mocks.isShieldsDown.mockReturnValue(false); - expect(() => assertAgentMcpConfigMutationAllowed("alpha", "hermes-config")).toThrow( + expect(() => + assertAgentMcpConfigMutationAllowed("alpha", "hermes-config", runtimeSelection), + ).toThrow( /Hermes sandbox 'alpha' has shields up/, ); }); @@ -92,7 +103,9 @@ describe("assertAgentMcpConfigMutationAllowed adapter routing", () => { // refuses there as well. `--force` does not bypass it: the ownership state // is preserved for a retry once shields are down. mocks.isShieldsDown.mockReturnValue(false); - expect(() => assertAgentMcpTeardownRuntimeCapability("alpha", "mcporter")).toThrow( + expect(() => + assertAgentMcpTeardownRuntimeCapability("alpha", "mcporter", runtimeSelection), + ).toThrow( /OpenClaw sandbox 'alpha' has shields up/, ); }); @@ -102,7 +115,9 @@ describe("assertAgentMcpConfigMutationAllowed adapter routing", () => { // any recursive state lock — the agent ships no `state-lock-plan.json` — and // teardown of a legacy entry must stay possible on an older image. mocks.isShieldsDown.mockReturnValue(false); - expect(() => assertAgentMcpConfigMutationAllowed("alpha", "deepagents-config")).not.toThrow(); + expect(() => + assertAgentMcpConfigMutationAllowed("alpha", "deepagents-config", runtimeSelection), + ).not.toThrow(); expect(mocks.isShieldsDown).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts index 0d0b08cf22e..dd40f939ed0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts @@ -29,6 +29,10 @@ const entry: McpBridgeEntry = { policyName: buildMcpBridgePolicyName("github"), addedAt: "2026-08-27T00:00:00.000Z", }; +const runtimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", +}; beforeEach(() => vi.restoreAllMocks()); @@ -44,7 +48,7 @@ describe("generated MCP policy", () => { it("applies directly to live OpenShell policy without a custom-policy registry row", () => { const livePolicy: { network_policies: Record } = { network_policies: {} }; - vi.spyOn(policies, "applyPresetContent").mockImplementation( + const applySpy = vi.spyOn(policies, "applyPresetContent").mockImplementation( (_sandboxName, _presetName, content) => { Object.assign( livePolicy.network_policies, @@ -53,7 +57,7 @@ describe("generated MCP policy", () => { return true; }, ); - vi.spyOn(policies, "getPresetContentGatewayState").mockImplementation( + const stateSpy = vi.spyOn(policies, "getPresetContentGatewayState").mockImplementation( (_sandboxName, content) => { const expected = (YAML.parse(content) as typeof livePolicy).network_policies; return Object.keys(expected).every((key) => key in livePolicy.network_policies) @@ -62,7 +66,12 @@ describe("generated MCP policy", () => { }, ); - applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }); + applyGeneratedPolicy( + "alpha", + entry, + { addresses: ["8.8.8.8"] }, + { runtimeSelection }, + ); expect(livePolicy.network_policies.mcp_bridge_github).toMatchObject({ endpoints: [ @@ -72,6 +81,18 @@ describe("generated MCP policy", () => { }), ], }); + expect(applySpy).toHaveBeenCalledWith( + "alpha", + entry.policyName, + expect.any(String), + expect.objectContaining({ runtimeSelection }), + ); + expect(stateSpy).toHaveBeenCalledWith( + "alpha", + expect.any(String), + undefined, + runtimeSelection, + ); }); it("removes generated content from the live policy", () => { @@ -88,7 +109,7 @@ describe("generated MCP policy", () => { ).network_policies.mcp_bridge_github, }, }; - vi.spyOn(policies, "removePreset").mockImplementation( + const removeSpy = vi.spyOn(policies, "removePreset").mockImplementation( (_sandboxName, _presetName, options) => { const removal = (YAML.parse(options?.presetContent ?? "") as typeof livePolicy) .network_policies; @@ -98,14 +119,24 @@ describe("generated MCP policy", () => { }, ); - removeGeneratedPolicy("alpha", entry); + removeGeneratedPolicy("alpha", entry, { runtimeSelection }); expect(livePolicy.network_policies).not.toHaveProperty("mcp_bridge_github"); + expect(removeSpy).toHaveBeenCalledWith( + "alpha", + entry.policyName, + expect.objectContaining({ runtimeSelection }), + ); }); it("refuses generated policy without exact public address pins", () => { expect(() => - applyGeneratedPolicy("alpha", { ...entry, allowedIps: [] }, { addresses: [] }), + applyGeneratedPolicy( + "alpha", + { ...entry, allowedIps: [] }, + { addresses: [] }, + { runtimeSelection }, + ), ).toThrow(/without exact public address pins/); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index 741216e43e4..db093789623 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -21,6 +21,7 @@ import { buildMcpBridgePolicyName, buildMcpBridgePolicyYaml, } from "./mcp-bridge-policy-render"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpBridgeTargetValidation } from "./mcp-bridge-url-validation"; export { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; @@ -36,7 +37,10 @@ export function applyGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, target: McpBridgeTargetValidation, - options: { bindCredential?: boolean } = {}, + options: { + bindCredential?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { const addresses = assertMcpBridgePolicyTarget(entry, target); if (addresses.length === 0) { @@ -58,8 +62,14 @@ export function applyGeneratedPolicy( if ( !policies.applyPresetContent(sandboxName, entry.policyName, content, { nonFatal: true, + runtimeSelection: options.runtimeSelection, }) || - policies.getPresetContentGatewayState(sandboxName, content) !== "match" + policies.getPresetContentGatewayState( + sandboxName, + content, + undefined, + options.runtimeSelection, + ) !== "match" ) { throw new McpBridgeError(`Failed to activate generated MCP policy '${entry.policyName}'.`); } @@ -161,13 +171,17 @@ export function assertGeneratedPolicyRegistrationMutationSafe( export function removeGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, - options: { bestEffort?: boolean } = {}, + options: { + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { const policyKey = buildMcpBridgePolicyKey(entry.server); const content = `network_policies:\n ${policyKey}: {}\n`; const removed = policies.removePreset(sandboxName, entry.policyName, { nonFatal: true, presetContent: content, + runtimeSelection: options.runtimeSelection, }); if (removed) return; if (options.bestEffort) return; @@ -193,9 +207,15 @@ export function getRegisteredGeneratedPolicy( export function getPolicyPresence( sandboxName: string, entry: McpBridgeEntry | undefined, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): boolean | null { const registered = getRegisteredGeneratedPolicy(sandboxName, entry); if (!registered) return entry ? null : false; - const state = policies.getPresetContentGatewayState(sandboxName, registered.content); + const state = policies.getPresetContentGatewayState( + sandboxName, + registered.content, + undefined, + runtimeSelection, + ); return state === "match" ? true : state === "absent" ? false : null; } diff --git a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts index 5feea6740e6..b61fcad9602 100644 --- a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts @@ -112,6 +112,8 @@ const registry = require("./src/lib/state/registry.js"); registry.registerSandbox({ name: "alpha", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { local: { server: "local", agent: "openclaw", diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts b/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts index eefa0691d02..48c093c4c71 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts @@ -17,6 +17,7 @@ import { inspectMcpProviderAttachments, type McpProviderAttachment, type McpProviderAttachmentInspection, + type McpProviderInspectionRuntimeSelection, providerMatchesCredential, providerMatchesManagedCredential, providerShapeDetail, @@ -29,8 +30,9 @@ import { function exactAttachment( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): { inspection: McpProviderAttachmentInspection; attachment?: McpProviderAttachment } { - const inspection = inspectMcpProviderAttachments(sandboxName); + const inspection = inspectMcpProviderAttachments(sandboxName, runtimeSelection); return { inspection, attachment: inspection.attachments?.find( @@ -52,7 +54,11 @@ function attachmentMatchesCurrentProviderSnapshot( ); } -export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void { +export function attachProvider( + sandboxName: string, + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { if (!entry.providerName) return; assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { @@ -60,7 +66,7 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing to attach same-name provider '${entry.providerName}'.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, runtimeSelection); if (inspection.exists === false) { throw new McpBridgeError( `OpenShell provider '${entry.providerName}' disappeared before attach.`, @@ -76,11 +82,11 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void } const result = runOpenshellProviderCommand( ["sandbox", "provider", "attach", sandboxName, entry.providerName], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + { ignoreError: true, runtimeSelection, stdio: ["ignore", "pipe", "pipe"] }, ) as OpenShellCommandResult; if (result.status !== 0) { const output = commandOutput(result); - const afterError = exactAttachment(sandboxName, entry); + const afterError = exactAttachment(sandboxName, entry, runtimeSelection); if (attachmentMatchesCurrentProviderSnapshot(afterError.attachment, entry)) return; throw new McpBridgeError( output || @@ -88,7 +94,7 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void `Failed to attach MCP provider '${entry.providerName}'.`, ); } - const after = exactAttachment(sandboxName, entry); + const after = exactAttachment(sandboxName, entry, runtimeSelection); if (!attachmentMatchesCurrentProviderSnapshot(after.attachment, entry)) { throw new McpBridgeError( after.inspection.error ?? @@ -120,7 +126,11 @@ function isRetryableSandboxMutationConflict(status: number | null, output: strin export function detachProvider( sandboxName: string, entry: McpBridgeEntry, - options: { allowLegacyGeneric?: boolean; bestEffort?: boolean } = {}, + options: { + allowLegacyGeneric?: boolean; + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): ProviderDetachOutcome { if (!entry.providerName) return "absent"; assertPersistedAuthenticatedBridgeEntry(entry); @@ -131,7 +141,7 @@ export function detachProvider( ); } for (let attempt = 0; attempt < MCP_PROVIDER_DETACH_ATTEMPTS; attempt += 1) { - const provider = inspectMcpProvider(entry.providerName); + const provider = inspectMcpProvider(entry.providerName, options.runtimeSelection); if ( !providerMatchesManagedCredential(provider, entry.env[0], entry.providerId, { allowLegacyGeneric: options.allowLegacyGeneric, @@ -142,7 +152,7 @@ export function detachProvider( `OpenShell provider '${entry.providerName}' changed before detach. ${providerShapeDetail(provider, entry.env[0], entry.providerId)} Refusing to mutate it.`, ); } - const before = exactAttachment(sandboxName, entry); + const before = exactAttachment(sandboxName, entry, options.runtimeSelection); if (!before.inspection.attachments) { if (options.bestEffort) return "unknown"; throw new McpBridgeError( @@ -160,12 +170,13 @@ export function detachProvider( ["sandbox", "provider", "detach", sandboxName, entry.providerName], { ignoreError: true, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], suppressOutput: true, } as Record, ) as OpenShellCommandResult; const output = commandOutput(result); - const after = exactAttachment(sandboxName, entry); + const after = exactAttachment(sandboxName, entry, options.runtimeSelection); if (after.inspection.attachments && !after.attachment) { return providerDetachChangedState(result.status, output) ? "detached" : "absent"; } @@ -196,10 +207,11 @@ export function detachProvider( export function detachMissingProviderReference( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): ProviderDetachOutcome { if (!entry.providerName) return "absent"; assertPersistedAuthenticatedBridgeEntry(entry); - const before = inspectMcpProvider(entry.providerName); + const before = inspectMcpProvider(entry.providerName, runtimeSelection); if (before.exists !== false) { const detail = before.exists === null @@ -211,7 +223,7 @@ export function detachMissingProviderReference( } const result = runOpenshellProviderCommand( ["sandbox", "provider", "detach", sandboxName, entry.providerName], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + { ignoreError: true, runtimeSelection, stdio: ["ignore", "pipe", "pipe"] }, ) as OpenShellCommandResult; const output = commandOutput(result); if (result.status !== 0) { @@ -219,7 +231,7 @@ export function detachMissingProviderReference( output || `Failed to remove dangling provider reference '${entry.providerName}'.`, ); } - const afterProvider = inspectMcpProvider(entry.providerName); + const afterProvider = inspectMcpProvider(entry.providerName, runtimeSelection); if (afterProvider.exists !== false) { throw new McpBridgeError( afterProvider.error ?? diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts new file mode 100644 index 00000000000..419000f2547 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts @@ -0,0 +1,212 @@ +// 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 { afterEach, describe, expect, it, vi } from "vitest"; + +import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; +import { + getMcpProviderInspectionRuntimeSelection, + inspectMcpProvider, +} from "./mcp-bridge-provider-inspection"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + setProviderCommandRuntimeHooksForTest({}); + vi.unstubAllEnvs(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +function temporaryDirectory(prefix: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +function writeClientTlsBundle(localTlsDir: string): void { + fs.mkdirSync(path.join(localTlsDir, "client"), { recursive: true }); + fs.writeFileSync(path.join(localTlsDir, "ca.crt"), "ca"); + fs.writeFileSync(path.join(localTlsDir, "client", "tls.crt"), "cert"); + fs.writeFileSync(path.join(localTlsDir, "client", "tls.key"), "key"); +} + +function writeExternalGatewayDeclaration(home: string, endpoint: string, stateDir: string): string { + const declarationPath = path.join(home, "gateway-management.json"); + fs.writeFileSync( + declarationPath, + JSON.stringify({ + version: 1, + mode: "externally-supervised", + endpoint, + stateDir, + supervisor: { + kind: "systemd-user", + serviceName: "openshell-gateway.service", + execPath: "/usr/bin/openshell-gateway", + }, + requiredCapabilities: [], + }), + ); + return declarationPath; +} + +describe("MCP provider runtime selection", () => { + it("binds a nondefault managed gateway to its own client TLS directory (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + vi.stubEnv("HOME", home); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/tmp/ambient-gateway-tls"); + const localTlsDir = path.join( + home, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway-8091", + "tls", + ); + writeClientTlsBundle(localTlsDir); + + expect( + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toEqual({ + gatewayName: "nemoclaw-8091", + localTlsDir, + workspace: "default", + }); + }); + + it("uses the declared state directory for an external HTTPS gateway (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + const stateDir = path.join(home, "external-gateway"); + const localTlsDir = path.join(stateDir, "tls"); + writeClientTlsBundle(localTlsDir); + const declarationPath = writeExternalGatewayDeclaration( + home, + "https://127.0.0.1:8091", + stateDir, + ); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", declarationPath); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/tmp/ambient-gateway-tls"); + + expect( + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toEqual({ + gatewayName: "nemoclaw-8091", + localTlsDir, + workspace: "default", + }); + }); + + it("omits client TLS for an external HTTP gateway (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + const stateDir = path.join(home, "external-gateway"); + const declarationPath = writeExternalGatewayDeclaration( + home, + "http://127.0.0.1:8091", + stateDir, + ); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", declarationPath); + + expect( + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toEqual({ gatewayName: "nemoclaw-8091", workspace: "default" }); + }); + + it("refuses an incomplete external HTTPS client bundle (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + const stateDir = path.join(home, "external-gateway"); + const localTlsDir = path.join(stateDir, "tls"); + writeClientTlsBundle(localTlsDir); + fs.rmSync(path.join(localTlsDir, "client", "tls.key")); + const declarationPath = writeExternalGatewayDeclaration( + home, + "https://127.0.0.1:8091", + stateDir, + ); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", declarationPath); + + expect(() => + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toThrow("client/tls.key"); + }); +}); + +describe("MCP provider absence inspection", () => { + it("accepts only an exact provider-specific absence diagnostic (#10514)", () => { + setProviderCommandRuntimeHooksForTest({ + runOpenshell: (() => ({ + status: 1, + stdout: "", + stderr: "provider 'alpha-mcp-fake' not found", + })) as never, + }); + + expect(inspectMcpProvider("alpha-mcp-fake")).toEqual({ + exists: false, + id: null, + resourceVersion: null, + type: null, + credentialKeys: null, + }); + }); + + it.each([ + "NotFound", + "NotFound: provider", + "provider 'other-mcp-fake' not found", + 'status: NotFound, message: "gateway not found"', + "workspace 'default' does not exist", + "transport unavailable", + ])("keeps ambiguous lookup failure indeterminate: %s (#10514)", (diagnostic) => { + setProviderCommandRuntimeHooksForTest({ + runOpenshell: (() => ({ status: 1, stdout: "", stderr: diagnostic })) as never, + }); + + expect(inspectMcpProvider("alpha-mcp-fake")).toMatchObject({ + exists: null, + error: diagnostic, + }); + }); + + it.each([null, 2])( + "keeps exact-looking absence indeterminate for noncanonical exit %s (#10514)", + (status) => { + setProviderCommandRuntimeHooksForTest({ + runOpenshell: (() => ({ + status, + stdout: "", + stderr: "provider 'alpha-mcp-fake' not found", + })) as never, + }); + + expect(inspectMcpProvider("alpha-mcp-fake")).toMatchObject({ + exists: null, + error: "provider 'alpha-mcp-fake' not found", + }); + }, + ); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 0cbe4c3f04f..27e94b489d5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -1,10 +1,28 @@ // 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 { stripAnsi } from "../../adapters/openshell/client"; -import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { + type OpenShellRuntimeSelection, + runOpenshellProviderCommand, +} from "../../adapters/openshell/provider-command"; +import { OPENSHELL_DEFAULT_WORKSPACE } from "../../adapters/openshell/sandbox-ssh-host"; +import { getDockerDriverGatewayLocalTlsDir } from "../../onboard/docker-driver-gateway-local-tls"; +import { reportsExactProviderNotFound } from "../../onboard/extra-provider-diagnostic-parser"; +import { resolveGatewayStateDirForPort } from "../../onboard/gateway/state-dir"; +import { resolveGatewayCredentialMutationAuthority } from "../../onboard/gateway-teardown-authority"; +import { + evaluateGatewayAttachmentConfiguration, + isExternallySupervised, + type GatewayOwner, +} from "../../onboard/gateway-ownership"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; -import { listExtraProviders, type McpBridgeEntry } from "../../state/registry"; +import { listExtraProviders, type McpBridgeEntry, type SandboxEntry } from "../../state/registry"; +import { getPersistedSandboxTargetGateway } from "./gateway-target"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output"; import type { McpBridgeTargetValidation } from "./mcp-bridge-url-validation"; @@ -36,6 +54,71 @@ export type McpProviderAttachmentInspection = { error?: string; }; +export type McpProviderInspectionRuntimeSelection = OpenShellRuntimeSelection; + +const GATEWAY_CLIENT_TLS_FILES = ["ca.crt", "client/tls.crt", "client/tls.key"] as const; + +function readableGatewayClientTlsDir(localTlsDir: string, required: boolean): string | undefined { + const observations = GATEWAY_CLIENT_TLS_FILES.map((relativePath) => { + const filePath = path.join(localTlsDir, relativePath); + try { + if (!fs.statSync(filePath).isFile()) throw new Error("not a file"); + fs.accessSync(filePath, fs.constants.R_OK); + return { filePath, readable: true }; + } catch { + return { filePath, readable: false }; + } + }); + if (observations.every(({ readable }) => readable)) return localTlsDir; + if (!required && observations.every(({ filePath }) => !fs.existsSync(filePath))) { + return undefined; + } + const unreadable = observations.find(({ readable }) => !readable)?.filePath ?? localTlsDir; + throw new McpBridgeError( + `OpenShell gateway TLS file is missing or unreadable: ${unreadable}`, + 1, + ); +} + +function providerRuntimeLocalTlsDir( + owner: GatewayOwner, + selectedInProcess: boolean, +): string | undefined { + if (isExternallySupervised(owner)) { + const configuration = evaluateGatewayAttachmentConfiguration(owner, owner.gatewayPort); + if (!configuration.ok) throw new McpBridgeError(configuration.message, 1); + if (!owner.endpoint || new URL(owner.endpoint).protocol !== "https:") return undefined; + if (!owner.stateDir) { + throw new McpBridgeError("Externally supervised HTTPS gateway requires a state directory.", 1); + } + return readableGatewayClientTlsDir(path.join(owner.stateDir, "tls"), true); + } + + const configuredStateDir = selectedInProcess + ? process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR + : undefined; + const stateDir = resolveGatewayStateDirForPort({ + configured: configuredStateDir, + home: process.env.HOME || os.homedir(), + port: owner.gatewayPort, + }); + return readableGatewayClientTlsDir(getDockerDriverGatewayLocalTlsDir(stateDir), false); +} + +export function getMcpProviderInspectionRuntimeSelection( + sandbox: SandboxEntry, +): McpProviderInspectionRuntimeSelection { + const providerRuntimeGateway = getPersistedSandboxTargetGateway(sandbox); + const { gatewayName, gatewayPort } = providerRuntimeGateway; + const owner = resolveGatewayCredentialMutationAuthority({ gatewayName, gatewayPort }); + const localTlsDir = providerRuntimeLocalTlsDir(owner, providerRuntimeGateway.selectedInProcess); + return { + gatewayName, + ...(localTlsDir ? { localTlsDir } : {}), + workspace: OPENSHELL_DEFAULT_WORKSPACE, + }; +} + const MCP_PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; export function parseMcpProviderMetadata(output: string): Omit { @@ -65,7 +148,10 @@ export function parseMcpProviderMetadata(output: string): Omit { - const provider = inspectMcpProvider(name); + const provider = inspectMcpProvider(name, runtimeSelection); if ( provider.exists !== true || !provider.id || @@ -169,10 +258,11 @@ export function inspectMcpProviderAttachments( export function assertNoAttachedProviderCredentialCollisions( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); - const inspection = inspectMcpProviderAttachments(sandboxName); + const inspection = inspectMcpProviderAttachments(sandboxName, runtimeSelection); if (!inspection.attachments) { throw new McpBridgeError( inspection.error ?? `Could not inspect providers attached to sandbox '${sandboxName}'.`, @@ -198,12 +288,15 @@ export function assertNoRegisteredProviderCredentialCollisions( deps: { listExtraProviders?: () => string[]; inspectProvider?: (providerName: string) => McpProviderInspection; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } = {}, ): void { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); const queryExtraProviders = deps.listExtraProviders ?? listExtraProviders; - const inspectProvider = deps.inspectProvider ?? inspectMcpProvider; + const inspectProvider = + deps.inspectProvider ?? + ((providerName: string) => inspectMcpProvider(providerName, deps.runtimeSelection)); for (const providerName of queryExtraProviders()) { const provider = inspectProvider(providerName); if (provider.exists === false) continue; @@ -230,9 +323,10 @@ export function assertNoRegisteredProviderCredentialCollisions( export function assertNoProviderCredentialCollisions( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { - assertNoAttachedProviderCredentialCollisions(sandboxName, entries); - assertNoRegisteredProviderCredentialCollisions(entries); + assertNoAttachedProviderCredentialCollisions(sandboxName, entries, runtimeSelection); + assertNoRegisteredProviderCredentialCollisions(entries, { runtimeSelection }); } export function providerMatchesCredential( @@ -306,7 +400,10 @@ export function providerShapeDetail( return `Expected ${MCP_BRIDGE_PROVIDER_TYPE} provider with only credential key '${expectedCredential ?? ""}', found type '${type}' with keys '${keys}'.`; } -export function assertMcpProviderRecoverable(entry: McpBridgeEntry): McpProviderInspection { +export function assertMcpProviderRecoverable( + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): McpProviderInspection { assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { throw new McpBridgeError( @@ -314,7 +411,7 @@ export function assertMcpProviderRecoverable(entry: McpBridgeEntry): McpProvider ); } const expectedCredential = entry.env[0]; - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${entry.providerName}'.`, @@ -406,9 +503,10 @@ export async function preflightMcpEntryTargets( export function providerAttached( sandboxName: string, providerName: string | undefined, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): boolean | null { if (!providerName) return null; - const inspection = inspectMcpProviderAttachments(sandboxName); + const inspection = inspectMcpProviderAttachments(sandboxName, runtimeSelection); if (!inspection.attachments) return null; return inspection.attachments.some((attachment) => attachment.name === providerName); } diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts index 575223e5943..ac9d0dfac1d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts @@ -22,12 +22,14 @@ import { ensureEndpointlessProviderProfile, } from "../../adapters/openshell/provider-profile"; import { REPOSITORY_ROOT } from "../../core/repository-root"; +import { reportsExactProviderNotFound } from "../../onboard/extra-provider-diagnostic-parser"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError, type ParsedEnvReference } from "./mcp-bridge-contracts"; import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output"; import { inspectMcpProvider, MCP_BRIDGE_PROVIDER_TYPE, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, providerMatchesCredential, providerMatchesManagedCredential, @@ -67,24 +69,34 @@ export { * removalCondition: remove this import when the minimum supported OpenShell * release classifies the `openai` inference credential as gateway-only itself. */ -function ensureOpenAiGatewayProviderProfile(): void { +function ensureOpenAiGatewayProviderProfile( + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { const result = checkOpenAiInferenceProviderProfile({ runOpenshell: (args, options) => - runOpenshellProviderCommand(args, options) as OpenShellCommandResult, + runOpenshellProviderCommand(args, { + ...options, + runtimeSelection, + }) as OpenShellCommandResult, }); if (result.ok) return; throw new McpBridgeError(result.messages.join("\n")); } /** Ensure the endpointless profile required by OpenShell static credential binding. */ -export function ensureMcpBridgeProviderProfile(): void { - ensureOpenAiGatewayProviderProfile(); +export function ensureMcpBridgeProviderProfile( + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + ensureOpenAiGatewayProviderProfile(runtimeSelection); const result = ensureEndpointlessProviderProfile({ profileId: MCP_BRIDGE_PROVIDER_TYPE, inferenceCapable: false, profilePath: endpointlessProviderProfilePath(REPOSITORY_ROOT, MCP_BRIDGE_PROVIDER_TYPE), runOpenshell: (args, options) => - runOpenshellProviderCommand(args, options) as OpenShellCommandResult, + runOpenshellProviderCommand(args, { + ...options, + runtimeSelection, + }) as OpenShellCommandResult, }); if (result.ok) return; if (result.reason === "import-failed") { @@ -130,6 +142,7 @@ export function upsertMcpProvider( expectedProviderId?: string; requireExisting?: boolean; prepareMutation?: (action: "create" | "update") => void; + runtimeSelection: McpProviderInspectionRuntimeSelection; }, ): { action: "created" | "updated" | "reused" | "none"; @@ -149,7 +162,7 @@ export function upsertMcpProvider( }; } const envValues = resolveCredentialEnv(env); - const inspection = inspectMcpProvider(providerName); + const inspection = inspectMcpProvider(providerName, options.runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${providerName}'.`, @@ -202,7 +215,7 @@ export function upsertMcpProvider( // removalCondition: use native immutable provider IDs or caller-supplied CAS // once OpenShell exposes them, then remove this inspect-mutate-inspect // compensation. - const beforeMutation = inspectMcpProvider(providerName); + const beforeMutation = inspectMcpProvider(providerName, options.runtimeSelection); if (action === "create" && beforeMutation.exists !== false) { const detail = beforeMutation.exists === null @@ -225,6 +238,7 @@ export function upsertMcpProvider( { ignoreError: true, env: envValues, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], }, ) as OpenShellCommandResult; @@ -236,7 +250,7 @@ export function upsertMcpProvider( commandOutput(result, envValues) || `Failed to ${action} MCP provider '${providerName}'.`, ); } - const after = inspectMcpProvider(providerName); + const after = inspectMcpProvider(providerName, options.runtimeSelection); if (after.exists !== true || !after.id) { throw new McpBridgeError( after.error ?? @@ -263,14 +277,17 @@ export function upsertMcpProvider( * revision without reading or rotating the stored credential, giving the * sidecar a post-policy generation to synchronize. */ -export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProviderInspection { +export function refreshMcpProviderEnvironment( + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): McpProviderInspection { assertPersistedAuthenticatedBridgeEntry(entry); if (!entry.providerName || !entry.providerId) { throw new McpBridgeError( `MCP server '${entry.server}' has no stable OpenShell provider identity for credential synchronization.`, ); } - const before = inspectMcpProvider(entry.providerName); + const before = inspectMcpProvider(entry.providerName, runtimeSelection); if (!providerMatchesCredential(before, entry.env[0], entry.providerId)) { throw new McpBridgeError( `OpenShell provider '${entry.providerName}' changed before credential synchronization. ${providerShapeDetail(before, entry.env[0], entry.providerId)} Refusing to mutate it.`, @@ -278,6 +295,7 @@ export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProvide } const result = runOpenshellProviderCommand(["provider", "update", entry.providerName], { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], }) as OpenShellCommandResult; if (result.status !== 0) { @@ -286,7 +304,7 @@ export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProvide `Failed to synchronize MCP provider '${entry.providerName}' after policy binding.`, ); } - const after = inspectMcpProvider(entry.providerName); + const after = inspectMcpProvider(entry.providerName, runtimeSelection); if ( !providerMatchesCredential(after, entry.env[0], entry.providerId) || !after.resourceVersion || @@ -301,7 +319,12 @@ export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProvide function inspectMcpProviderForDeletion( entry: McpBridgeEntry, - options: { allowLegacyGeneric?: boolean; allowMissing?: boolean; bestEffort?: boolean } = {}, + options: { + allowLegacyGeneric?: boolean; + allowMissing?: boolean; + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): McpProviderInspection | null { if (!entry.providerName) return null; try { @@ -311,7 +334,7 @@ function inspectMcpProviderForDeletion( `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing to delete same-name provider '${entry.providerName}'.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (inspection.exists === false) { if (options.allowMissing) return inspection; throw new McpBridgeError( @@ -336,23 +359,35 @@ function inspectMcpProviderForDeletion( export function deleteProvider( entry: McpBridgeEntry, - options: { allowLegacyGeneric?: boolean; allowMissing?: boolean; bestEffort?: boolean } = {}, + options: { + allowLegacyGeneric?: boolean; + allowMissing?: boolean; + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { if (!entry.providerName) return; const inspection = inspectMcpProviderForDeletion(entry, options); if (!inspection?.exists || !inspection.id || !inspection.resourceVersion) return; const result = runOpenshellProviderCommand(["provider", "delete", entry.providerName], { ignoreError: true, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], suppressOutput: true, } as Record) as OpenShellCommandResult; if (result.status !== 0) { const output = commandOutput(result); - if (options.allowMissing && /not\s+found|NotFound/i.test(output)) return; + if ( + options.allowMissing && + result.status === 1 && + reportsExactProviderNotFound(output, entry.providerName, output.length) + ) { + return; + } if (options.bestEffort) return; throw new McpBridgeError(output || `Failed to delete MCP provider '${entry.providerName}'.`); } - const after = inspectMcpProvider(entry.providerName); + const after = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (after.exists !== false && !options.bestEffort) { throw new McpBridgeError( after.error ?? `OpenShell provider '${entry.providerName}' still exists after delete.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts index 3ba6261ee85..fb3f55eb146 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; import { ensureMcpBridgeProviderProfile, MCP_BRIDGE_PROVIDER_TYPE } from "./mcp-bridge-provider"; +const runtimeSelection = { gatewayName: "nemoclaw-8080", workspace: "default" }; + beforeEach(() => { setProviderCommandRuntimeHooksForTest({}); }); @@ -34,7 +36,7 @@ describe("OpenShell MCP provider profile", () => { .mockReturnValueOnce({ status: 0, stdout: "Imported", stderr: "" }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).not.toThrow(); + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).not.toThrow(); expect(runOpenshell).toHaveBeenCalledTimes(4); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "profile", "import", "--file", expect.stringMatching(/openai\.yaml$/)], @@ -61,7 +63,7 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).not.toThrow(); + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).not.toThrow(); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "profile", "export", "openai", "--output", "json"], expect.any(Object), @@ -93,7 +95,7 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow( + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( /does not match NemoClaw's endpointless credential contract/, ); }); @@ -108,7 +110,7 @@ describe("OpenShell MCP provider profile", () => { let message = ""; try { - ensureMcpBridgeProviderProfile(); + ensureMcpBridgeProviderProfile(runtimeSelection); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -136,7 +138,7 @@ describe("OpenShell MCP provider profile", () => { let message = ""; try { - ensureMcpBridgeProviderProfile(); + ensureMcpBridgeProviderProfile(runtimeSelection); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -173,7 +175,7 @@ describe("OpenShell MCP provider profile", () => { const runOpenshell = vi.fn().mockReturnValueOnce({ status: 0, stdout, stderr: "" }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow( + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( /does not match NemoClaw's endpointless inference contract/, ); expect(runOpenshell).toHaveBeenCalledOnce(); @@ -191,7 +193,9 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow(/could not be read for validation/); + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( + /could not be read for validation/, + ); expect(runOpenshell).toHaveBeenCalledOnce(); }); @@ -206,7 +210,7 @@ describe("OpenShell MCP provider profile", () => { .mockReturnValueOnce({ status: 1, stdout: "", stderr: "export rejected" }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow( + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( /nemoclaw-mcp-v1.*could not be exported for validation/u, ); expect(runOpenshell).toHaveBeenCalledTimes(2); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts index fc357f47f98..f7e4b1739a4 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts @@ -4,6 +4,7 @@ import { shellQuote } from "../../runner"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError } from "./mcp-bridge-contracts"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { waitForMcpBridgeCondition } from "./mcp-bridge/timing"; import { assertAuthenticatedBridgeEntry, @@ -46,12 +47,14 @@ type McpCredentialRevisionAttempt = function executeMcpCredentialProofCommand( sandboxName: string, command: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): ReturnType { // OpenShell preserves the proof as one multiline command argument. The // script classifies placeholder shape/revision only and never prints a raw // credential value or writes sandbox state. return executeSandboxExecCommand(sandboxName, command, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); } @@ -115,10 +118,12 @@ function parseMcpCredentialRevisionObservation( function tryObserveMcpCredentialRevision( sandboxName: string, envName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): McpCredentialRevisionAttempt { const result = executeMcpCredentialProofCommand( sandboxName, buildMcpCredentialRevisionObservationCommand(envName), + runtimeSelection, ); if (!result) return { kind: "transport-unavailable" }; if (result.status !== 0) return { kind: "command-failed", status: result.status }; @@ -142,9 +147,10 @@ function describeMcpCredentialRevisionAttempt(attempt: McpCredentialRevisionAtte export function observeMcpCredentialRevision( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): McpCredentialRevisionObservation { assertAuthenticatedBridgeEntry(entry); - const attempt = tryObserveMcpCredentialRevision(sandboxName, entry.env[0]); + const attempt = tryObserveMcpCredentialRevision(sandboxName, entry.env[0], runtimeSelection); if (attempt.kind !== "observation") { throw new McpBridgeError( `Could not observe the current OpenShell credential revision for sandbox '${sandboxName}'.`, @@ -156,6 +162,7 @@ export function observeMcpCredentialRevision( export function waitForAttachedMcpCredential( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: { previousRevision?: McpCredentialRevisionObservation; refreshAfterObservedAbsence?: () => void; @@ -182,7 +189,7 @@ export function waitForAttachedMcpCredential( // Each exec is a fresh OpenShell process. Only the bounded placeholder // classification crosses back to the host, where the comparison cannot // be influenced by a same-UID sandbox process rewriting a snapshot file. - let attempt = tryObserveMcpCredentialRevision(sandboxName, envName); + let attempt = tryObserveMcpCredentialRevision(sandboxName, envName, runtimeSelection); lastAttempt = attempt; if ( attempt.kind === "observation" && @@ -192,7 +199,7 @@ export function waitForAttachedMcpCredential( ) { refreshedAfterObservedAbsence = true; options.refreshAfterObservedAbsence(); - attempt = tryObserveMcpCredentialRevision(sandboxName, envName); + attempt = tryObserveMcpCredentialRevision(sandboxName, envName, runtimeSelection); lastAttempt = attempt; } const observation = attempt.kind === "observation" ? attempt.observation : null; @@ -241,7 +248,11 @@ export function buildMcpCredentialDetachedCommand(envName: string): string { return `[ -z "\${${envName}+x}" ]`; } -export function waitForDetachedMcpCredential(sandboxName: string, entry: McpBridgeEntry): void { +export function waitForDetachedMcpCredential( + sandboxName: string, + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { assertPersistedAuthenticatedBridgeEntry(entry); const envName = entry.env[0]; try { @@ -258,7 +269,11 @@ export function waitForDetachedMcpCredential(sandboxName: string, entry: McpBrid ); const revoked = waitForMcpBridgeCondition( () => - executeMcpCredentialProofCommand(sandboxName, buildMcpCredentialDetachedCommand(envName)) + executeMcpCredentialProofCommand( + sandboxName, + buildMcpCredentialDetachedCommand(envName), + runtimeSelection, + ) ?.status === 0, Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 30, 1_000, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts index 0eaaff30d83..546637d0589 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -20,16 +20,30 @@ import { providerMatchesManagedCredential, } from "./mcp-bridge-provider-inspection"; import { + attachProvider, assertMcpProviderRecoverable, + deleteProvider, + detachMissingProviderReference, + detachProvider, + ensureMcpBridgeProviderProfile, + MCP_BRIDGE_PROVIDER_TYPE, observeMcpCredentialRevision, refreshMcpProviderEnvironment, + upsertMcpProvider, waitForAttachedMcpCredential, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; import * as processRecovery from "./process-recovery"; +const runtimeSelection = { + gatewayName: "nemoclaw-8091", + localTlsDir: "/recorded/gateway/tls", + workspace: "default", +} as const; + describe("OpenShell MCP provider state", () => { afterEach(() => { + providerCommand.setProviderCommandRuntimeHooksForTest({}); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); @@ -93,11 +107,7 @@ Provider: }; expect( - providerMatchesCredential( - inspection, - "GITHUB_TOKEN", - "11111111-2222-4333-8444-555555555555", - ), + providerMatchesCredential(inspection, "GITHUB_TOKEN", "11111111-2222-4333-8444-555555555555"), ).toBe(false); expect( providerMatchesManagedCredential( @@ -135,7 +145,7 @@ Provider: addedAt: "2026-08-19T00:00:00.000Z", }; - expect(() => assertMcpProviderRecoverable(entry)).toThrow( + expect(() => assertMcpProviderRecoverable(entry, runtimeSelection)).toThrow( /legacy generic profile.*cannot bind to an MCP endpoint/, ); }); @@ -168,7 +178,188 @@ Provider: .mockReturnValueOnce(providerResult(8)); expect( - refreshMcpProviderEnvironment({ + refreshMcpProviderEnvironment( + { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: id, + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }, + { + gatewayName: "nemoclaw-8080", + workspace: "default", + }, + ), + ).toMatchObject({ resourceVersion: 8 }); + expect(run.mock.calls[1]?.[0]).toEqual(["provider", "update", "alpha-mcp-github"]); + expect(run.mock.calls[1]?.[0]).not.toContain("--credential"); + }); + + it("pins every managed MCP provider lifecycle read and write to the recorded runtime target (#10514)", () => { + vi.stubEnv("EXPECTED_TOKEN", "host-only-secret"); + vi.stubEnv("OPENSHELL_GATEWAY", "ambient-gateway"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "http://ambient.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true"); + vi.stubEnv("OPENSHELL_WORKSPACE", "ambient-workspace"); + + const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + const providerId = "11111111-2222-4333-8444-555555555555"; + const commandFamilies = new Set(); + let providerExists = false; + let providerAttached = false; + let resourceVersion = 0; + const providerOutput = () => + [ + `Id: ${providerId}`, + `Type: ${MCP_BRIDGE_PROVIDER_TYPE}`, + `Resource version: ${resourceVersion}`, + "Credential keys: EXPECTED_TOKEN", + ].join("\n"); + const profileOutput = (id: string, inferenceCapable: boolean) => + JSON.stringify({ + id, + credentials: [], + endpoints: [], + binaries: [], + inference_capable: inferenceCapable, + }); + + const runOpenshell = vi.fn((args: string[], options: { env?: Record }) => { + const env = options.env ?? {}; + expect( + Object.keys(env) + .filter((name) => name.startsWith("OPENSHELL_")) + .sort(), + ).toEqual(["OPENSHELL_GATEWAY", "OPENSHELL_WORKSPACE"]); + expect(env.OPENSHELL_GATEWAY).toBe(runtimeSelection.gatewayName); + expect(env.OPENSHELL_WORKSPACE).toBe(runtimeSelection.workspace); + + const command = `${args[0]} ${args[1]} ${args[2] ?? ""}`; + switch (command) { + case "provider profile export": + commandFamilies.add("profile"); + return { + status: 0, + stdout: profileOutput(args[3], args[3] === "openai"), + stderr: "", + }; + case "provider get alpha-mcp-fake": + commandFamilies.add("get"); + return providerExists + ? { status: 0, stdout: providerOutput(), stderr: "" } + : { status: 1, stdout: "", stderr: `provider '${args[2]}' not found` }; + case "provider create --name": + commandFamilies.add("create"); + providerExists = true; + resourceVersion = 1; + return { status: 0, stdout: "Created", stderr: "" }; + case "provider update alpha-mcp-fake": + commandFamilies.add("update"); + resourceVersion += 1; + return { status: 0, stdout: "Updated", stderr: "" }; + case "provider delete alpha-mcp-fake": + commandFamilies.add("delete"); + providerExists = false; + return { status: 0, stdout: "Deleted", stderr: "" }; + case "sandbox provider list": + commandFamilies.add("list"); + return providerAttached + ? { + status: 0, + stdout: `NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-fake ${MCP_BRIDGE_PROVIDER_TYPE} 1 0\n`, + stderr: "", + } + : { + status: 0, + stdout: "No providers attached to sandbox alpha.\n", + stderr: "", + }; + case "sandbox provider attach": + commandFamilies.add("attach"); + providerAttached = true; + return { status: 0, stdout: "Attached", stderr: "" }; + case "sandbox provider detach": { + commandFamilies.add("detach"); + const changed = providerAttached; + providerAttached = false; + return { + status: 0, + stdout: changed + ? "Detached provider alpha-mcp-fake from sandbox alpha." + : "Provider alpha-mcp-fake was not attached to sandbox alpha.", + stderr: "", + }; + } + default: + throw new Error(`Unexpected OpenShell command: ${args.join(" ")}`); + } + }); + providerCommand.setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); + + ensureMcpBridgeProviderProfile(runtimeSelection); + const created = upsertMcpProvider("alpha-mcp-fake", [{ name: "EXPECTED_TOKEN" }], { + allowExisting: false, + runtimeSelection, + }); + const entry: McpBridgeEntry = { + server: "fake", + agent: "openclaw", + adapter: "mcporter", + url: "https://mcp.example.test/mcp", + env: ["EXPECTED_TOKEN"], + providerName: "alpha-mcp-fake", + providerId: created.inspection.id ?? undefined, + policyName: "mcp-bridge-fake", + addedAt: "2026-06-01T00:00:00.000Z", + }; + attachProvider("alpha", entry, runtimeSelection); + refreshMcpProviderEnvironment(entry, runtimeSelection); + expect(detachProvider("alpha", entry, { runtimeSelection })).toBe("detached"); + deleteProvider(entry, { runtimeSelection }); + expect(detachMissingProviderReference("alpha", entry, runtimeSelection)).toBe("absent"); + + expect(commandFamilies).toEqual( + new Set(["profile", "get", "create", "attach", "list", "update", "detach", "delete"]), + ); + }); + + it.each([ + "NotFound: provider", + "provider 'other-mcp-github' not found", + 'status: NotFound, message: "gateway nemoclaw-8091 not found"', + ])( + "rejects ambiguous provider-delete output %s while cleanup is retryable (#10514)", + (diagnostic) => { + const id = "11111111-2222-4333-8444-555555555555"; + const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + const run = vi + .spyOn(providerCommand, "runOpenshellProviderCommand") + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + "", + ], + stdout: `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + stderr: "", + }) + .mockReturnValueOnce({ + pid: 1234, + status: 1, + signal: null, + output: [null, "", diagnostic], + stdout: "", + stderr: diagnostic, + }); + const entry: McpBridgeEntry = { server: "github", agent: "openclaw", adapter: "mcporter", @@ -178,10 +369,54 @@ Provider: providerId: id, policyName: "mcp-bridge-github", addedAt: "2026-08-19T00:00:00.000Z", - }), - ).toMatchObject({ resourceVersion: 8 }); - expect(run.mock.calls[1]?.[0]).toEqual(["provider", "update", "alpha-mcp-github"]); - expect(run.mock.calls[1]?.[0]).not.toContain("--credential"); + }; + + expect(() => deleteProvider(entry, { allowMissing: true, runtimeSelection })).toThrow( + diagnostic, + ); + expect(run).toHaveBeenCalledTimes(2); + }, + ); + + it("accepts an exact provider-delete absence while cleanup is retryable (#10514)", () => { + const id = "11111111-2222-4333-8444-555555555555"; + const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + const run = vi + .spyOn(providerCommand, "runOpenshellProviderCommand") + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + "", + ], + stdout: `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + stderr: "", + }) + .mockReturnValueOnce({ + pid: 1234, + status: 1, + signal: null, + output: [null, "", "provider 'alpha-mcp-github' not found"], + stdout: "", + stderr: "provider 'alpha-mcp-github' not found", + }); + const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: id, + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }; + + expect(() => deleteProvider(entry, { allowMissing: true, runtimeSelection })).not.toThrow(); + expect(run).toHaveBeenCalledTimes(2); }); it("distinguishes a real detach from OpenShell's idempotent success", () => { @@ -223,9 +458,9 @@ alpha-mcp-slack generic 1 0 addedAt: "2026-06-01T00:00:00.000Z", }; - expect(() => assertNoAttachedProviderCredentialCollisions("alpha", [entry])).toThrow( - "MCP server 'example' has no complete authenticated credential binding", - ); + expect(() => + assertNoAttachedProviderCredentialCollisions("alpha", [entry], runtimeSelection), + ).toThrow("MCP server 'example' has no complete authenticated credential binding"); expect(() => assertNoRegisteredProviderCredentialCollisions([entry], { listExtraProviders: () => ["foreign-registered"], @@ -262,6 +497,99 @@ alpha-mcp-slack generic 1 0 ); }); + it("pins attachment collision inspection to the recorded runtime target (#10514)", () => { + const runtimeSelection = { gatewayName: "nemoclaw-9090", workspace: "default" }; + const run = vi + .spyOn(providerCommand, "runOpenshellProviderCommand") + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nforeign-provider nemoclaw-mcp-v1 1 0\n", + "", + ], + stdout: "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nforeign-provider nemoclaw-mcp-v1 1 0\n", + stderr: "", + }) + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + "", + ], + stdout: + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + stderr: "", + }); + const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }; + + expect(() => + assertNoAttachedProviderCredentialCollisions("alpha", [entry], runtimeSelection), + ).toThrow("Credential key 'GITHUB_TOKEN' is already supplied by attached provider"); + expect(run).toHaveBeenCalledTimes(2); + expect( + run.mock.calls.every( + ([, options]) => + options?.runtimeSelection?.gatewayName === runtimeSelection.gatewayName && + options.runtimeSelection.workspace === runtimeSelection.workspace, + ), + ).toBe(true); + }); + + it("pins registered collision inspection to the recorded runtime target (#10514)", () => { + const runtimeSelection = { gatewayName: "nemoclaw-9090", workspace: "default" }; + const run = vi.spyOn(providerCommand, "runOpenshellProviderCommand").mockReturnValue({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + "", + ], + stdout: + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + stderr: "", + }); + const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }; + + expect(() => + assertNoRegisteredProviderCredentialCollisions([entry], { + listExtraProviders: () => ["foreign-provider"], + runtimeSelection, + }), + ).toThrow("Credential key 'GITHUB_TOKEN' is already supplied by registered provider"); + expect(run).toHaveBeenCalledWith( + ["provider", "get", "foreign-provider"], + expect.objectContaining({ runtimeSelection }), + ); + }); + it.each([ { value: undefined, observation: "absent" }, { value: "openshell:resolve:env:GITHUB_TOKEN", observation: "canonical" }, @@ -318,7 +646,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toBe("v11"); const proofCommand = exec.mock.calls[0]?.[1] ?? ""; expect(proofCommand).toContain("\n"); @@ -327,6 +655,7 @@ alpha-mcp-slack generic 1 0 expect(proofCommand).not.toContain("base64 -d"); expect(exec).toHaveBeenCalledWith("alpha", proofCommand, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); exec.mockReturnValue({ status: 0, stdout: "raw-secret", stderr: "" }); @@ -341,7 +670,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/Could not observe the current OpenShell credential revision/); }); @@ -365,6 +694,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ); @@ -395,7 +725,7 @@ alpha-mcp-slack generic 1 0 .mockReturnValueOnce({ status: 0, stdout: "v11", stderr: "" }) .mockReturnValue({ status: 0, stdout: "v12", stderr: "" }); - expect(waitForAttachedMcpCredential("alpha", entry)).toBe("v12"); + expect(waitForAttachedMcpCredential("alpha", entry, runtimeSelection)).toBe("v12"); expect(exec).toHaveBeenCalledTimes(3); }); @@ -419,7 +749,7 @@ alpha-mcp-slack generic 1 0 .mockReturnValue({ status: 0, stdout: "v7480654703696766813", stderr: "" }); expect( - waitForAttachedMcpCredential("alpha", entry, { + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { previousRevision: "v15566468742889590075", }), ).toBe("v7480654703696766813"); @@ -446,7 +776,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/last bounded observation: canonical/); }); @@ -470,7 +800,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/last bounded observation: absent/); expect(exec).toHaveBeenCalledOnce(); }); @@ -493,9 +823,11 @@ alpha-mcp-slack generic 1 0 .mockReturnValue({ status: 0, stdout: "v12", stderr: "" }); const refreshAfterObservedAbsence = vi.fn(); - expect(waitForAttachedMcpCredential("alpha", entry, { refreshAfterObservedAbsence })).toBe( - "v12", - ); + expect( + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { + refreshAfterObservedAbsence, + }), + ).toBe("v12"); expect(refreshAfterObservedAbsence).toHaveBeenCalledOnce(); expect(exec).toHaveBeenCalledTimes(3); }); @@ -524,6 +856,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ), ).toThrow(/post-absence provider refresh attempted: yes/u); @@ -556,6 +889,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ); } catch (error) { @@ -591,6 +925,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ), ).toThrow("provider refresh failed"); @@ -620,6 +955,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { previousRevision: "v11", refreshAfterObservedAbsence }, ), ).toThrow(/last bounded observation: v11; post-absence provider refresh attempted: yes/u); @@ -643,7 +979,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/did not confirm credential 'GITHUB_TOKEN' was revoked/); const proofCommand = exec.mock.calls[0]?.[1] ?? ""; @@ -651,6 +987,7 @@ alpha-mcp-slack generic 1 0 expect(proofCommand).not.toContain("base64 -d"); expect(exec).toHaveBeenCalledWith("alpha", proofCommand, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); }); @@ -672,17 +1009,22 @@ alpha-mcp-slack generic 1 0 stderr: "", }); - expect(waitForAttachedMcpCredential("alpha", entry, { previousRevision: "v11" })).toBe("v12"); + expect( + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { + previousRevision: "v11", + }), + ).toBe("v12"); expect(exec).toHaveBeenCalledTimes(2); vi.stubEnv("NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS", "1"); exec.mockClear(); exec.mockReturnValue({ status: 0, stdout: "v11", stderr: "" }); vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(1_000); - expect(() => waitForAttachedMcpCredential("alpha", entry, { previousRevision: "v11" })).toThrow( - /did not synchronize the expected credential revision/, - ); + expect(() => + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { + previousRevision: "v11", + }), + ).toThrow(/did not synchronize the expected credential revision/); expect(exec).toHaveBeenCalledTimes(1); }); - }); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.ts b/src/lib/actions/sandbox/mcp-bridge-provider.ts index 42b991c6b10..609faf20675 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.ts @@ -5,12 +5,14 @@ export type { McpProviderAttachment, McpProviderAttachmentInspection, McpProviderInspection, + McpProviderInspectionRuntimeSelection, } from "./mcp-bridge-provider-inspection"; export { assertMcpProviderRecoverable, assertNoAttachedProviderCredentialCollisions, assertNoProviderCredentialCollisions, assertNoRegisteredProviderCredentialCollisions, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, inspectMcpProviderAttachments, MCP_BRIDGE_PROVIDER_TYPE, diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts index a9e15211cb8..477a5a12672 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -12,6 +12,8 @@ import { } from "./mcp-bridge-destroy-preflight"; import { assertNoProviderCredentialCollisions, + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, } from "./mcp-bridge-provider"; import { @@ -27,6 +29,7 @@ import { assertAuthenticatedBridgeEntry, validateSandboxName } from "./mcp-bridg type ReadOnlyValidationSnapshot = { providerByServer: Map; + runtimeSelection: McpProviderInspectionRuntimeSelection; targetsByServer: Map; }; @@ -43,6 +46,7 @@ export interface ExecUnavailableMcpRebuildPreparation { scrubbedAdapterEntries: McpBridgeEntry[]; revalidateBeforeDelete: () => Promise; assertDeleteEdgeUnchanged: () => void; + runtimeSelection: McpProviderInspectionRuntimeSelection; } function assertUniqueMcpOwnership(entries: readonly McpBridgeEntry[]): void { @@ -127,23 +131,43 @@ function targetFingerprint(target: McpBridgeTargetValidation | undefined): strin async function inspectReadOnlyRecoveryState( sandboxName: string, entries: readonly McpBridgeEntry[], + expectedRuntimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { const resolvedTargets = await preflightMcpEntryTargets(entries); - // This may start or recover the sandbox's recorded host gateway and select + const currentRuntimeSelection = getMcpProviderInspectionRuntimeSelection( + getSandboxOrThrow(sandboxName), + ); + if ( + expectedRuntimeSelection && + (currentRuntimeSelection.gatewayName !== expectedRuntimeSelection.gatewayName || + currentRuntimeSelection.workspace !== expectedRuntimeSelection.workspace || + currentRuntimeSelection.localTlsDir !== expectedRuntimeSelection.localTlsDir) + ) { + throw new McpBridgeError( + `Sandbox '${sandboxName}' changed its MCP gateway authority before host-side rebuild recovery could inspect providers. Recorded OpenShell target gateway '${expectedRuntimeSelection.gatewayName}' no longer matches observed target gateway '${currentRuntimeSelection.gatewayName}' or its workspace and TLS authority. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expectedRuntimeSelection.gatewayName}', confirm it is healthy, then retry.`, + ); + } + const providerRuntimeSelection = expectedRuntimeSelection ?? currentRuntimeSelection; + // This may start or recover only the frozen recorded host gateway and select // it in CLI context. It does not mutate MCP lifecycle state or sandbox // contents; the provider and target checks below remain inspection-only. - if (entries.length > 0) await ensureSandboxGatewaySelected(sandboxName); + if (entries.length > 0) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const providerByServer = new Map(); const targetsByServer = new Map(); for (const entry of entries) { const target = resolvedTargets.get(entry.server); - const provider = inspectExactMcpDestroyProvider(entry, { allowMissing: false }); + const provider = inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); providerByServer.set(entry.server, providerFingerprint(provider)); targetsByServer.set(entry.server, targetFingerprint(target)); } - assertNoProviderCredentialCollisions(sandboxName, entries); - return { providerByServer, targetsByServer }; + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); + return { providerByServer, runtimeSelection: providerRuntimeSelection, targetsByServer }; } function assertValidationSnapshotCurrent( @@ -156,9 +180,15 @@ function assertValidationSnapshotCurrent( current.providerByServer.get(entry.server) !== expected.providerByServer.get(entry.server) || current.targetsByServer.get(entry.server) !== expected.targetsByServer.get(entry.server), ); - if (drifted) { + const targetChanged = + current.runtimeSelection.gatewayName !== expected.runtimeSelection.gatewayName || + current.runtimeSelection.workspace !== expected.runtimeSelection.workspace || + current.runtimeSelection.localTlsDir !== expected.runtimeSelection.localTlsDir; + if (drifted || targetChanged) { throw new McpBridgeError( - `MCP server '${drifted.server}' changed after host-side rebuild preflight. Refusing to delete the still-live sandbox; retry after its target and provider state are stable.`, + drifted + ? `MCP server '${drifted.server}' changed after host-side rebuild preflight on recorded OpenShell gateway '${expected.runtimeSelection.gatewayName}'. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expected.runtimeSelection.gatewayName}', confirm it is healthy and the MCP target and provider identity match the sandbox registry, then retry.` + : `Sandbox MCP gateway authority changed after host-side rebuild preflight. Recorded OpenShell target gateway '${expected.runtimeSelection.gatewayName}' no longer matches observed target gateway '${current.runtimeSelection.gatewayName}' or its workspace and TLS authority. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expected.runtimeSelection.gatewayName}', confirm it is healthy, then retry.`, ); } } @@ -182,9 +212,10 @@ function assertDeleteEdgeUnchanged( `Sandbox '${sandboxName}' changed its recorded agent or MCP adapter after host-side rebuild preflight. Refusing to delete it.`, ); } - if (resolveSandboxGatewayName(sandbox) !== expectedGatewayName) { + const observedGatewayName = resolveSandboxGatewayName(sandbox); + if (observedGatewayName !== expectedGatewayName) { throw new McpBridgeError( - `Sandbox '${sandboxName}' changed its recorded gateway after host-side rebuild preflight. Refusing to delete it.`, + `Sandbox '${sandboxName}' changed its recorded gateway after host-side rebuild preflight from '${expectedGatewayName}' to '${observedGatewayName}'. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expectedGatewayName}', confirm it is healthy, then retry.`, ); } } @@ -204,7 +235,11 @@ async function revalidateBeforeDelete( expectedAgentName, expectedAdapter, ); - const currentValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); + const currentValidation = await inspectReadOnlyRecoveryState( + sandboxName, + expectedEntries, + expectedValidation.runtimeSelection, + ); assertValidationSnapshotCurrent(expectedEntries, expectedValidation, currentValidation); } @@ -218,14 +253,20 @@ async function revalidateBeforeDelete( */ export async function prepareMcpBridgesForExecUnavailableRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { const { entries, gatewayName, agentName, adapter } = snapshotCompleteEntries(sandboxName); const expectedEntries = entries.map(cloneMcpBridgeEntry); - const expectedValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); + const expectedValidation = await inspectReadOnlyRecoveryState( + sandboxName, + expectedEntries, + runtimeSelection, + ); return { entries: entries.map(cloneMcpBridgeEntry), detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: expectedValidation.runtimeSelection, revalidateBeforeDelete: () => revalidateBeforeDelete( sandboxName, diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index b9aaefb080f..ebc43f9d3c5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -1,10 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isDeepStrictEqual } from "node:util"; - -import YAML from "yaml"; - import type { McpBridgeEntry } from "../../state/registry"; import * as policies from "../../policy"; import { @@ -29,6 +25,8 @@ import { assertNoProviderCredentialCollisions, assertNoRegisteredProviderCredentialCollisions, detachProvider, + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; @@ -57,14 +55,8 @@ export interface McpRebuildPreparation { revalidateBeforeDelete?: () => Promise; /** Final synchronous registry-only proof immediately before delete. */ assertDeleteEdgeUnchanged?: () => void; -} - -function policyDocumentsMatch(left: string, right: string): boolean { - try { - return isDeepStrictEqual(YAML.parse(left), YAML.parse(right)); - } catch { - return false; - } + /** One authority-derived OpenShell target frozen for this rebuild attempt. */ + runtimeSelection?: McpProviderInspectionRuntimeSelection; } function policyWithoutManagedMcpEntries( @@ -81,11 +73,13 @@ function policyWithoutManagedMcpEntries( function assertMcpTeardownPolicyUnchanged( sandboxName: string, expectedTeardownPolicy: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { const currentPolicy = getSandboxPolicy(sandboxName, { recordedGatewayOperation: "verify the live policy before MCP teardown", + runtimeSelection, }).yaml; - if (!currentPolicy || !policyDocumentsMatch(currentPolicy, expectedTeardownPolicy)) { + if (!currentPolicy || !policies.policyDocumentsMatch(currentPolicy, expectedTeardownPolicy)) { throw new McpBridgeError( `OpenShell policy changed while preparing MCP teardown for sandbox '${sandboxName}'. Refusing sandbox deletion.`, ); @@ -96,11 +90,19 @@ export { prepareMcpBridgesForExecUnavailableRebuild } from "./mcp-bridge-rebuild async function getCompleteMcpRebuildEntries( sandboxName: string, - options: { sandboxAbsent?: boolean } = {}, -): Promise { + options: { + runtimeSelection?: McpProviderInspectionRuntimeSelection; + sandboxAbsent?: boolean; + } = {}, +): Promise<{ + entries: McpBridgeEntry[]; + runtimeSelection: McpProviderInspectionRuntimeSelection; +}> { validateSandboxName(sandboxName); const currentSandbox = getSandboxOrThrow(sandboxName); assertMcpDestroyNotPending(currentSandbox); + const runtimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(currentSandbox); if (!options.sandboxAbsent) { const entriesRequiringExternalCleanup = Object.values(bridgeState(currentSandbox)).filter( (entry) => entry.addState !== "prepared", @@ -113,9 +115,13 @@ async function getCompleteMcpRebuildEntries( sandboxName, currentSandbox, entriesRequiringExternalCleanup, + runtimeSelection, ); } - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, options); + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + runtimeSelection, + sandboxAbsent: options.sandboxAbsent, + }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const incompleteAdd = entries.find((entry) => entry.addState); if (incompleteAdd) { @@ -123,7 +129,7 @@ async function getCompleteMcpRebuildEntries( `MCP server '${incompleteAdd.server}' has an incomplete add transaction (${incompleteAdd.addState}). Re-run the original mcp add command or remove it with --force before rebuilding the sandbox.`, ); } - return entries; + return { entries, runtimeSelection }; } /** @@ -134,34 +140,47 @@ async function getCompleteMcpRebuildEntries( */ export async function prepareMcpBridgesForAbsentSandboxRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - const entries = await getCompleteMcpRebuildEntries(sandboxName, { sandboxAbsent: true }); + const { entries, runtimeSelection: providerRuntimeSelection } = + await getCompleteMcpRebuildEntries(sandboxName, { + sandboxAbsent: true, + runtimeSelection, + }); if (entries.length === 0) { return { entries: [], detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: providerRuntimeSelection, }; } await preflightMcpEntryTargets(entries); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); for (const entry of entries) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } - for (const entry of entries) assertMcpProviderRecoverable(entry); - assertNoRegisteredProviderCredentialCollisions(entries); + for (const entry of entries) { + assertMcpProviderRecoverable(entry, providerRuntimeSelection); + } + assertNoRegisteredProviderCredentialCollisions(entries, { + runtimeSelection: providerRuntimeSelection, + }); return { entries, detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: providerRuntimeSelection, }; } export async function prepareMcpBridgesForRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { const sandbox = getSandboxOrThrow(sandboxName); - const entries = await getCompleteMcpRebuildEntries(sandboxName); + const { entries, runtimeSelection: providerRuntimeSelection } = + await getCompleteMcpRebuildEntries(sandboxName, { runtimeSelection }); if (entries.length === 0) { return { entries: [], @@ -170,11 +189,18 @@ export async function prepareMcpBridgesForRebuild( }; } await preflightMcpEntryTargets(entries); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); for (const entry of entries) assertGeneratedPolicyMutationSafe(sandboxName, entry); - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); - for (const entry of entries) assertMcpProviderRecoverable(entry); - assertNoProviderCredentialCollisions(sandboxName, entries); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); + for (const entry of entries) { + assertMcpProviderRecoverable(entry, providerRuntimeSelection); + } + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); // This is the bounded replacement handoff, not a durable NemoClaw policy // record. Capture OpenShell immediately before the internal teardown // mutations so the replacement receives the complete operator-owned @@ -182,6 +208,7 @@ export async function prepareMcpBridgesForRebuild( // the still-running source sandbox before provider detach. const policyHandoff = getSandboxPolicy(sandboxName, { recordedGatewayOperation: "capture the live policy before MCP teardown", + runtimeSelection: providerRuntimeSelection, }).yaml; if (!policyHandoff) { throw new McpBridgeError( @@ -197,32 +224,50 @@ export async function prepareMcpBridgesForRebuild( // `/sandbox` may be a retained PVC. Scrub before delete so a replacement // Hermes/agent cannot boot with a stale placeholder while its provider // is intentionally detached during recreate. - scrubbedAdapters.push(scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry)); + scrubbedAdapters.push( + scrubManagedMcpAdapterOrThrow( + sandboxName, + sandbox, + entry, + providerRuntimeSelection, + ), + ); } for (const entry of entries) { // The same-name replacement journal fingerprints this source row before // MCP teardown removes the live entry from the source sandbox. Rebuild's // OpenShell policy handoff already captured the complete live document. - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); removedPolicies.push(entry); } for (const entry of entries) { // Keep the provider and its host-only credentials for the replacement // sandbox, but detach it before OpenShell deletes the old attachment. - inspectExactMcpDestroyProvider(entry, { allowMissing: false }); - const detachOutcome = detachProvider(sandboxName, entry); + inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); + const detachOutcome = detachProvider(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); if (detachOutcome === "unknown") { throw new McpBridgeError( `Could not prove provider detach for MCP server '${entry.server}'.`, ); } - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); // A binding already absent on retry was still detached by this rebuild // transaction (possibly before a prior process died), so it must be // reattached if sandbox deletion later aborts. detached.push(entry); } - assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); + assertMcpTeardownPolicyUnchanged( + sandboxName, + expectedTeardownPolicy, + providerRuntimeSelection, + ); } catch (error) { const rollbackFailures: string[] = []; let runtimeRestored = false; @@ -230,6 +275,7 @@ export async function prepareMcpBridgesForRebuild( try { await restoreExistingMcpBridgeRuntime(sandboxName, removedPolicies, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (rollbackError) { @@ -239,7 +285,14 @@ export async function prepareMcpBridgesForRebuild( } } if (!runtimeRestored) { - rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); + rollbackFailures.push( + ...rollbackScrubbedMcpAdapters( + sandboxName, + sandbox, + scrubbedAdapters, + providerRuntimeSelection, + ), + ); } const detail = error instanceof Error ? error.message : String(error); throw new McpBridgeError( @@ -253,8 +306,13 @@ export async function prepareMcpBridgesForRebuild( detachedProviderEntries: detached, scrubbedAdapterEntries: scrubbedAdapters, policyHandoff, + runtimeSelection: providerRuntimeSelection, revalidateBeforeDelete: async () => { - assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); + assertMcpTeardownPolicyUnchanged( + sandboxName, + expectedTeardownPolicy, + providerRuntimeSelection, + ); }, }; } @@ -263,14 +321,19 @@ export async function reattachMcpProvidersAfterRebuildAbort( sandboxName: string, entries: readonly McpBridgeEntry[], scrubbedAdapterEntries: readonly McpScrubbedAdapterEntry[] = [], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { if (entries.length === 0 && scrubbedAdapterEntries.length === 0) return; - await ensureSandboxGatewaySelected(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, [ - ...entries, - ...scrubbedAdapterEntries, - ]); + const providerRuntimeSelection = + runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + [...entries, ...scrubbedAdapterEntries], + providerRuntimeSelection, + ); const failures: string[] = []; let runtimeRestored = false; @@ -278,6 +341,7 @@ export async function reattachMcpProvidersAfterRebuildAbort( try { await restoreExistingMcpBridgeRuntime(sandboxName, entries, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (error) { @@ -285,7 +349,14 @@ export async function reattachMcpProvidersAfterRebuildAbort( } } if (!runtimeRestored) { - failures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapterEntries)); + failures.push( + ...rollbackScrubbedMcpAdapters( + sandboxName, + sandbox, + scrubbedAdapterEntries, + providerRuntimeSelection, + ), + ); } if (failures.length > 0) { throw new McpBridgeError(failures.join("; ")); @@ -295,6 +366,7 @@ export async function reattachMcpProvidersAfterRebuildAbort( export async function restoreMcpBridgesAfterRebuild( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); @@ -307,5 +379,8 @@ export async function restoreMcpBridgesAfterRebuild( // Sandbox creation already received the complete pre-rebuild OpenShell // policy. Restore providers and adapters without regenerating or overwriting // policy entries that an operator may have edited independently. - await restoreExistingMcpBridgeRuntime(sandboxName, entries, { applyPolicy: false }); + await restoreExistingMcpBridgeRuntime(sandboxName, entries, { + applyPolicy: false, + ...(runtimeSelection ? { runtimeSelection } : {}), + }); } diff --git a/src/lib/actions/sandbox/mcp-bridge-recovery.ts b/src/lib/actions/sandbox/mcp-bridge-recovery.ts index a9e85582dc0..927a35e51c6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-recovery.ts +++ b/src/lib/actions/sandbox/mcp-bridge-recovery.ts @@ -6,6 +6,7 @@ import { inspectHermesMcpRuntimeIntent, sanitizeHermesMcpReconciliationDetail, } from "./mcp-bridge-hermes-reconciliation"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; export type McpReconciliationRefusalRecoveryResult = { checked: true; @@ -23,8 +24,12 @@ type InspectHermesMcpRuntimeIntent = (sandboxName: string) => HermesMcpReconcili export function inspectHermesMcpReconciliationRefusal( sandboxName: string, inspect: InspectHermesMcpRuntimeIntent = inspectHermesMcpRuntimeIntent, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): { detail: string } | null { - const reconciliation = inspect(sandboxName); + const reconciliation = + inspect === inspectHermesMcpRuntimeIntent + ? inspectHermesMcpRuntimeIntent(sandboxName, { runtimeSelection }) + : inspect(sandboxName); if (reconciliation.ok) return null; return { detail: sanitizeHermesMcpReconciliationDetail(reconciliation.detail) }; } @@ -33,8 +38,9 @@ export function processRecoveryMcpReconciliationRefusal( sandboxName: string, wasRunning: boolean, inspect: InspectHermesMcpRuntimeIntent = inspectHermesMcpRuntimeIntent, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): McpReconciliationRefusalRecoveryResult | null { - const refusal = inspectHermesMcpReconciliationRefusal(sandboxName, inspect); + const refusal = inspectHermesMcpReconciliationRefusal(sandboxName, inspect, runtimeSelection); if (!refusal) return null; return { checked: true, diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index cbab3068043..d69747ae96e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -17,7 +17,9 @@ import { deleteProvider, detachMissingProviderReference, detachProvider, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, + type McpProviderInspectionRuntimeSelection, providerMatchesManagedCredential, providerShapeDetail, waitForDetachedMcpCredential, @@ -56,7 +58,11 @@ function requiresProviderDetachBeforeAdapterCleanup(entry: McpBridgeEntry): bool function assertExactMcpRemoveProvider( entry: McpBridgeEntry, - options: { allowMissing: boolean; force?: boolean }, + options: { + allowMissing: boolean; + force?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { assertPersistedAuthenticatedBridgeEntry(entry); if (!entry.providerId) { @@ -64,7 +70,7 @@ function assertExactMcpRemoveProvider( `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing destructive cleanup of same-name provider '${entry.providerName}'. Remove the legacy bridge with --force only after independently cleaning that provider.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${entry.providerName}'.`, @@ -195,6 +201,7 @@ async function removeMcpBridgeUnlocked( console.log(` Cancelled incomplete MCP add for '${server}' on sandbox '${sandboxName}'.`); return "cancelledPreparedAdd"; } + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); // Cleanup follows the adapter persisted with the bridge. Requiring the // sandbox's current agent to still advertise MCP support would strand old // resources after an agent/capability migration. @@ -208,15 +215,15 @@ async function removeMcpBridgeUnlocked( // entry on an image that predates the managed launcher marker. Hermes still // performs its host-side shields preflight here, before any provider, policy, // attachment, or adapter side effect. - assertAgentMcpConfigMutationAllowed(sandboxName, adapter); - await ensureSandboxGatewaySelected(sandboxName); + assertAgentMcpConfigMutationAllowed(sandboxName, adapter, providerRuntimeSelection); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); assertGeneratedPolicyMutationSafe(sandboxName, entry); const failures: string[] = []; let providerOwnershipProved = !entry.providerName; let providerWasMissing = false; if (entry.providerName) { if (!entry.providerId) { - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (inspection.exists === false) { // With no live provider there is no global object to adopt or destroy. // This lets an operator independently remove a legacy/orphan provider, @@ -232,7 +239,7 @@ async function removeMcpBridgeUnlocked( failures.push(detail); } } else { - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (inspection.exists === false) { providerOwnershipProved = true; providerWasMissing = true; @@ -268,7 +275,7 @@ async function removeMcpBridgeUnlocked( detachBeforeAdapterCleanup ) { try { - detachMissingProviderReference(sandboxName, entry); + detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); missingProviderReferenceDetached = true; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -284,7 +291,10 @@ async function removeMcpBridgeUnlocked( ? missingProviderReferenceDetached ? "detached" : "unknown" - : detachProvider(sandboxName, entry, { allowLegacyGeneric: true }); + : detachProvider(sandboxName, entry, { + allowLegacyGeneric: true, + runtimeSelection: providerRuntimeSelection, + }); providerDetachedBeforeAdapterCleanup = detachOutcome !== "unknown"; if (!providerDetachedBeforeAdapterCleanup) { throw new McpBridgeError( @@ -310,11 +320,16 @@ async function removeMcpBridgeUnlocked( // this probe precedes every provider/policy/adapter side effect. Hermes // retains its helper/lifecycle validation; Deep Agents intentionally // skips only the marker that an older image cannot expose. - assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter); + assertAgentMcpTeardownRuntimeCapability( + sandboxName, + adapter, + providerRuntimeSelection, + ); const adapterRemoval = unregisterAgentAdapter( sandboxName, (entry.adapter as AgentMcpAdapter | undefined) ?? adapter, entry, + providerRuntimeSelection, { force: options.force === true, envValues: adapterEnvValues, @@ -333,6 +348,7 @@ async function removeMcpBridgeUnlocked( (candidate) => candidate.server !== server, ), managedServerNames: sandbox.mcp?.managedServerNames, + runtimeSelection: providerRuntimeSelection, }); } } catch (error) { @@ -345,7 +361,9 @@ async function removeMcpBridgeUnlocked( let policyCleanupProved = false; if (adapterCleanupProved) { try { - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); policyCleanupProved = true; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -368,11 +386,14 @@ async function removeMcpBridgeUnlocked( if (providerWasMissing) { detachOutcome = missingProviderReferenceDetached ? "detached" - : detachMissingProviderReference(sandboxName, entry); + : detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); } else { detachOutcome = providerDetachedBeforeAdapterCleanup ? "detached" - : detachProvider(sandboxName, entry, { allowLegacyGeneric: true }); + : detachProvider(sandboxName, entry, { + allowLegacyGeneric: true, + runtimeSelection: providerRuntimeSelection, + }); } if (detachOutcome !== "unknown") { // A missing provider has no credential left to revoke. Its stock CLI @@ -380,7 +401,7 @@ async function removeMcpBridgeUnlocked( // skipping a fresh-exec probe lets cleanup proceed even if another // unrelated provider reference is also dangling. if (!providerWasMissing && !providerDetachedBeforeAdapterCleanup) { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); } reservationCleanupProved = true; } @@ -409,10 +430,12 @@ async function removeMcpBridgeUnlocked( assertExactMcpRemoveProvider(entry, { allowMissing: false, force: options.force, + runtimeSelection: providerRuntimeSelection, }); deleteProvider(entry, { allowLegacyGeneric: true, allowMissing: options.force === true || entry.addState === "preflighted", + runtimeSelection: providerRuntimeSelection, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); diff --git a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts index ef401e8b80e..324aac09758 100644 --- a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts @@ -46,6 +46,11 @@ const readyProbe = { providerAttached: true, providerCredentialReady: true, } as const; +const runtimeSelection = { + gatewayName: "nemoclaw-8091", + localTlsDir: "/recorded/gateway/tls", + workspace: "default", +} as const; function probeStdout( parts: { @@ -291,7 +296,13 @@ describe("MCP credential-resolution probe execution gates", () => { ] as const)( "fails closed before sandbox traffic unless policy and provider readiness are all true [case %#] (#6379)", (readiness, expectedDetail) => { - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readiness); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readiness, + runtimeSelection, + ); expect(probe).toMatchObject({ ok: null }); expect(probe.detail).toContain(expectedDetail); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); @@ -299,7 +310,13 @@ describe("MCP credential-resolution probe execution gates", () => { ); it("skips without contacting the sandbox when the adapter is not declared (#6379)", () => { - const probe = probeCredentialResolution("alpha", baseEntry, undefined, readyProbe); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + undefined, + readyProbe, + runtimeSelection, + ); expect(probe).toEqual({ ok: null, detail: "MCP adapter is not declared" }); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); }); @@ -310,6 +327,7 @@ describe("MCP credential-resolution probe execution gates", () => { { ...baseEntry, addState: "preflighted" }, "mcporter", readyProbe, + runtimeSelection, ); expect(probe).toEqual({ ok: null, detail: "add transaction incomplete" }); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); @@ -321,6 +339,7 @@ describe("MCP credential-resolution probe execution gates", () => { { ...baseEntry, url: "http://api.githubcopilot.com/mcp/" }, "mcporter", readyProbe, + runtimeSelection, ); expect(probe).toEqual({ ok: null, detail: "no credential binding or safe endpoint to probe" }); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); @@ -341,13 +360,20 @@ describe("MCP credential-resolution probe execution gates", () => { stderr: "", }; }); - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readyProbe); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readyProbe, + runtimeSelection, + ); expect(probe).toEqual({ ok: true, httpStatus: 200, controlHttpStatus: 401 }); expect(mocks.executeSandboxCommand).toHaveBeenCalledTimes(1); const [, command] = mocks.executeSandboxCommand.mock.calls[0]; expect(command).toContain("openshell:resolve:env:v11_GITHUB_TOKEN"); expect(command).not.toContain("authorization: Bearer openshell:resolve:env:GITHUB_TOKEN"); expect(command).toContain(MCP_PROBE_CONTROL_BEARER); + expect(mocks.executeSandboxCommand.mock.calls[0]?.[2]).toEqual({ runtimeSelection }); }); it("reuses a status observation instead of starting a second revision check (#10079)", () => { @@ -366,7 +392,14 @@ describe("MCP credential-resolution probe execution gates", () => { }; }); - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readyProbe, "v12"); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readyProbe, + runtimeSelection, + "v12", + ); expect(probe).toEqual({ ok: true, httpStatus: 200, controlHttpStatus: 401 }); expect(mocks.observeMcpCredentialRevision).not.toHaveBeenCalled(); @@ -378,7 +411,13 @@ describe("MCP credential-resolution probe execution gates", () => { it("does not probe with an identityless canonical placeholder (#10079)", () => { mocks.observeMcpCredentialRevision.mockReturnValue("canonical"); - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readyProbe); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readyProbe, + runtimeSelection, + ); expect(probe).toEqual({ ok: null, diff --git a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts index 401aaf466bb..dbc58f100e8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts +++ b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts @@ -54,6 +54,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import { authorizationValue } from "./mcp-bridge-adapter-status"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; import { observeMcpCredentialRevision } from "./mcp-bridge-provider"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision, McpCredentialRevisionObservation, @@ -388,6 +389,7 @@ export function probeCredentialResolution( entry: McpBridgeEntry, adapter: AgentMcpAdapter | undefined, readiness: CredentialResolutionProbeReadiness, + runtimeSelection: McpProviderInspectionRuntimeSelection, observedCredentialRevision?: McpCredentialRevisionObservation, ): CredentialResolutionProbe { if (!adapter) return { ok: null, detail: "MCP adapter is not declared" }; @@ -406,7 +408,7 @@ export function probeCredentialResolution( let credentialRevision = observedCredentialRevision; if (credentialRevision === undefined) { try { - credentialRevision = observeMcpCredentialRevision(sandboxName, entry); + credentialRevision = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); } catch { return { ok: null, @@ -429,6 +431,6 @@ export function probeCredentialResolution( } const probeCommand = buildCredentialResolutionProbeCommand(entry, adapter, credentialRevision); if (!probeCommand) return { ok: null, detail: "no credential binding or safe endpoint to probe" }; - const result = executeSandboxCommand(sandboxName, probeCommand.command); + const result = executeSandboxCommand(sandboxName, probeCommand.command, { runtimeSelection }); return classifyCredentialResolutionProbe(result, entry, probeCommand.resultMarker); } diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index 379812df5ec..661010aa674 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -16,8 +16,10 @@ import { attachProvider, detachMissingProviderReference, ensureMcpBridgeProviderProfile, + getMcpProviderInspectionRuntimeSelection, refreshMcpProviderEnvironment, type McpCredentialRevisionObservation, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, observeMcpCredentialRevision, preflightMcpEntryTargets, @@ -77,7 +79,12 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P const bridges = bridgeState(sandbox); const targets = server ? [[server, bridges[server]] as const] : Object.entries(bridges); if (targets.length === 0) { - if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + if (adapter === "hermes-config") { + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } console.log(` No MCP servers for sandbox '${sandboxName}'.`); return; } @@ -92,20 +99,29 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P } assertAuthenticatedBridgeEntry(entry); } + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); const targetEntries = targets .map(([, entry]) => entry) .filter((entry): entry is McpBridgeEntry => !!entry); // Hermes shields posture is host-visible. Refuse before DNS, gateway // recovery/selection, provider inspection, or any lifecycle mutation. - assertMcpAdapterConfigMutationsAllowed(sandboxName, sandbox, targetEntries); + assertMcpAdapterConfigMutationsAllowed( + sandboxName, + sandbox, + targetEntries, + providerRuntimeSelection, + ); const resolvedByServer = await preflightMcpEntryTargets(targetEntries); assertMcpCredentialBoundaryRuntimeVersion(); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); // Validate every generated policy name before inspecting or updating any provider. for (const entry of targetEntries) assertGeneratedPolicyMutationSafe(sandboxName, entry); const providerInspectionByServer = new Map(); for (const entry of targetEntries) { - providerInspectionByServer.set(entry.server, assertMcpProviderRecoverable(entry)); + providerInspectionByServer.set( + entry.server, + assertMcpProviderRecoverable(entry, providerRuntimeSelection), + ); } const missingProviderEntries = targetEntries.filter( (entry) => providerInspectionByServer.get(entry.server)?.exists === false, @@ -116,15 +132,20 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P // already proven absent; no live credential is removed before the runtime // capability probe, and the durable bridge manifest is retained on failure. for (const entry of missingProviderEntries) { - detachMissingProviderReference(sandboxName, entry); + detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); } - assertMcpAdapterMutationRuntimeCapabilities(sandboxName, sandbox, targetEntries); + assertMcpAdapterMutationRuntimeCapabilities( + sandboxName, + sandbox, + targetEntries, + providerRuntimeSelection, + ); for (const entry of missingProviderEntries) { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); } // Inspect registered providers once before the first mutation. Per-entry // checks below inspect only attached providers at each mutation edge. - assertNoProviderCredentialCollisions(sandboxName, targetEntries); + assertNoProviderCredentialCollisions(sandboxName, targetEntries, providerRuntimeSelection); for (const [name, storedEntry] of targets) { // Validated as a complete authenticated entry before gateway side effects. if (!storedEntry) continue; @@ -133,18 +154,26 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P const adapterEnvValues = resolveCredentialEnv(envRefs); const target = resolvedTargetPins(resolvedByServer, entry); let previousCredentialRevision: McpCredentialRevisionObservation | undefined; - assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); // Revalidate the actual running supervisor before rotating or recreating // credentials. The temporary policy cannot bind the provider until an // endpointless profile is attached. - ensureMcpBridgeProviderProfile(); - applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false }); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); + applyGeneratedPolicy(sandboxName, entry, target, { + bindCredential: false, + runtimeSelection: providerRuntimeSelection, + }); const providerResult = upsertMcpProvider(entry.providerName ?? "", envRefs, { allowExisting: true, expectedProviderId: entry.providerId, + runtimeSelection: providerRuntimeSelection, prepareMutation: (action) => { if (action === "update") { - previousCredentialRevision = observeMcpCredentialRevision(sandboxName, entry); + previousCredentialRevision = observeMcpCredentialRevision( + sandboxName, + entry, + providerRuntimeSelection, + ); } }, }); @@ -162,25 +191,33 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P writeBridgeEntry(sandboxName, refreshedEntry); entry = refreshedEntry; } - assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); if (providerResult.action === "updated" && previousCredentialRevision === undefined) { throw new McpBridgeError( `Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`, ); } - attachProvider(sandboxName, entry); - applyGeneratedPolicy(sandboxName, entry, target); - refreshMcpProviderEnvironment(entry); - const entryAdapter = (entry.adapter as AgentMcpAdapter | undefined) ?? adapter; - const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { - ...(providerResult.action === "updated" - ? { previousRevision: previousCredentialRevision } - : {}), + attachProvider(sandboxName, entry, providerRuntimeSelection); + applyGeneratedPolicy(sandboxName, entry, target, { + runtimeSelection: providerRuntimeSelection, }); + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); + const entryAdapter = (entry.adapter as AgentMcpAdapter | undefined) ?? adapter; + const credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { + ...(providerResult.action === "updated" + ? { previousRevision: previousCredentialRevision } + : {}), + }, + ); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, entryAdapter, entry, + providerRuntimeSelection, adapterEnvValues, credentialRevision, { replaceExisting: true }, @@ -192,7 +229,11 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P }); console.log(` Refreshed MCP server '${name}'.`); } - if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + if (adapter === "hermes-config") { + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } } export async function restoreExistingMcpBridgeRuntime( @@ -201,6 +242,7 @@ export async function restoreExistingMcpBridgeRuntime( options: { lifecyclePhase?: "active-mutation" | "teardown-rollback"; applyPolicy?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } = {}, ): Promise { if (entries.length === 0) return; @@ -209,22 +251,34 @@ export async function restoreExistingMcpBridgeRuntime( if (options.lifecyclePhase !== "teardown-rollback") { assertMcpCredentialBoundaryRuntimeVersion(); } - await ensureSandboxGatewaySelected(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); assertMcpDestroyNotPending(sandbox); if (options.lifecyclePhase === "teardown-rollback") { // A failed delete/rebuild must be able to restore a backward-compatible // Deep Agents entry on the same old image it just scrubbed. New/rebuilt // images use the default path and must prove the current marker before any // policy, provider, attachment, or adapter mutation. - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); } else { - assertMcpAdapterMutationRuntimeCapabilities(sandboxName, sandbox, entries); + assertMcpAdapterMutationRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); } const defaultAdapter = getBridgeAdapter(getSandboxAgent(sandbox)); for (const entry of entries) { assertGeneratedPolicyMutationSafe(sandboxName, entry); - const provider = assertMcpProviderRecoverable(entry); + const provider = assertMcpProviderRecoverable(entry, providerRuntimeSelection); if (provider.exists !== true) { throw new McpBridgeError( `OpenShell provider '${entry.providerName}' is missing. Runtime restoration refuses to create or rotate credentials; run explicit MCP restart after exporting '${entry.env[0]}'.`, @@ -235,26 +289,34 @@ export async function restoreExistingMcpBridgeRuntime( // pre-existing collision on a later entry cannot follow an earlier restore // mutation. Per-entry attached-provider checks detect new collisions at each // restore mutation edge. - assertNoProviderCredentialCollisions(sandboxName, entries); + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); for (const entry of entries) { - assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); - ensureMcpBridgeProviderProfile(); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); if (options.applyPolicy !== false) { applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { bindCredential: false, + runtimeSelection: providerRuntimeSelection, }); } - attachProvider(sandboxName, entry); + attachProvider(sandboxName, entry, providerRuntimeSelection); if (options.applyPolicy !== false) { - applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry)); + applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { + runtimeSelection: providerRuntimeSelection, + }); } const adapter = (entry.adapter as AgentMcpAdapter | undefined) ?? defaultAdapter; - refreshMcpProviderEnvironment(entry); - const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry); + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); + const credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + ); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, adapter, entry, + providerRuntimeSelection, {}, credentialRevision, { @@ -268,6 +330,9 @@ export async function restoreExistingMcpBridgeRuntime( defaultAdapter === "hermes-config" || entries.some((entry) => entry.adapter === "hermes-config") ) { - assertHermesMcpRuntimeIntent(sandboxName, { entries }); + assertHermesMcpRuntimeIntent(sandboxName, { + entries, + runtimeSelection: providerRuntimeSelection, + }); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts b/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts index aa73e878be4..8e4a0bb0781 100644 --- a/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts +++ b/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts @@ -9,6 +9,7 @@ import { assertAgentMcpTeardownRuntimeCapability, } from "./mcp-bridge-adapters"; import { isAgentMcpAdapter } from "./mcp-bridge-contracts"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { getBridgeAdapter, getSandboxAgent } from "./mcp-bridge-state"; function adaptersForEntries( @@ -26,9 +27,10 @@ export function assertMcpAdapterMutationRuntimeCapabilities( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { for (const adapter of adaptersForEntries(sandbox, entries)) { - assertAgentMcpMutationRuntimeCapability(sandboxName, adapter); + assertAgentMcpMutationRuntimeCapability(sandboxName, adapter, runtimeSelection); } } @@ -42,9 +44,10 @@ export function assertMcpAdapterConfigMutationsAllowed( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpBridgeEntry[], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): void { for (const adapter of adaptersForEntries(sandbox, entries)) { - assertAgentMcpConfigMutationAllowed(sandboxName, adapter); + assertAgentMcpConfigMutationAllowed(sandboxName, adapter, runtimeSelection); } } @@ -52,8 +55,9 @@ export function assertMcpAdapterTeardownRuntimeCapabilities( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { for (const adapter of adaptersForEntries(sandbox, entries)) { - assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter); + assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter, runtimeSelection); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-state.ts b/src/lib/actions/sandbox/mcp-bridge-state.ts index 291c2efa813..4695ae5c7e8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-state.ts +++ b/src/lib/actions/sandbox/mcp-bridge-state.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { type AgentDefinition, type AgentMcpAdapter, loadAgent } from "../../agent/defs"; -import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; +import { + recoverNamedGatewayRuntime, + replaceOpenShellRuntimeSelectionEnv, +} from "../../gateway-runtime-action"; import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { getSandboxTargetGatewayName } from "./gateway-target"; @@ -234,10 +238,14 @@ export function removeBridgeEntry(sandboxName: string, server: string): void { setBridgeState(sandboxName, bridges); } -export async function ensureSandboxGatewaySelected(sandboxName: string): Promise { +export async function ensureSandboxGatewaySelected( + sandboxName: string, + runtimeSelection: OpenShellRuntimeSelection, +): Promise { const gatewayName = getSandboxTargetGatewayName(sandboxName); const recovery = await recoverNamedGatewayRuntime({ gatewayName, + runtimeSelection, }); if (!recovery.recovered || recovery.after.state !== "healthy_named") { throw new McpBridgeError( @@ -248,5 +256,5 @@ export async function ensureSandboxGatewaySelected(sandboxName: string): Promise // the sandbox's recorded gateway. The globally selected gateway is mutable // shared metadata and another NemoClaw process may select a sibling between // this health check and the provider/policy mutation. - process.env.OPENSHELL_GATEWAY = gatewayName; + replaceOpenShellRuntimeSelectionEnv(process.env, runtimeSelection); } diff --git a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts index 1757a5dc0f4..609b3a84e62 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts @@ -8,6 +8,8 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; + const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] .filter(Boolean) @@ -25,7 +27,127 @@ afterEach(() => { tempHomes.clear(); }); -describe("cross-agent MCP status boundaries", () => { +describe("cross-agent MCP status boundaries", testTimeoutOptions(15_000), () => { + it("pins provider and policy reads to the recorded runtime (#10514)", () => { + const home = createTempHome("nemoclaw-mcp-status-provider-target-"); + const script = String.raw` +process.env.HOME = ${JSON.stringify(home)}; +process.env.OPENSHELL_GATEWAY = "ambient-gateway"; +process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://other.example.test"; +process.env.OPENSHELL_GATEWAY_INSECURE = "true"; +process.env.OPENSHELL_LOCAL_TLS_DIR = "/tmp/ambient-client-tls"; +process.env.OPENSHELL_TOKEN = "ambient-token"; +process.env.OPENSHELL_WORKSPACE = "ambient-workspace"; +const registry = require("./src/lib/state/registry.js"); +const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); +const openshellRuntime = require("./src/lib/adapters/openshell/runtime.js"); +const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +const providerEnvironments = []; +const policyEnvironments = []; +openshellRuntime.captureResolvedOpenshell = (args, options) => { + if (args[0] !== "policy" || args[1] !== "get") { + throw new Error("Unexpected OpenShell capture: " + args.join(" ")); + } + policyEnvironments.push(options.env); + const output = "Version: 1\nHash: sha256:current\n---\nversion: 1\nnetwork_policies: {}\n"; + return { status: 0, output, stdout: output, stderr: "" }; +}; +providerCommands.setProviderCommandRuntimeHooksForTest({ runOpenshell: (args, options) => { + providerEnvironments.push(options.env); + if (args[0] === "provider" && args[1] === "get") { + return { + status: 0, + stdout: "Id: 11111111-2222-4333-8444-555555555555\nType: nemoclaw-mcp-v1\nResource version: 4\nCredential keys: GITHUB_TOKEN\n", + stderr: "", + }; + } + if (args.slice(0, 3).join(" ") === "sandbox provider list") { + return { + status: 0, + stdout: "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-github nemoclaw-mcp-v1 1 0\n", + stderr: "", + }; + } + throw new Error("Unexpected OpenShell call: " + args.join(" ")); +} }); +gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ + recovered: true, + attempted: false, + before: { state: "healthy_named" }, + after: { state: "healthy_named" }, +}); +processRecovery.executeSandboxExecCommand = () => ({ status: 0, stdout: "v11", stderr: "" }); +processRecovery.executeSandboxCommand = () => ({ status: 0, stdout: "registered", stderr: "" }); +registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, + mcp: { bridges: { github: { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + allowedIps: ["8.8.8.8"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-06-01T00:00:00.000Z", + } } }, +}); +require("./src/lib/actions/sandbox/mcp-bridge-status.js").statusMcpBridge("alpha", "github").then( + () => process.stdout.write(JSON.stringify({ + processEnvironment: Object.fromEntries( + Object.entries(process.env).filter(([name]) => name.startsWith("OPENSHELL_")), + ), + providerEnvironments, + policyEnvironments, + })), + (error) => process.stderr.write(error.stack || error.message, () => process.exit(1)), +); +`; + const result = spawnSync(process.execPath, ["-e", script], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, HOME: home, NODE_OPTIONS: sourceNodeOptions }, + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + const payload = JSON.parse(result.stdout) as { + policyEnvironments: Array>; + processEnvironment: Record; + providerEnvironments: Array>; + }; + expect(payload.processEnvironment).toEqual({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_WORKSPACE: "default", + }); + expect(payload.providerEnvironments.length).toBeGreaterThan(0); + expect( + payload.providerEnvironments.every( + (environment) => + environment.OPENSHELL_GATEWAY === "nemoclaw-9090" && + environment.OPENSHELL_WORKSPACE === "default" && + !Object.hasOwn(environment, "OPENSHELL_GATEWAY_ENDPOINT"), + ), + ).toBe(true); + expect(payload.policyEnvironments).toHaveLength(1); + expect(payload.policyEnvironments[0]).toMatchObject({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_WORKSPACE: "default", + }); + expect( + [ + "OPENSHELL_GATEWAY_ENDPOINT", + "OPENSHELL_GATEWAY_INSECURE", + "OPENSHELL_LOCAL_TLS_DIR", + "OPENSHELL_TOKEN", + ].every((name) => !Object.hasOwn(payload.policyEnvironments[0] ?? {}, name)), + ).toBe(true); + }); + it("reports unsupported persisted boundaries without starting an unsafe sandbox child", () => { const home = createTempHome("nemoclaw-mcp-status-risk-"); const script = String.raw` @@ -66,6 +188,8 @@ processRecovery.executeSandboxCommand = () => { registry.registerSandbox({ name: "alpha", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { fake: { server: "fake", agent: "openclaw", @@ -132,8 +256,17 @@ const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); const script = ` process.env.HOME = ${JSON.stringify(home)}; const registry = require("./src/lib/state/registry.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => { + throw new Error("runtime selection resolved for empty MCP status"); +}; const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes" }); +registry.registerSandbox({ + name: "hermes-sandbox", + agent: "hermes", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, +}); bridge.dispatchMcpBridgeCommand("hermes-sandbox", ["status", "--json"]).then( () => process.exit(0), (error) => { diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 09c024b10b2..65d446a1e3e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -48,7 +48,11 @@ let providerInspectionState = "present"; let providerCredentialKey = "GITHUB_TOKEN"; let persistedCredentialRevision = "v11"; const hermesIntentPayloads = []; -providerCommands.runOpenshellProviderCommand = (args) => { +const providerCommandRuntimeSelections = []; +providerCommands.runOpenshellProviderCommand = (args, options) => { + if (options?.runtimeSelection) { + providerCommandRuntimeSelections.push(options.runtimeSelection); + } if (args[0] === "provider" && args[1] === "get") { if (providerInspectionState === "absent") { return { status: 1, stdout: "", stderr: "provider not found" }; @@ -87,6 +91,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { let activePolicyState = "match"; policies.getPresetContentGatewayState = () => activePolicyState; const executedSandboxCommands = []; +const executedSandboxOptions = []; let providerCredentialObservation = "v11"; let credentialObservationCount = 0; processRecovery.executeSandboxExecCommand = () => { @@ -97,8 +102,9 @@ processRecovery.executeSandboxExecCommand = () => { stderr: "", }; }; -processRecovery.executeSandboxCommand = (sandboxName, command) => { +processRecovery.executeSandboxCommand = (sandboxName, command, options) => { executedSandboxCommands.push(command); + executedSandboxOptions.push(options); if (command.includes("NEMOCLAW_MCP_PROBE")) { const resultMarker = command.match(/__NEMOCLAW_SANDBOX_EXEC_STARTED___[0-9a-f]{32}/)?.[0]; if (!resultMarker) throw new Error("credential probe result marker missing"); @@ -141,6 +147,8 @@ processRecovery.executeSandboxCommand = (sandboxName, command) => { registry.registerSandbox({ name: "alpha", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { github: { server: "github", agent: "openclaw", @@ -668,15 +676,32 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 hasDiscovery: !!status.toolDiscovery, probeCommands: executedSandboxCommands.filter((c) => c.includes("NEMOCLAW_MCP_PROBE")).length, discoveryCommands: executedSandboxCommands.filter((c) => c.includes("mcp-tool-discovery-runtime")).length, + selections: executedSandboxOptions.map((options) => options?.runtimeSelection), })); `, ); - expect(JSON.parse(stdout)).toEqual({ + const payload = JSON.parse(stdout) as { + discoveryCommands: number; + hasDiscovery: boolean; + hasResolution: boolean; + probeCommands: number; + selections: Array<{ gatewayName?: string; workspace?: string }>; + }; + expect(payload).toEqual({ hasResolution: true, hasDiscovery: true, probeCommands: 1, discoveryCommands: 1, + selections: expect.arrayContaining([ + expect.objectContaining({ gatewayName: "nemoclaw-9090", workspace: "default" }), + ]), }); + expect( + payload.selections.every( + (selection) => + selection.gatewayName === "nemoclaw-9090" && selection.workspace === "default", + ), + ).toBe(true); }); it("requires a named server for --tools and renders the discovered names (#6901)", () => { @@ -750,6 +775,36 @@ describe("MCP add post-add credential-resolution probe", () => { expect(payload.exitCode).toBe(0); }); + it("reuses the add target for the post-add status probe (#10514)", () => { + const home = createTempHome("nemoclaw-mcp-resolution-add-target-"); + const { stdout } = runHarness( + home, + String.raw` + const addRestart = require("./src/lib/actions/sandbox/mcp-bridge-add-restart.js"); + const addRuntimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", + localTlsDir: "/authority/tls", + }; + addRestart.addMcpBridge = async () => addRuntimeSelection; + await bridge.dispatchMcpBridgeCommand("alpha", [ + "add", "github", "--url", "https://api.githubcopilot.com/mcp/", "--env", "GITHUB_TOKEN", + ]); + const commandSelections = executedSandboxOptions + .map((options) => options?.runtimeSelection) + .filter(Boolean); + const observedSelections = [...providerCommandRuntimeSelections, ...commandSelections]; + process.stdout.write(JSON.stringify({ + observedCount: observedSelections.length, + reused: observedSelections.every((selection) => selection === addRuntimeSelection), + })); +`, + ); + const payload = JSON.parse(stdout) as { observedCount: number; reused: boolean }; + expect(payload.observedCount).toBeGreaterThan(0); + expect(payload.reused).toBe(true); + }); + it("skips post-add probe traffic when policy verification is absent, drifted, or unknown (#6379)", () => { const home = createTempHome("nemoclaw-mcp-resolution-add-policy-gate-"); const { stdout } = runHarness( diff --git a/src/lib/actions/sandbox/mcp-bridge-status-state.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-state.test.ts index d989c8178ae..9775313e771 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-state.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-state.test.ts @@ -40,6 +40,8 @@ const registry = require("./src/lib/state/registry.js"); registry.registerSandbox({ name: "openclaw-sandbox", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { first: { server: "first", url: "https://8.8.8.8/mcp", @@ -86,6 +88,8 @@ for (const [index, marker] of markers.entries()) { registry.registerSandbox({ name, agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { github: { server: "github", @@ -149,6 +153,8 @@ providerCommands.runOpenshellProviderCommand = (args) => { registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: {}, managedServerNames: ["retired"] }, }); const status = require("./src/lib/actions/sandbox/mcp-bridge-status.js"); @@ -194,7 +200,12 @@ const status = require("./src/lib/actions/sandbox/mcp-bridge-status.js"); process.env.HOME = ${JSON.stringify(home)}; const registry = require("./src/lib/state/registry.js"); const status = require("./src/lib/actions/sandbox/mcp-bridge-status.js"); -registry.registerSandbox({ name: "openclaw-sandbox", agent: "openclaw" }); +registry.registerSandbox({ + name: "openclaw-sandbox", + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, +}); (async () => { let invalid; try { @@ -262,6 +273,8 @@ processRecovery.executeSandboxCommand = (_sandboxName, command) => { registry.registerSandbox({ name: "custom-root-status", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { github: { server: "github", agent: "openclaw", @@ -334,6 +347,8 @@ processRecovery.executeSandboxCommand = () => ({ status: 0, stdout: "registered" registry.registerSandbox({ name: "persisted-status", agent: "current-disabled", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { direct: { server: "direct", diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index a4580968b40..5ea5ac5df92 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -18,12 +18,14 @@ import { import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; import { getPolicyPresence, getRegisteredGeneratedPolicy } from "./mcp-bridge-policy"; import { + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, observeMcpCredentialRevision, providerAttached, providerMatchesCredential, providerShapeDetail, } from "./mcp-bridge-provider"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision, McpCredentialRevisionObservation, @@ -90,6 +92,7 @@ function getAdapterRegistration( sandboxName: string, adapter: AgentMcpAdapter | undefined, entry: McpBridgeEntry | undefined, + runtimeSelection: McpProviderInspectionRuntimeSelection, hermesReconciliation?: HermesMcpReconciliationResult, credentialRevision?: McpAttachedCredentialRevision, credentialObservationDetail?: string, @@ -118,7 +121,7 @@ function getAdapterRegistration( : adapter === "hermes-config" ? buildHermesMcpStatusCommand(entry, credentialRevision) : buildDeepAgentsMcpStatusCommand(entry, credentialRevision); - const result = executeSandboxCommand(sandboxName, command); + const result = executeSandboxCommand(sandboxName, command, { runtimeSelection }); if (!result) return { registered: null, detail: "sandbox unreachable" }; if (result.status === 0) { const output = result.stdout.trim(); @@ -148,6 +151,8 @@ export interface McpBridgeStatusOptions { * layer restricts this live operation to an explicitly named server. */ discoverTools?: boolean; + /** Reuse the operation-scoped OpenShell target when status closes another lifecycle action. */ + runtimeSelection?: McpProviderInspectionRuntimeSelection; } function attachedCredentialRevision( @@ -185,9 +190,6 @@ export async function statusMcpBridge( const sandbox = getSandboxOrThrow(sandboxName); const agent = getSandboxAgent(sandbox); const bridges = bridgeState(sandbox); - if (Object.keys(bridges).length > 0) { - await ensureSandboxGatewaySelected(sandboxName); - } const selectedEntry = server !== undefined && Object.hasOwn(bridges, server) ? bridges[server] : undefined; const entries: Array<[string, McpBridgeEntry | undefined]> = @@ -227,12 +229,23 @@ export async function statusMcpBridge( }, ]; } + const hasHermesManagedIntent = + agent.name === "hermes" && (sandbox.mcp?.managedServerNames?.length ?? 0) > 0; + if (entries.length === 0 && !hasHermesManagedIntent) return []; + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + if (Object.keys(bridges).length > 0) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const credentialObservations = new Map(); for (const [name, entry] of entries) { if (!entry || storedCredentialWarning(entry) !== undefined) continue; try { - credentialObservations.set(name, observeMcpCredentialRevision(sandboxName, entry)); + credentialObservations.set( + name, + observeMcpCredentialRevision(sandboxName, entry, providerRuntimeSelection), + ); } catch { credentialObservations.set(name, null); } @@ -260,7 +273,10 @@ export async function statusMcpBridge( state: "error" as const, detail: hermesCredentialObservationDetail, } - : inspectHermesMcpRuntimeIntent(sandboxName, { credentialRevisions }) + : inspectHermesMcpRuntimeIntent(sandboxName, { + credentialRevisions, + runtimeSelection: providerRuntimeSelection, + }) : undefined; if (entries.length === 0 && hermesReconciliation && !hermesReconciliation.ok) { throw new McpBridgeError( @@ -286,7 +302,7 @@ export async function statusMcpBridge( return entries.map(([name, entry]) => { const support = entry ? getPersistedBridgeSupport(entry) : getSupportSummary(agent); const registeredPolicy = getRegisteredGeneratedPolicy(sandboxName, entry); - const policyPresence = getPolicyPresence(sandboxName, entry); + const policyPresence = getPolicyPresence(sandboxName, entry, providerRuntimeSelection); const hasCredentialBinding = !!entry && Array.isArray(entry.env) && @@ -299,7 +315,7 @@ export async function statusMcpBridge( ) : []; const expectedCredential = entry?.env.length === 1 ? entry.env[0] : undefined; - const providerInspection = inspectMcpProvider(entry?.providerName); + const providerInspection = inspectMcpProvider(entry?.providerName, providerRuntimeSelection); const providerCredentialReady = providerMatchesCredential( providerInspection, expectedCredential, @@ -310,7 +326,7 @@ export async function statusMcpBridge( expectedCredential, entry?.providerId, ); - const attached = providerAttached(sandboxName, entry?.providerName); + const attached = providerAttached(sandboxName, entry?.providerName, providerRuntimeSelection); const warnings: string[] = []; let credentialWarning: string | undefined; if (entry) { @@ -349,6 +365,7 @@ export async function statusMcpBridge( sandboxName, support.adapter, entry, + providerRuntimeSelection, hermesReconciliation, credentialRevision, observationDetail, @@ -374,6 +391,7 @@ export async function statusMcpBridge( entry, support.adapter, readiness, + providerRuntimeSelection, credentialRevision, ) : undefined; @@ -392,7 +410,13 @@ export async function statusMcpBridge( detail: "tool discovery skipped: the unsupported legacy credential may still be attached to fresh sandbox children", } - : discoverMcpTools(sandboxName, entry, support.adapter, readiness) + : discoverMcpTools( + sandboxName, + entry, + support.adapter, + readiness, + providerRuntimeSelection, + ) : undefined; return { server: name, diff --git a/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts b/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts index a50db124efb..3f06021dd61 100644 --- a/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts +++ b/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts @@ -6,6 +6,7 @@ import { shellQuote } from "../../core/shell-quote"; import type { McpBridgeEntry } from "../../state/registry"; import type { McpBridgeStatus } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { CredentialResolutionProbeReadiness } from "./mcp-bridge-resolution-readiness"; import { MCP_RUNTIME_SANITIZED_ENV_VARS, @@ -220,6 +221,7 @@ export function discoverMcpTools( entry: McpBridgeEntry, adapter: AgentMcpAdapter | undefined, readiness: McpToolDiscoveryReadiness, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): NonNullable { if (!adapter) return failure("tool discovery skipped: MCP adapter is not declared"); if (entry.addState) return failure("tool discovery skipped: add transaction is incomplete"); @@ -230,7 +232,7 @@ export function discoverMcpTools( return failure("tool discovery skipped: no valid managed endpoint is available"); } return classifyMcpToolDiscoveryResult( - executeSandboxCommand(sandboxName, discoveryCommand.command), + executeSandboxCommand(sandboxName, discoveryCommand.command, { runtimeSelection }), entry, discoveryCommand.resultMarker, ); diff --git a/src/lib/actions/sandbox/mcp-bridge.ts b/src/lib/actions/sandbox/mcp-bridge.ts index d501611c7c2..4ef1d47ce66 100644 --- a/src/lib/actions/sandbox/mcp-bridge.ts +++ b/src/lib/actions/sandbox/mcp-bridge.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import type { McpScrubbedAdapterEntry } from "./mcp-bridge-adapter-teardown"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider"; import { addMcpBridge as addMcpBridgeLifecycle } from "./mcp-bridge-add-restart"; import { type McpBridgeAddOptions, @@ -89,7 +90,7 @@ export { statusMcpBridge }; export async function addMcpBridge( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise { return addMcpBridgeLifecycle(sandboxName, options); } @@ -107,14 +108,20 @@ export async function removeMcpBridge( export async function prepareMcpBridgesForAbsentSandboxDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; + } = {}, ): Promise { return prepareMcpBridgesForAbsentSandboxDestroyLifecycle(sandboxName, options); } export async function prepareMcpBridgesForDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; + } = {}, ): Promise { return prepareMcpBridgesForDestroyLifecycle(sandboxName, options); } @@ -136,33 +143,38 @@ export async function finalizeMcpBridgesAfterSandboxDelete( export async function prepareMcpBridgesForAbsentSandboxRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - return prepareMcpBridgesForAbsentSandboxRebuildLifecycle(sandboxName); + return prepareMcpBridgesForAbsentSandboxRebuildLifecycle(sandboxName, runtimeSelection); } export async function prepareMcpBridgesForRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - return prepareMcpBridgesForRebuildLifecycle(sandboxName); + return prepareMcpBridgesForRebuildLifecycle(sandboxName, runtimeSelection); } export async function reattachMcpProvidersAfterRebuildAbort( sandboxName: string, entries: readonly McpBridgeEntry[], scrubbedAdapterEntries: readonly McpScrubbedAdapterEntry[] = [], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { return reattachMcpProvidersAfterRebuildAbortLifecycle( sandboxName, entries, scrubbedAdapterEntries, + runtimeSelection, ); } export async function restoreMcpBridgesAfterRebuild( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - return restoreMcpBridgesAfterRebuildLifecycle(sandboxName, entries); + return restoreMcpBridgesAfterRebuildLifecycle(sandboxName, entries, runtimeSelection); } function parseJsonFlag(args: string[]): { json: boolean; rest: string[] } { @@ -193,12 +205,17 @@ function parseToolsFlag(args: string[]): { tools: boolean; rest: string[] } { * nonzero exit here would break scripted adds mid-remediation; `mcp status * ` remains the authoritative recheck. */ -async function reportAddCredentialResolution(sandboxName: string, server: string): Promise { +async function reportAddCredentialResolution( + sandboxName: string, + server: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): Promise { let probe: McpBridgeStatus["provider"]["credentialResolution"]; let credentialEnvName: string | undefined; try { const [status] = await statusMcpBridge(sandboxName, server, { probeCredentialResolution: true, + runtimeSelection, }); probe = status?.provider.credentialResolution; credentialEnvName = status?.env.names[0]; @@ -314,9 +331,11 @@ export async function dispatchMcpBridgeCommand( 2, ); const options = parseMcpAddArgs(addRest); - await addMcpBridge(sandboxName, options); + const runtimeSelection = await addMcpBridge(sandboxName, options); console.log(` MCP server '${options.server}' added to sandbox '${sandboxName}'.`); - if (probe !== false) await reportAddCredentialResolution(sandboxName, options.server); + if (probe !== false) { + await reportAddCredentialResolution(sandboxName, options.server, runtimeSelection); + } return; } case "list": { diff --git a/src/lib/actions/sandbox/policy-get.ts b/src/lib/actions/sandbox/policy-get.ts index 976f1237eb2..59b3f2fe900 100644 --- a/src/lib/actions/sandbox/policy-get.ts +++ b/src/lib/actions/sandbox/policy-get.ts @@ -7,6 +7,7 @@ import { captureRecordedSandboxBasePolicy, parseCurrentPolicy, } from "../../policy/index"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { runCapture } from "../../runner"; export interface PolicyGetResult { @@ -17,12 +18,16 @@ export interface PolicyGetResult { /** Read the round-trippable OpenShell base policy and strip its metadata header. */ export function getSandboxPolicy( sandboxName: string, - options: { recordedGatewayOperation?: string } = {}, + options: { + recordedGatewayOperation?: string; + runtimeSelection?: OpenShellRuntimeSelection; + } = {}, ): PolicyGetResult { if (options.recordedGatewayOperation) { const yaml = captureRecordedSandboxBasePolicy( sandboxName, options.recordedGatewayOperation, + options.runtimeSelection, ); return { raw: yaml, yaml }; } diff --git a/src/lib/adapters/openshell/policy-state.test.ts b/src/lib/adapters/openshell/policy-state.test.ts index 1fbe75d0763..4193c8f2443 100644 --- a/src/lib/adapters/openshell/policy-state.test.ts +++ b/src/lib/adapters/openshell/policy-state.test.ts @@ -13,6 +13,7 @@ import { inspectSandboxPolicy, isPolicyObservationError, policyStateInternals, + submitSandboxPolicyFile, } from "./policy-state"; function capture(stdout: string, overrides: Record = {}) { @@ -85,6 +86,82 @@ describe("OpenShell policy observation", () => { expect(captureSandboxBasePolicy("alpha", "nemoclaw")).toBe("version: 1\nnetwork_policies: {}"); }); + it("uses the selected policy runtime for every sandbox policy read (#10514)", () => { + vi.stubEnv("XDG_CONFIG_HOME", "/tmp/openshell-config"); + vi.stubEnv("OPENSHELL_WORKSPACE", "ambient-workspace"); + vi.spyOn(openshellRuntime, "buildOpenShellSubprocessEnv").mockReturnValue({ + OPENSHELL_GATEWAY: "ambient-gateway", + OPENSHELL_GATEWAY_ENDPOINT: "https://other.example.test", + OPENSHELL_GATEWAY_INSECURE: "true", + OPENSHELL_LOCAL_TLS_DIR: "/tmp/ambient-tls", + OPENSHELL_TOKEN: "ambient-token", + OPENSHELL_WORKSPACE: "ambient-workspace", + PATH: "/usr/bin", + }); + const spy = vi.spyOn(openshellRuntime, "captureResolvedOpenshell").mockImplementation( + (args) => + (args.includes("--output") + ? capture( + JSON.stringify({ + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "sandbox", + hash: "sha256:current", + active_version: 7, + policy: { version: 1, network_policies: {} }, + }), + ) + : capture( + "Version: 1\nHash: sha256:current\n---\nversion: 1\nnetwork_policies: {}\n", + )) as never, + ); + const runtimeSelection = { + gatewayName: "recorded-gateway", + localTlsDir: "/tmp/recorded-tls", + workspace: "default", + }; + + expect( + inspectSandboxPolicy({ + sandboxName: "alpha", + gatewayName: "recorded-gateway", + runtimeSelection, + }), + ).toEqual( + expect.objectContaining({ + policyIdentity: { hash: "sha256:current", activeVersion: 7 }, + }), + ); + expect(captureSandboxBasePolicy("alpha", "recorded-gateway", runtimeSelection)).toBe( + "version: 1\nnetwork_policies: {}", + ); + expect(captureSandboxBasePolicyRevision("alpha", "recorded-gateway", 7, runtimeSelection)).toBe( + "version: 1\nnetwork_policies: {}", + ); + expect(spy.mock.calls.map(([args]) => args)).toEqual([ + ["policy", "get", "-g", "recorded-gateway", "--full", "--output", "json", "alpha"], + ["policy", "get", "-g", "recorded-gateway", "--base", "alpha"], + ["policy", "get", "-g", "recorded-gateway", "--rev", "7", "--base", "alpha"], + ]); + const expectedRuntimeOptions = { + env: { + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_LOCAL_TLS_DIR: "/tmp/recorded-tls", + OPENSHELL_WORKSPACE: "default", + PATH: "/usr/bin", + XDG_CONFIG_HOME: "/tmp/openshell-config", + }, + replaceEnv: true, + }; + expect( + spy.mock.calls.map(([, options]) => ({ + env: options?.env, + replaceEnv: options?.replaceEnv, + })), + ).toEqual([expectedRuntimeOptions, expectedRuntimeOptions, expectedRuntimeOptions]); + }); + it("reads an immutable base-policy revision through the selected gateway", () => { const spy = vi .spyOn(openshellRuntime, "captureResolvedOpenshell") @@ -107,6 +184,40 @@ describe("OpenShell policy observation", () => { ]); }); + it("submits policy through only the authority-selected OpenShell runtime (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/tmp/hostile-tls"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + localTlsDir: "/tmp/recorded-tls", + workspace: "default", + }; + const spy = vi.spyOn(openshellRuntime, "runOpenshell").mockReturnValue({ status: 0 } as never); + + expect(submitSandboxPolicyFile("alpha", "/tmp/policy.yaml", runtimeSelection).status).toBe(0); + + expect(spy).toHaveBeenCalledWith( + ["policy", "set", "-g", "nemoclaw-9090", "--policy", "/tmp/policy.yaml", "--wait", "alpha"], + expect.objectContaining({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_LOCAL_TLS_DIR: "/tmp/recorded-tls", + OPENSHELL_WORKSPACE: "default", + }), + ignoreError: true, + replaceEnv: true, + }), + ); + const env = spy.mock.calls[0]?.[1]?.env; + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE"); + expect(env).not.toHaveProperty("OPENSHELL_TOKEN"); + }); + it("rejects a metadata-only base policy display", () => { vi.spyOn(openshellRuntime, "captureResolvedOpenshell").mockReturnValue( capture("Version: 13\nHash: sha256:current\n") as never, diff --git a/src/lib/adapters/openshell/policy-state.ts b/src/lib/adapters/openshell/policy-state.ts index 700151c87bd..17035688e15 100644 --- a/src/lib/adapters/openshell/policy-state.ts +++ b/src/lib/adapters/openshell/policy-state.ts @@ -13,6 +13,7 @@ import { buildPolicyGetArgs, buildPolicyGetFullJsonArgs, buildPolicyGetRevisionArgs, + buildPolicySetArgs, } from "../../policy/commands"; import { assertPolicyRequirementContainment, @@ -56,12 +57,18 @@ export function isPolicyObservationError(error: unknown): boolean { interface SandboxPolicyInspectionOptions { readonly sandboxName: string; readonly gatewayName?: string; + readonly runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; } interface ActiveGlobalPolicyInspectionOptions { readonly gatewayName?: string; } +interface PolicyCommandRuntimeSelection { + readonly gatewayName?: string; + readonly runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; +} + function validatePolicyName(name: string, label: string): string { if (!name || typeof name !== "string") { throw new PolicyObservationError( @@ -92,16 +99,24 @@ function failInspection(subject: "sandbox" | "global" | "gateway", reason: strin function captureBoundedOpenShell( args: string[], subject: "sandbox" | "global" | "gateway", - runtimeSelection?: { readonly gatewayName?: string }, + selection?: PolicyCommandRuntimeSelection, ): ReturnType { - const env = openshellRuntime.buildOpenShellSubprocessEnv(); - if (runtimeSelection !== undefined) { - for (const name of ["XDG_CONFIG_HOME", "OPENSHELL_WORKSPACE"] as const) { - const value = process.env[name]; - if (value !== undefined) env[name] = value; - } - if (runtimeSelection.gatewayName !== undefined) { - env.OPENSHELL_GATEWAY = runtimeSelection.gatewayName; + let env = Object.fromEntries( + Object.entries(openshellRuntime.buildOpenShellSubprocessEnv()).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + if (selection !== undefined) { + const configHome = process.env.XDG_CONFIG_HOME; + if (configHome !== undefined) env.XDG_CONFIG_HOME = configHome; + if (selection.runtimeSelection) { + env = openshellRuntime.buildOpenShellRuntimeSelectionEnv(env, selection.runtimeSelection); + } else { + const workspace = process.env.OPENSHELL_WORKSPACE; + if (workspace !== undefined) env.OPENSHELL_WORKSPACE = workspace; + if (selection.gatewayName !== undefined) { + env.OPENSHELL_GATEWAY = selection.gatewayName; + } } } try { @@ -121,9 +136,9 @@ function captureBoundedOpenShell( function capturePolicyCommand( args: string[], subject: "sandbox" | "global" | "gateway", - runtimeSelection?: { readonly gatewayName?: string }, + selection?: PolicyCommandRuntimeSelection, ): { readonly output: string; readonly stdout: string; readonly stderr: string } { - const result = captureBoundedOpenShell(args, subject, runtimeSelection); + const result = captureBoundedOpenShell(args, subject, selection); if ( !isObject(result) || typeof result.output !== "string" || @@ -151,23 +166,27 @@ function capturePolicyCommand( function capturePolicyRead( args: string[], subject: "sandbox" | "global", - runtimeSelection?: { readonly gatewayName?: string }, + selection?: PolicyCommandRuntimeSelection, ): string { - return capturePolicyCommand(args, subject, runtimeSelection).stdout; + return capturePolicyCommand(args, subject, selection).stdout; } /** Inspect the effective policy source for one live sandbox. */ export function inspectSandboxPolicy({ sandboxName, gatewayName, + runtimeSelection, }: SandboxPolicyInspectionOptions): SandboxPolicyInspection { const validatedSandboxName = validatePolicyName(sandboxName, "sandbox name"); const validatedGatewayName = gatewayName === undefined ? undefined : validatePolicyName(gatewayName, "gateway name"); + if (runtimeSelection && runtimeSelection.gatewayName !== validatedGatewayName) { + failInspection("sandbox", "the runtime selection does not match the requested gateway"); + } const raw = capturePolicyRead( buildPolicyGetFullJsonArgs(validatedSandboxName, validatedGatewayName), "sandbox", - { gatewayName: validatedGatewayName }, + { gatewayName: validatedGatewayName, runtimeSelection }, ); try { return parseSandboxPolicyMetadata(raw, validatedSandboxName); @@ -209,12 +228,19 @@ export function inspectActiveGlobalPolicy({ } /** Read one sandbox base policy through the same bounded OpenShell adapter. */ -export function captureSandboxBasePolicy(sandboxName: string, gatewayName: string): string { +export function captureSandboxBasePolicy( + sandboxName: string, + gatewayName: string, + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection, +): string { const validatedGatewayName = validatePolicyName(gatewayName, "gateway name"); + if (runtimeSelection && runtimeSelection.gatewayName !== validatedGatewayName) { + failInspection("sandbox", "the runtime selection does not match the requested gateway"); + } const raw = capturePolicyRead( buildPolicyGetArgs(validatePolicyName(sandboxName, "sandbox name"), validatedGatewayName), "sandbox", - { gatewayName: validatedGatewayName }, + { gatewayName: validatedGatewayName, runtimeSelection }, ); try { return parseOpenShellPolicy(raw).yamlBody; @@ -231,11 +257,15 @@ export function captureSandboxBasePolicyRevision( sandboxName: string, gatewayName: string, revision: number, + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection, ): string { if (!Number.isSafeInteger(revision) || revision < 1) { failInspection("sandbox", "the requested policy revision is invalid"); } const validatedGatewayName = validatePolicyName(gatewayName, "gateway name"); + if (runtimeSelection && runtimeSelection.gatewayName !== validatedGatewayName) { + failInspection("sandbox", "the runtime selection does not match the requested gateway"); + } const raw = capturePolicyRead( buildPolicyGetRevisionArgs( validatePolicyName(sandboxName, "sandbox name"), @@ -243,7 +273,7 @@ export function captureSandboxBasePolicyRevision( revision, ), "sandbox", - { gatewayName: validatedGatewayName }, + { gatewayName: validatedGatewayName, runtimeSelection }, ); try { return parseOpenShellPolicy(raw).yamlBody; @@ -255,19 +285,42 @@ export function captureSandboxBasePolicyRevision( } } +/** Submit one sandbox policy file through an authority-selected OpenShell runtime. */ +export function submitSandboxPolicyFile( + sandboxName: string, + policyFile: string, + runtimeSelection: openshellRuntime.OpenShellRuntimeSelection, +): ReturnType { + const validatedSandboxName = validatePolicyName(sandboxName, "sandbox name"); + const gatewayName = validatePolicyName(runtimeSelection.gatewayName, "gateway name"); + return openshellRuntime.runOpenshell( + buildPolicySetArgs(policyFile, validatedSandboxName, gatewayName), + { + env: openshellRuntime.buildSelectedOpenShellSubprocessEnv(runtimeSelection), + ignoreError: true, + replaceEnv: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); +} + /** Read and fingerprint one sandbox ID without exposing the ID in diagnostics. */ export function inspectOpenShellSandboxIdentityFingerprint(options: { readonly sandboxName: string; readonly gatewayName: string; + readonly runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; }): string { const gatewayName = validatePolicyName(options.gatewayName, "gateway name"); const sandboxName = validatePolicyName(options.sandboxName, "sandbox name"); + if (options.runtimeSelection && options.runtimeSelection.gatewayName !== gatewayName) { + throw new Error("OpenShell sandbox identity target does not match the runtime selection"); + } let result: ReturnType; try { result = captureBoundedOpenShell( ["sandbox", "get", "-g", gatewayName, sandboxName], "sandbox", - { gatewayName }, + { gatewayName, runtimeSelection: options.runtimeSelection }, ); } catch { throw new Error("OpenShell sandbox identity inspection could not run"); diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 871e2d70eeb..28824928626 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -8,11 +8,13 @@ const mocks = vi.hoisted(() => ({ runOpenshell: vi.fn(), })); -vi.mock("../../subprocess-env", () => ({ +vi.mock("../../subprocess-env", async (importOriginal) => ({ + ...(await importOriginal()), buildSubprocessEnv: mocks.buildSubprocessEnv, })); -vi.mock("./runtime", () => ({ +vi.mock("./runtime", async (importOriginal) => ({ + ...(await importOriginal()), runOpenshell: mocks.runOpenshell, })); @@ -66,4 +68,60 @@ describe("OpenShell provider command runtime", () => { ); expect(result).toEqual({ status: 0 }); }); + + it("pins provider commands to the recorded gateway and workspace (#10514)", () => { + mocks.buildSubprocessEnv.mockReturnValue({ + OPENSHELL_GATEWAY: "ambient-gateway", + OPENSHELL_GATEWAY_ENDPOINT: "https://other.example.test", + OPENSHELL_GATEWAY_INSECURE: "true", + OPENSHELL_LOCAL_TLS_DIR: "/var/lib/openshell/ambient-client-tls", + OPENSHELL_TOKEN: "ambient-token", + OPENSHELL_WORKSPACE: "ambient-workspace", + PATH: "/usr/bin", + }); + + runOpenshellProviderCommand(["provider", "get", "alpha-mcp-fake"], { + runtimeSelection: { + gatewayName: "recorded-gateway", + localTlsDir: "/var/lib/openshell/recorded-client-tls", + workspace: "default", + }, + }); + + expect(mocks.runOpenshell).toHaveBeenCalledWith( + ["provider", "get", "alpha-mcp-fake"], + expect.objectContaining({ + env: { + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_LOCAL_TLS_DIR: "/var/lib/openshell/recorded-client-tls", + OPENSHELL_WORKSPACE: "default", + PATH: "/usr/bin", + }, + replaceEnv: true, + }), + ); + }); + + it("does not invent an mTLS directory for a gateway without one (#10514)", () => { + mocks.buildSubprocessEnv.mockReturnValue({ + OPENSHELL_GATEWAY_ENDPOINT: "https://other.example.test", + PATH: "/usr/bin", + }); + + runOpenshellProviderCommand(["provider", "list"], { + runtimeSelection: { gatewayName: "recorded-gateway", workspace: "default" }, + }); + + expect(mocks.runOpenshell).toHaveBeenCalledWith( + ["provider", "list"], + expect.objectContaining({ + env: { + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_WORKSPACE: "default", + PATH: "/usr/bin", + }, + replaceEnv: true, + }), + ); + }); }); diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index e332155a8d0..e0b0ff6aad0 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -3,14 +3,21 @@ import type { StdioOptions } from "node:child_process"; -import { buildSubprocessEnv } from "../../subprocess-env"; -import { OPENSHELL_OPERATION_TIMEOUT_MS, runOpenshell } from "./runtime"; +import { buildOpenShellCommandEnv } from "./command-argv"; +import type { OpenShellRuntimeSelection } from "./runtime-selection"; +import { + OPENSHELL_OPERATION_TIMEOUT_MS, + runOpenshell, +} from "./runtime"; + +export type { OpenShellRuntimeSelection } from "./runtime"; export { OPENSHELL_OPERATION_TIMEOUT_MS }; export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; stdio?: StdioOptions; timeout?: number; }; @@ -26,14 +33,16 @@ export function setProviderCommandRuntimeHooksForTest(hooks: ProviderCommandRunt } export function runOpenshellProviderCommand(args: string[], opts?: ProviderCommandOptions) { + const { runtimeSelection, ...runtimeOptions } = opts ?? {}; const explicitEnv = Object.fromEntries( - Object.entries(opts?.env ?? {}).filter( + Object.entries(runtimeOptions.env ?? {}).filter( (entry): entry is [string, string] => entry[1] !== undefined, ), ); + const env = buildOpenShellCommandEnv(runtimeSelection, explicitEnv); const providerOpts = { - ...opts, - env: buildSubprocessEnv(explicitEnv), + ...runtimeOptions, + env, replaceEnv: true, }; const commandRunner = runtimeHooks.runOpenshell ?? runOpenshell; diff --git a/src/lib/policy/commands.ts b/src/lib/policy/commands.ts index 7bf80324603..868c8689951 100644 --- a/src/lib/policy/commands.ts +++ b/src/lib/policy/commands.ts @@ -8,7 +8,16 @@ export function buildPolicySetCommand( sandboxName: string, gatewayName?: string, ): string[] { - return buildOpenshellCommand([ + return buildOpenshellCommand(buildPolicySetArgs(policyFile, sandboxName, gatewayName)); +} + +/** Set one sandbox policy through an already-selected OpenShell runtime. */ +export function buildPolicySetArgs( + policyFile: string, + sandboxName: string, + gatewayName?: string, +): string[] { + return [ "policy", "set", ...policyGatewayArgs(gatewayName), @@ -16,7 +25,7 @@ export function buildPolicySetCommand( policyFile, "--wait", sandboxName, - ]); + ]; } /** Read the round-trippable base policy before a mutation. */ diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 9812cde34b4..6ba3dad0255 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -17,7 +17,9 @@ import { inspectSandboxPolicy, PolicyObservationError, type SandboxPolicyInspection, + submitSandboxPolicyFile, } from "../adapters/openshell/policy-state"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; import * as openshellResolveModule from "../adapters/openshell/resolve"; import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; @@ -651,18 +653,47 @@ export interface PolicyMutationContext { readonly gatewayName: string; readonly inspection: SandboxPolicyInspection; readonly basePolicyDocument?: string; + readonly runtimeSelection?: OpenShellRuntimeSelection; } interface LivePolicyBoundary { readonly gatewayName: string; readonly inspection: SandboxPolicyInspection; readonly basePolicyDocument: string; + readonly runtimeSelection?: OpenShellRuntimeSelection; +} + +function captureSelectedSandboxBasePolicy( + sandboxName: string, + gatewayName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): string { + return runtimeSelection + ? captureSandboxBasePolicy(sandboxName, gatewayName, runtimeSelection) + : captureSandboxBasePolicy(sandboxName, gatewayName); +} + +function captureSelectedSandboxBasePolicyRevision( + sandboxName: string, + gatewayName: string, + revision: number, + runtimeSelection?: OpenShellRuntimeSelection, +): string { + return runtimeSelection + ? captureSandboxBasePolicyRevision( + sandboxName, + gatewayName, + revision, + runtimeSelection, + ) + : captureSandboxBasePolicyRevision(sandboxName, gatewayName, revision); } function inspectLivePolicyBoundary( sandboxName: string, operation: string, requestedGatewayName?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): LivePolicyBoundary { let sandbox: ReturnType; try { @@ -699,12 +730,27 @@ function inspectLivePolicyBoundary( `Refusing to ${operation}: the sandbox gateway is unavailable or invalid.`, ); } + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + throw new PolicyObservationError( + `Refusing to ${operation}: the selected OpenShell target does not match the recorded sandbox gateway.`, + ); + } const inspection = inspectSandboxPolicy({ sandboxName, gatewayName, + ...(runtimeSelection ? { runtimeSelection } : {}), }); - const basePolicyDocument = captureSandboxBasePolicy(sandboxName, gatewayName); - return { gatewayName, inspection, basePolicyDocument }; + const basePolicyDocument = captureSelectedSandboxBasePolicy( + sandboxName, + gatewayName, + runtimeSelection, + ); + return { + gatewayName, + inspection, + basePolicyDocument, + ...(runtimeSelection ? { runtimeSelection } : {}), + }; } /** Read the current live policy through the sandbox's recorded gateway binding. */ @@ -712,8 +758,14 @@ export function inspectPolicyMutationContext( sandboxName: string, operation: string, requestedGatewayName?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): PolicyMutationContext { - return inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName); + return inspectLivePolicyBoundary( + sandboxName, + operation, + requestedGatewayName, + runtimeSelection, + ); } /** @@ -723,16 +775,28 @@ export function inspectPolicyMutationContext( export function captureRecordedSandboxBasePolicy( sandboxName: string, operation: string, + runtimeSelection?: OpenShellRuntimeSelection, ): string { - return inspectLivePolicyBoundary(sandboxName, operation).basePolicyDocument; + return inspectLivePolicyBoundary( + sandboxName, + operation, + runtimeSelection?.gatewayName, + runtimeSelection, + ).basePolicyDocument; } function preparePolicyMutationContext( sandboxName: string, operation: string, requestedGatewayName?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): PolicyMutationContext { - return inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName); + return inspectLivePolicyBoundary( + sandboxName, + operation, + requestedGatewayName, + runtimeSelection, + ); } /** Re-read live state immediately before a policy mutation. */ @@ -741,7 +805,12 @@ export function recheckPolicyMutationContext( operation: string, previous: PolicyMutationContext, ): PolicyMutationContext { - const current = inspectPolicyMutationContext(sandboxName, operation, previous.gatewayName); + const current = inspectPolicyMutationContext( + sandboxName, + operation, + previous.gatewayName, + previous.runtimeSelection, + ); if ( !isDeepStrictEqual(current.inspection.effectivePolicy, previous.inspection.effectivePolicy) || (previous.basePolicyDocument !== undefined && @@ -786,9 +855,10 @@ function inspectLivePolicyForMutation( sandboxName: string, operation: string, gatewayName?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): PolicyMutationContext | null { try { - return preparePolicyMutationContext(sandboxName, operation, gatewayName); + return preparePolicyMutationContext(sandboxName, operation, gatewayName, runtimeSelection); } catch (error) { reportPolicyObservationFailure(error); return null; @@ -822,6 +892,7 @@ function submitComposedPolicy( sandboxName: string, policyDocument: string, gatewayName?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): PolicySetSubmission { // `mkdtempSync` creates nothing when it throws, so only the write and the // submission need the cleanup boundary. Writing inside it keeps a failed or @@ -834,10 +905,12 @@ function submitComposedPolicy( try { const tmpFile = path.join(tmpDir, "policy.yaml"); fs.writeFileSync(tmpFile, policyDocument, { encoding: "utf-8", mode: 0o600 }); - const result = run(buildPolicySetCommand(tmpFile, sandboxName), { - ignoreError: true, - ...(gatewayName ? { env: { OPENSHELL_GATEWAY: gatewayName } } : {}), - }); + const result = runtimeSelection + ? submitSandboxPolicyFile(sandboxName, tmpFile, runtimeSelection) + : run(buildPolicySetCommand(tmpFile, sandboxName), { + ignoreError: true, + ...(gatewayName ? { env: { OPENSHELL_GATEWAY: gatewayName } } : {}), + }); return { outcome: classifyPolicySetResult({ status: result.status, @@ -896,7 +969,11 @@ function inspectPolicyDocumentReadback( ): "matched" | "different" | "unavailable" { try { return policyDocumentsMatch( - captureSandboxBasePolicy(sandboxName, previous.gatewayName), + captureSelectedSandboxBasePolicy( + sandboxName, + previous.gatewayName, + previous.runtimeSelection, + ), desiredPolicyDocument, ) ? "matched" @@ -1006,6 +1083,7 @@ export function setPolicyDocument( gatewayName?: string; operation?: string; context?: PolicyMutationContext; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): boolean { const operation = options.operation ?? "set the sandbox policy"; @@ -1013,7 +1091,12 @@ export function setPolicyDocument( try { context = options.context ? recheckPolicyMutationContext(sandboxName, operation, options.context) - : preparePolicyMutationContext(sandboxName, operation, options.gatewayName); + : preparePolicyMutationContext( + sandboxName, + operation, + options.gatewayName, + options.runtimeSelection, + ); } catch (error) { console.error(` ${policyObservationError(error)}`); if (options.nonFatal) return false; @@ -1035,12 +1118,18 @@ export function setPolicyDocument( } const originalDocument = - context.basePolicyDocument ?? captureSandboxBasePolicy(sandboxName, context.gatewayName); + context.basePolicyDocument ?? + captureSelectedSandboxBasePolicy( + sandboxName, + context.gatewayName, + context.runtimeSelection, + ); const originalVersion = context.inspection.policyIdentity.activeVersion; const { outcome, status } = submitComposedPolicy( sandboxName, requestedDocument, context.gatewayName, + context.runtimeSelection, ); if (outcome.kind === "rejected") { console.error(` ${policySetFailure(sandboxName, outcome).message}`); @@ -1050,7 +1139,12 @@ export function setPolicyDocument( let observed: PolicyMutationContext; try { - observed = preparePolicyMutationContext(sandboxName, operation, context.gatewayName); + observed = preparePolicyMutationContext( + sandboxName, + operation, + context.gatewayName, + context.runtimeSelection, + ); } catch (error) { if (outcome.kind === "ambiguous") { console.error( @@ -1064,7 +1158,12 @@ export function setPolicyDocument( process.exit(1); } const observedDocument = - observed.basePolicyDocument ?? captureSandboxBasePolicy(sandboxName, observed.gatewayName); + observed.basePolicyDocument ?? + captureSelectedSandboxBasePolicy( + sandboxName, + observed.gatewayName, + observed.runtimeSelection, + ); const observedVersion = observed.inspection.policyIdentity.activeVersion; const requestedIsCurrent = policyDocumentsMatch(observedDocument, requestedDocument); const concurrentRevision = observedVersion > originalVersion + 1; @@ -1088,11 +1187,12 @@ export function setPolicyDocument( let externalDocument: string; try { externalDocument = requestedIsCurrent - ? captureSandboxBasePolicyRevision( + ? captureSelectedSandboxBasePolicyRevision( sandboxName, - context.gatewayName, - observedVersion - 1, - ) + context.gatewayName, + observedVersion - 1, + context.runtimeSelection, + ) : observedDocument; const rebased = rebasePolicyDocumentOntoConcurrentEdit( originalDocument, @@ -1320,7 +1420,7 @@ function classifyPresetEntries(currentPolicy: string, presetEntries: string): Pr } } -function policyDocumentsMatch(left: string, right: string): boolean { +export function policyDocumentsMatch(left: string, right: string): boolean { try { return isDeepStrictEqual(parseOpenShellPolicy(left).policy, parseOpenShellPolicy(right).policy); } catch { @@ -1832,7 +1932,11 @@ function removePresetFromPolicy( function removePreset( sandboxName: string, presetName: string, - options: { nonFatal?: boolean; presetContent?: string } = {}, + options: { + nonFatal?: boolean; + presetContent?: string; + runtimeSelection?: OpenShellRuntimeSelection; + } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -1864,10 +1968,19 @@ function removePreset( } const operation = `remove policy preset '${presetName}'`; - const context = inspectLivePolicyForMutation(sandboxName, operation); + const context = inspectLivePolicyForMutation( + sandboxName, + operation, + options.runtimeSelection?.gatewayName, + options.runtimeSelection, + ); if (!context) return false; - const currentPolicy = readCurrentSandboxPolicy(sandboxName, context.gatewayName); + const currentPolicy = readCurrentSandboxPolicy( + sandboxName, + context.gatewayName, + context.runtimeSelection, + ); if (!currentPolicy) { console.error(` Could not read current policy for sandbox '${sandboxName}'.`); return false; @@ -1943,6 +2056,7 @@ function removePreset( !setPolicyDocument(sandboxName, updated, { nonFatal: options.nonFatal, context, + runtimeSelection: options.runtimeSelection, }) ) { return false; @@ -1952,12 +2066,18 @@ function removePreset( } /** Round-trippable live policy body from `--base`, or null when unreadable. */ -function readCurrentSandboxPolicy(sandboxName: string, gatewayName?: string): string | null { +function readCurrentSandboxPolicy( + sandboxName: string, + gatewayName?: string, + runtimeSelection?: OpenShellRuntimeSelection, +): string | null { try { const selectedGateway = gatewayName ?? resolveSandboxGatewayName(registry.getSandbox(sandboxName)); return ( - parseCurrentPolicyOrEmpty(captureSandboxBasePolicy(sandboxName, selectedGateway)) || null + parseCurrentPolicyOrEmpty( + captureSelectedSandboxBasePolicy(sandboxName, selectedGateway, runtimeSelection), + ) || null ); } catch { return null; @@ -2247,6 +2367,7 @@ function applyPresetContent( suppressDisclosure?: boolean; disclosedPresetState?: PresetPolicyState | null; includeMessagingCredentialBindings?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated @@ -2322,12 +2443,21 @@ function applyPresetContent( const operation = `apply policy preset '${presetName}'`; let context: PolicyMutationContext; try { - context = preparePolicyMutationContext(sandboxName, operation); + context = preparePolicyMutationContext( + sandboxName, + operation, + options.runtimeSelection?.gatewayName, + options.runtimeSelection, + ); } catch (error) { return reportPolicyObservationFailure(error); } - const currentPolicy = readCurrentSandboxPolicy(sandboxName, context.gatewayName); + const currentPolicy = readCurrentSandboxPolicy( + sandboxName, + context.gatewayName, + context.runtimeSelection, + ); // A live mutation requires a usable policy; empty is an invalid read, not a // fresh sandbox whose unknown policy may be replaced with a scaffold. if (!currentPolicy) { @@ -2433,6 +2563,7 @@ function applyPresetContent( !setPolicyDocument(sandboxName, merged, { nonFatal: options.nonFatal, context, + runtimeSelection: options.runtimeSelection, }) ) { return false; @@ -2833,9 +2964,11 @@ function getPresetContentGatewayState( sandboxName: string, presetContent: string, policyKey?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): "match" | "absent" | "drift" | null { return inspectPresetContentGatewayState({ - readPolicy: () => readCurrentSandboxPolicy(sandboxName) ?? "", + readPolicy: () => + readCurrentSandboxPolicy(sandboxName, runtimeSelection?.gatewayName, runtimeSelection) ?? "", parseCurrentPolicy: parseCurrentPolicyOrEmpty, extractPresetEntries, presetContent, diff --git a/src/lib/policy/policy-live-state.test.ts b/src/lib/policy/policy-live-state.test.ts index 1ead51ed14e..ae3c0bfbe7c 100644 --- a/src/lib/policy/policy-live-state.test.ts +++ b/src/lib/policy/policy-live-state.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ resolveOpenshell: vi.fn(), run: vi.fn(), runCapture: vi.fn(), + submitSandboxPolicyFile: vi.fn(), })); vi.mock("../adapters/openshell/policy-state", async (importOriginal) => ({ @@ -23,6 +24,7 @@ vi.mock("../adapters/openshell/policy-state", async (importOriginal) => ({ captureSandboxBasePolicy: mocks.captureSandboxBasePolicy, captureSandboxBasePolicyRevision: mocks.captureSandboxBasePolicyRevision, inspectSandboxPolicy: mocks.inspectSandboxPolicy, + submitSandboxPolicyFile: mocks.submitSandboxPolicyFile, })); vi.mock("../adapters/openshell/resolve", async (importOriginal) => ({ ...(await importOriginal()), @@ -56,7 +58,15 @@ describe("live OpenShell policy mutations", () => { let livePolicy: string; beforeEach(() => { - for (const mock of Object.values(mocks)) mock.mockReset(); + vi.unstubAllEnvs(); + mocks.captureSandboxBasePolicy.mockReset(); + mocks.captureSandboxBasePolicyRevision.mockReset(); + mocks.getSandbox.mockReset(); + mocks.inspectSandboxPolicy.mockReset(); + mocks.resolveOpenshell.mockReset(); + mocks.run.mockReset(); + mocks.runCapture.mockReset(); + mocks.submitSandboxPolicyFile.mockReset(); livePolicy = YAML.stringify({ version: 1, network_policies: { host_approval: hostEntry }, @@ -77,6 +87,10 @@ describe("live OpenShell policy mutations", () => { livePolicy = fs.readFileSync(command[policyIndex + 1] as string, "utf8"); return { status: 0 }; }); + mocks.submitSandboxPolicyFile.mockImplementation((_sandboxName: string, policyFile: string) => { + livePolicy = fs.readFileSync(policyFile, "utf8"); + return { status: 0 }; + }); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined); @@ -101,6 +115,53 @@ describe("live OpenShell policy mutations", () => { expect(YAML.parse(livePolicy).network_policies).toEqual({ host_approval: hostEntry }); }); + it("pins selected policy mutations to the recorded OpenShell target (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + mocks.getSandbox.mockReturnValue({ + name: sandboxName, + gatewayName: "nemoclaw-9090", + }); + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + localTlsDir: "/recorded/tls", + workspace: "default", + }; + + expect( + applyPresetContent(sandboxName, "weather", preset, { + nonFatal: true, + runtimeSelection, + }), + ).toBe(true); + expect( + removePreset(sandboxName, "weather", { + nonFatal: true, + presetContent: preset, + runtimeSelection, + }), + ).toBe(true); + + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.submitSandboxPolicyFile).toHaveBeenCalledTimes(2); + expect(mocks.submitSandboxPolicyFile).toHaveBeenNthCalledWith( + 1, + sandboxName, + expect.any(String), + runtimeSelection, + ); + expect(mocks.submitSandboxPolicyFile).toHaveBeenNthCalledWith( + 2, + sandboxName, + expect.any(String), + runtimeSelection, + ); + }); + it("does not overwrite a host edit that races a prepared full-policy update", () => { let observations = 0; mocks.inspectSandboxPolicy.mockImplementation(() => { diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 541c8aaa611..7a7e56a50fa 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; @@ -14,9 +13,14 @@ import { createShieldsFlowHarness, managedMcpPolicy, managedMcpSandbox, + timerAuthorityFixtures, type ShieldsFlowHarnessOptions, + writeActivePolicyTransition, + writeBoundPolicySnapshot, + writeExpiredShieldsFixture, writeShieldsTimerAuthorizationProof, } from "../../../test/helpers/shields-flow-harness"; +import { GATEWAY_PORT } from "../core/ports"; const requireDist = createRequire(import.meta.url); const shieldsModulePath = "./index.js"; @@ -30,107 +34,6 @@ function createHarness(options: ShieldsFlowHarnessOptions = {}) { return createShieldsFlowHarness(requireDist, tmpDir, options); } -function writeBoundPolicySnapshot( - snapshotPath: string, - content = "version: 1\nnetwork_policies:\n test: {}\n", -) { - fs.writeFileSync(snapshotPath, content, { mode: 0o600 }); - fs.chmodSync(snapshotPath, 0o600); - const metadata = fs.statSync(snapshotPath); - return { - schemaVersion: 1 as const, - path: snapshotPath, - sha256: createHash("sha256").update(content).digest("hex"), - size: Buffer.byteLength(content), - mode: 0o600, - uid: metadata.uid, - gid: metadata.gid, - nlink: 1 as const, - }; -} - -function writeActivePolicyTransition( - stateDir: string, - sandboxName: string, - processToken: string, - snapshotPath: string, - snapshotPolicy: ReturnType, -): void { - const forwardPolicy = writeBoundPolicySnapshot( - path.join(stateDir, `policy-forward-${processToken.slice(0, 8)}.yaml`), - ); - fs.writeFileSync( - path.join(stateDir, `shields-transition-${sandboxName}-${processToken}.json`), - JSON.stringify({ - version: 1, - phase: "active", - ownerPid: 2_147_483_647, - ownerStartIdentity: "test-timer-owner", - processToken, - sandboxName, - snapshotPath, - snapshotPolicy, - forwardPolicy, - }), - { mode: 0o600 }, - ); -} - -const timerAuthorityFixtures: ReadonlyArray void]> = [ - ["missing", () => undefined], - ["malformed", (markerPath) => fs.writeFileSync(markerPath, "{not-json")], -]; - -function writeExpiredShieldsFixture( - processToken: string, - reason: string, - ownerState: "dead" | "live", -) { - const liveOwner = ownerState === "live"; - const sandboxName = "openclaw"; - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, `snapshot-${processToken.slice(0, 8)}.yaml`); - const timerMarkerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); - const transitionLockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); - fs.mkdirSync(stateDir, { recursive: true }); - const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); - fs.writeFileSync( - path.join(stateDir, `shields-${sandboxName}.json`), - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: new Date(Date.now() - 120_000).toISOString(), - shieldsDownTimeout: 60, - shieldsDownReason: reason, - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, - shieldsPolicySnapshot: snapshotPolicy, - }), - ); - fs.writeFileSync( - timerMarkerPath, - JSON.stringify({ - pid: liveOwner ? 2_147_483_647 : 4242, - sandboxName, - snapshotPath, - restoreAt: new Date(Date.now() - 60_000).toISOString(), - processToken, - }), - ); - fs.writeFileSync( - transitionLockPath, - JSON.stringify({ - version: 1, - sandboxName, - pid: liveOwner ? process.pid : 4242, - processStartIdentity: liveOwner ? currentProcessStartIdentity : "dead-timer", - command: liveOwner ? "shields down" : "shields auto-restore", - acquiredAtMs: Date.now() - 60_000, - takeoverToken: processToken, - }), - ); - return { stateDir, timerMarkerPath, transitionLockPath }; -} - describe("shields command flow", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-flow-")); @@ -532,6 +435,8 @@ describe("shields command flow", () => { const lifecycleLock = requireDist("../state/mcp-lifecycle-lock.js"); const timerControl = requireDist("./timer-control.js"); const { stateDir, timerMarkerPath, transitionLockPath } = writeExpiredShieldsFixture( + tmpDir, + currentProcessStartIdentity, processToken, "long lifecycle operation", "dead", @@ -1331,7 +1236,13 @@ describe("shields command flow", () => { stateDir, timerMarkerPath, transitionLockPath: lockPath, - } = writeExpiredShieldsFixture(processToken, "coverage", "dead"); + } = writeExpiredShieldsFixture( + tmpDir, + currentProcessStartIdentity, + processToken, + "coverage", + "dead", + ); vi.spyOn(process, "kill").mockImplementation((pid: number, signal?: string | number) => { const failDeadTimerProbe = () => { const error = new Error("timer is gone") as NodeJS.ErrnoException; @@ -1365,6 +1276,8 @@ describe("shields command flow", () => { const mainLockPath = lifecycleLock.getMcpLifecycleLockPath("openclaw"); const containmentPath = `${mainLockPath}.containment`; const { timerMarkerPath, transitionLockPath } = writeExpiredShieldsFixture( + tmpDir, + currentProcessStartIdentity, processToken, "containment write failure coverage", "dead", @@ -1419,7 +1332,13 @@ describe("shields command flow", () => { const processToken = "8".repeat(32); const lifecycleLock = requireDist("../state/mcp-lifecycle-lock.js"); const containmentPath = `${lifecycleLock.getMcpLifecycleLockPath(sandboxName)}.containment`; - writeExpiredShieldsFixture(processToken, "takeover exhaustion coverage", "live"); + writeExpiredShieldsFixture( + tmpDir, + currentProcessStartIdentity, + processToken, + "takeover exhaustion coverage", + "live", + ); const waitSpy = vi.spyOn(Atomics, "wait").mockReturnValue("timed-out"); const harness = createHarness(); @@ -1451,7 +1370,13 @@ describe("shields command flow", () => { const lifecycleLock = requireDist("../state/mcp-lifecycle-lock.js"); const mainLockPath = lifecycleLock.getMcpLifecycleLockPath(sandboxName); const containmentPath = `${mainLockPath}.containment`; - writeExpiredShieldsFixture(processToken, "containment write failure coverage", "live"); + writeExpiredShieldsFixture( + tmpDir, + currentProcessStartIdentity, + processToken, + "containment write failure coverage", + "live", + ); const waitSpy = vi.spyOn(Atomics, "wait").mockReturnValue("timed-out"); let containmentAttempts = 0; const harness = createHarness({ @@ -1481,4 +1406,69 @@ describe("shields command flow", () => { ); }, ); + + it("pins policy commands and the auto-restore timer to the selected target", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/tmp/hostile-tls"); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "recorded-workspace", + localTlsDir: "/tmp/recorded-tls", + }; + let timerOptions: { env?: NodeJS.ProcessEnv } | undefined; + const harness = createHarness({ + confirmOpenClawInodeFlags: true, + fork: (...args: unknown[]) => { + timerOptions = args[2] as { env?: NodeJS.ProcessEnv }; + return { + pid: 4242, + disconnect: () => undefined, + unref: () => undefined, + send: () => true, + kill: () => true, + }; + }, + }); + + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "selected target coverage", + runtimeSelection, + throwOnError: true, + }); + + const policyCalls = [...harness.runCaptureSpy.mock.calls, ...harness.runSpy.mock.calls].filter( + ([command]) => Array.isArray(command) && command.includes("policy"), + ); + const selectedCall = { + gateway: runtimeSelection.gatewayName, + workspace: runtimeSelection.workspace, + localTlsDir: runtimeSelection.localTlsDir, + replaceEnv: true, + }; + expect(policyCalls.length).toBeGreaterThan(0); + expect( + policyCalls.map(([, options]) => ({ + gateway: options.env.OPENSHELL_GATEWAY, + workspace: options.env.OPENSHELL_WORKSPACE, + localTlsDir: options.env.OPENSHELL_LOCAL_TLS_DIR, + replaceEnv: options.replaceEnv, + })), + ).toEqual(Array(policyCalls.length).fill(selectedCall)); + expect(timerOptions).toMatchObject({ + env: { + OPENSHELL_GATEWAY: runtimeSelection.gatewayName, + OPENSHELL_WORKSPACE: runtimeSelection.workspace, + OPENSHELL_LOCAL_TLS_DIR: runtimeSelection.localTlsDir, + }, + }); + expect(timerOptions?.env?.NEMOCLAW_GATEWAY_PORT).toBe(String(GATEWAY_PORT)); + expect(harness.policyStateSpy).toHaveBeenCalledWith( + "openclaw", + "lower Shields", + runtimeSelection.gatewayName, + runtimeSelection, + ); + }); }); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 230b0c00001..ed0dd6d548d 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -20,6 +20,7 @@ // timer-bound lock tests before this facade can shrink safely. import { run, runCapture, validateName } from "../runner"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; const fs = require("fs"); const path = require("path"); @@ -52,6 +53,7 @@ const { const { parseDuration, MAX_SECONDS, DEFAULT_SECONDS } = require("../domain/duration"); const { buildOpenshellCommand, + buildSelectedOpenShellSubprocessEnv, }: typeof import("../adapters/openshell/command-argv") = require("../adapters/openshell/command-argv"); const { parseLiveSandboxEntries, @@ -94,6 +96,7 @@ const { inspectAnyShieldsTransitionLockOwner, isShieldsTransitionLockUnavailable, resolveShieldsStateDir, + resolveShieldsStateGatewayPort, withShieldsTransitionLock, }: typeof import("./transition-lock") = require("./transition-lock"); const { @@ -150,14 +153,34 @@ type AgentStateLockPlan = import("../agent/definition-types").AgentStateLockPlan type TimerMarker = import("./timer-control").TimerMarker; type PolicyMutationContext = ReturnType; +function withSelectedOpenShellEnv( + options: T, + runtimeSelection?: OpenShellRuntimeSelection, +): T & { env?: Record; replaceEnv?: true } { + return runtimeSelection + ? { + ...options, + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : options; +} + /** Re-read current OpenShell state before a Shields-owned policy mutation. */ function assertShieldsPolicyMutationContext( sandboxName: string, operation: string, recorded?: PolicyMutationContext, + runtimeSelection?: OpenShellRuntimeSelection, ): PolicyMutationContext { - return recorded - ? recheckPolicyMutationContext(sandboxName, operation, recorded) + if (recorded) return recheckPolicyMutationContext(sandboxName, operation, recorded); + return runtimeSelection + ? inspectPolicyMutationContext( + sandboxName, + operation, + runtimeSelection.gatewayName, + runtimeSelection, + ) : inspectPolicyMutationContext(sandboxName, operation); } @@ -1088,10 +1111,13 @@ function resolveActiveHermesRuntimeProviderMutationTarget( return target; } -function recoverActiveHermesRuntimeProviderMutation(sandboxName: string): AgentConfigTarget | null { +function recoverActiveHermesRuntimeProviderMutation( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): AgentConfigTarget | null { const target = resolveActiveHermesRuntimeProviderMutationTarget(sandboxName); if (!target) return null; - runHermesProviderProtectionTransition(sandboxName, target, "locked", "locked"); + runHermesProviderProtectionTransition(sandboxName, target, "locked", "locked", runtimeSelection); return target; } @@ -1359,12 +1385,21 @@ function requireHermesRuntimeProviderSandbox(sandboxName: string) { // #10104: best-effort, fail-open Phase probe. Any resolution failure, ENOENT, // non-zero exit, or timeout collapses to null (proceed as before); only a // positive, non-Ready/Running phase trips the fast-fail path below. -function probeHermesRuntimeProviderSandboxPhase(sandboxName: string): string | null { +function probeHermesRuntimeProviderSandboxPhase( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): string | null { try { - const output = runCapture(buildOpenshellCommand(["sandbox", "list"]), { - ignoreError: true, - timeout: HERMES_RUNTIME_PROVIDER_PHASE_PROBE_TIMEOUT_MS, - }); + const output = runCapture( + buildOpenshellCommand(["sandbox", "list"]), + withSelectedOpenShellEnv( + { + ignoreError: true, + timeout: HERMES_RUNTIME_PROVIDER_PHASE_PROBE_TIMEOUT_MS, + }, + runtimeSelection, + ), + ); return ( parseLiveSandboxEntries(output).find((entry) => entry.name === sandboxName)?.phase ?? null ); @@ -1373,8 +1408,11 @@ function probeHermesRuntimeProviderSandboxPhase(sandboxName: string): string | n } } -function waitForHermesRuntimeProviderReleaseReady(sandboxName: string): void { - let phase = probeHermesRuntimeProviderSandboxPhase(sandboxName); +function waitForHermesRuntimeProviderReleaseReady( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): void { + let phase = probeHermesRuntimeProviderSandboxPhase(sandboxName, runtimeSelection); if (phase === null || phase === "Ready" || phase === "Running") return; const deadline = Date.now() + HERMES_RUNTIME_PROVIDER_RELEASE_READY_TIMEOUT_MS; @@ -1386,7 +1424,7 @@ function waitForHermesRuntimeProviderReleaseReady(sandboxName: string): void { 0, Math.min(HERMES_RUNTIME_PROVIDER_RELEASE_READY_POLL_MS, deadline - Date.now()), ); - phase = probeHermesRuntimeProviderSandboxPhase(sandboxName); + phase = probeHermesRuntimeProviderSandboxPhase(sandboxName, runtimeSelection); if (phase === "Ready" || phase === "Running") return; if (phase !== null) lastObservedPhase = phase; } @@ -1395,6 +1433,15 @@ function waitForHermesRuntimeProviderReleaseReady(sandboxName: string): void { ); } +function waitForSelectedHermesInferenceRouteConvergence( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +) { + return waitForHermesInferenceRouteConvergence(sandboxName, { + run: (command, options) => run(command, withSelectedOpenShellEnv(options, runtimeSelection)), + }); +} + function restartHermesManagedMcpAfterProviderRelease( sandboxName: string, sandbox: ReturnType, @@ -1427,9 +1474,10 @@ function runHermesProviderProtectionTransition( configTarget: AgentConfigTarget, targetPosture: "locked" | "mutable", rollback: "locked" | "mutable", + runtimeSelection?: OpenShellRuntimeSelection, ): void { const sandbox = requireHermesRuntimeProviderSandbox(sandboxName); - const phase = probeHermesRuntimeProviderSandboxPhase(sandboxName); + const phase = probeHermesRuntimeProviderSandboxPhase(sandboxName, runtimeSelection); if ( hermesRuntimeProviderPhaseBlocksMutation( phase, @@ -1441,7 +1489,9 @@ function runHermesProviderProtectionTransition( ); } runHermesRuntimeProviderStateMutation({ - environment: process.env, + environment: runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(runtimeSelection) + : process.env, sandbox, sandboxName, configTarget, @@ -1451,7 +1501,7 @@ function runHermesProviderProtectionTransition( // Releasing the exact process fence resumes OpenShell PID 1 last. OpenShell // then asynchronously republishes the sandbox lifecycle phase; callers must // not issue route or mutation commands during that Provisioning interval. - waitForHermesRuntimeProviderReleaseReady(sandboxName); + waitForHermesRuntimeProviderReleaseReady(sandboxName, runtimeSelection); // The fenced gateway can prove local health while OpenShell PID 1 is held, // but Hermes performs network MCP discovery before exposing that health. // Restart once after release so configured managed bridges are discovered @@ -1877,8 +1927,13 @@ function consumeShieldsPolicySnapshotRecovery( function getShieldsPostureWithoutHostLock( sandboxName: string, allowInlineRecovery = false, + runtimeSelection?: OpenShellRuntimeSelection, ): ShieldsPosture { - const state = recoverExpiredAutoRestoreGate(sandboxName, allowInlineRecovery); + const state = recoverExpiredAutoRestoreGate( + sandboxName, + allowInlineRecovery, + runtimeSelection, + ); const timerBoundTransition = !state._isCorrupt && state.shieldsDown === true ? readTimerBoundShieldsDownTransition(sandboxName) @@ -2148,11 +2203,12 @@ function isDurableContainmentFailure(error: unknown): boolean { function retryInlineAutoRestore( sandboxName: string, marker: TimerMarker & { processToken: string }, + runtimeSelection?: OpenShellRuntimeSelection, ): void { let notifiedError: string | null = null; for (let attempt = 0; attempt < INTERACTIVE_AUTO_RESTORE_MAX_ATTEMPTS; attempt += 1) { try { - const recoveredState = recoverExpiredAutoRestoreGate(sandboxName, true); + const recoveredState = recoverExpiredAutoRestoreGate(sandboxName, true, runtimeSelection); if (!recoveredState._isCorrupt && recoveredState.shieldsDown !== true) { return; } @@ -2204,6 +2260,7 @@ function withExpiredAutoRestoreDeadlineFence( operation: (allowInlineRecovery: boolean) => T, fallbackTakeoverToken?: string, assertCommandAvailable?: () => void, + runtimeSelection?: OpenShellRuntimeSelection, ): T { const completedMarker = inspectCompletedAbandonedAutoRestoreMarker(sandboxName); if (completedMarker) { @@ -2250,7 +2307,7 @@ function withExpiredAutoRestoreDeadlineFence( }; const recoverThenRun = () => { withTimerBoundAutoRestoreLock(sandboxName, command, () => { - if (takeover) retryInlineAutoRestore(sandboxName, takeover.marker); + if (takeover) retryInlineAutoRestore(sandboxName, takeover.marker, runtimeSelection); }); return runWithHostLock(() => operation(false)); }; @@ -2268,7 +2325,7 @@ function withExpiredAutoRestoreDeadlineFence( () => assertTimerMarkerGeneration(sandboxName, marker), ); if (fallbackTakeoverToken) { - retryInlineAutoRestore(sandboxName, marker); + retryInlineAutoRestore(sandboxName, marker, runtimeSelection); } // This lifecycle owner is what the live timer is waiting on. Run the // nested operation without re-entering recovery against that timer. @@ -2364,13 +2421,23 @@ function withExpiredAutoRestoreDeadlineFence( ); } -function getShieldsPosture(sandboxName: string, allowInlineRecovery = false): ShieldsPosture { - if (!allowInlineRecovery) return getShieldsPostureWithoutHostLock(sandboxName, false); +function getShieldsPosture( + sandboxName: string, + allowInlineRecovery = false, + runtimeSelection?: OpenShellRuntimeSelection, +): ShieldsPosture { + if (!allowInlineRecovery) { + return getShieldsPostureWithoutHostLock(sandboxName, false, runtimeSelection); + } validateName(sandboxName, "sandbox name"); return withExpiredAutoRestoreDeadlineFence( sandboxName, "recover expired shields posture", - (allowInlineRecovery) => getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery), + (allowInlineRecovery) => + getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery, runtimeSelection), + undefined, + undefined, + runtimeSelection, ); } @@ -3340,11 +3407,18 @@ function unlockAgentConfigUnderMutationLock( rawTarget: AgentConfigTarget, rollbackLocked: boolean, protocol: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): void { const target = ensureConfigHashSensitiveFile(rawTarget); if (target.agentName === "hermes" && protocol === "provider-state-mutation-v2") { if (!rollbackLocked) { - runHermesProviderProtectionTransition(sandboxName, target, "mutable", "mutable"); + runHermesProviderProtectionTransition( + sandboxName, + target, + "mutable", + "mutable", + runtimeSelection, + ); try { verifyHermesProviderMutablePosture(sandboxName, target); return; @@ -3354,7 +3428,13 @@ function unlockAgentConfigUnderMutationLock( // an invalid same-posture provider plan. } } - runHermesProviderProtectionTransition(sandboxName, target, "mutable", "locked"); + runHermesProviderProtectionTransition( + sandboxName, + target, + "mutable", + "locked", + runtimeSelection, + ); verifyHermesProviderMutablePosture(sandboxName, target); return; } @@ -3632,6 +3712,7 @@ function unlockAgentConfigWithoutHostLock( rollbackLocked = getShieldsPosture(sandboxName, false).locked, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): void { const target = ensureConfigHashSensitiveFile(rawTarget); const protocol = resolveHermesShieldsProtocol( @@ -3640,7 +3721,13 @@ function unlockAgentConfigWithoutHostLock( allowLegacyHermesProtocol, cachedProtocol, ); - return unlockAgentConfigUnderMutationLock(sandboxName, target, rollbackLocked, protocol); + return unlockAgentConfigUnderMutationLock( + sandboxName, + target, + rollbackLocked, + protocol, + runtimeSelection, + ); } function unlockAgentConfig( @@ -3649,6 +3736,7 @@ function unlockAgentConfig( rollbackLocked?: boolean, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): void { return withShieldsTransitionLock(sandboxName, "unlock agent config", () => { const effectiveRollbackLocked = rollbackLocked ?? getShieldsPosture(sandboxName, false).locked; @@ -3658,6 +3746,7 @@ function unlockAgentConfig( effectiveRollbackLocked, allowLegacyHermesProtocol, cachedProtocol, + runtimeSelection, ); }); } @@ -3695,7 +3784,10 @@ function inspectMutableConfigPerms(sandboxName: string): MutableConfigPermsInspe ); } -function repairMutableConfigPerms(sandboxName: string): MutableConfigRepairResult { +function repairMutableConfigPerms( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): MutableConfigRepairResult { validateName(sandboxName, "sandbox name"); return withExpiredAutoRestoreDeadlineFence( sandboxName, @@ -3705,11 +3797,14 @@ function repairMutableConfigPerms(sandboxName: string): MutableConfigRepairResul return repairMutableConfigPermsCore( target, mutableConfigPostureMode( - getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery).mode, + getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery, runtimeSelection).mode, ), () => normalizeMutableOpenClawConfig(sandboxName, target.configDir), ); }, + undefined, + undefined, + runtimeSelection, ); } @@ -3808,11 +3903,18 @@ function lockAgentConfigUnderMutationLock( rawTarget: AgentConfigTarget, rollbackLocked: boolean, protocol: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): { chattrApplied: boolean; fileHashes: { [path: string]: string } } { const target = ensureConfigHashSensitiveFile(rawTarget); if (target.agentName === "hermes" && protocol === "provider-state-mutation-v2") { if (rollbackLocked) { - runHermesProviderProtectionTransition(sandboxName, target, "locked", "locked"); + runHermesProviderProtectionTransition( + sandboxName, + target, + "locked", + "locked", + runtimeSelection, + ); try { return verifyHermesProviderLockedPosture(sandboxName, target); } catch { @@ -3820,7 +3922,13 @@ function lockAgentConfigUnderMutationLock( // failures retain the provider fence, so this remains fail-closed. } } - runHermesProviderProtectionTransition(sandboxName, target, "locked", "mutable"); + runHermesProviderProtectionTransition( + sandboxName, + target, + "locked", + "mutable", + runtimeSelection, + ); return verifyHermesProviderLockedPosture(sandboxName, target); } const compatibilityIssues = stateLockPlanCompatibilityIssues( @@ -4127,6 +4235,7 @@ function synchronizeAutoRestoreTransition( expiredTimerRecovery?: boolean; retainTransition?: boolean; assertTakeoverAuthority?: () => void; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): void { const transition = waitForShieldsDownForwardCommit( @@ -4155,6 +4264,7 @@ function synchronizeAutoRestoreTransition( transitionProcessToken: processToken, ...(deadlineAuthoritative ? { deadlineAuthoritative: true } : {}), ...(options.expiredTimerRecovery ? { expiredTimerRecovery: true } : {}), + runtimeSelection: options.runtimeSelection, }); const status = typeof restoreResult.status === "number" ? restoreResult.status : 1; if (status !== 0) { @@ -4217,7 +4327,10 @@ function prepareAutoRestoreTransitionTakeover( ); } -function synchronizeAutoRestoreWithShieldsDown(sandboxName: string): void { +function synchronizeAutoRestoreWithShieldsDown( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): void { const timerMarker = readTimerMarker(sandboxName); if ( !timerMarker || @@ -4234,6 +4347,7 @@ function synchronizeAutoRestoreWithShieldsDown(sandboxName: string): void { { retainTransition: true, assertTakeoverAuthority: () => assertTimerMarkerGeneration(sandboxName, timerMarker), + runtimeSelection, }, ); } @@ -4265,6 +4379,7 @@ function lockAgentConfigWithoutHostLock( rollbackLocked = getShieldsPosture(sandboxName, false).locked, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): { chattrApplied: boolean; fileHashes: { [path: string]: string } } { const target = ensureConfigHashSensitiveFile(rawTarget); const protocol = resolveHermesShieldsProtocol( @@ -4273,8 +4388,14 @@ function lockAgentConfigWithoutHostLock( allowLegacyHermesProtocol, cachedProtocol, ); - synchronizeAutoRestoreWithShieldsDown(sandboxName); - return lockAgentConfigUnderMutationLock(sandboxName, target, rollbackLocked, protocol); + synchronizeAutoRestoreWithShieldsDown(sandboxName, runtimeSelection); + return lockAgentConfigUnderMutationLock( + sandboxName, + target, + rollbackLocked, + protocol, + runtimeSelection, + ); } function lockAgentConfig( @@ -4283,6 +4404,7 @@ function lockAgentConfig( rollbackLocked?: boolean, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): { chattrApplied: boolean; fileHashes: { [path: string]: string } } { return withShieldsTransitionLock(sandboxName, "lock agent config", () => { const effectiveRollbackLocked = rollbackLocked ?? getShieldsPosture(sandboxName, false).locked; @@ -4292,6 +4414,7 @@ function lockAgentConfig( effectiveRollbackLocked, allowLegacyHermesProtocol, cachedProtocol, + runtimeSelection, ); }); } @@ -4321,6 +4444,7 @@ interface ShieldsPolicySnapshotRestoreOptions { expiredTimerRecovery?: boolean; buildPolicySet?: typeof buildPolicySetCommand; runPolicySet?: typeof run; + runtimeSelection?: OpenShellRuntimeSelection; } type ShieldsPolicySnapshotRestoreResult = ReturnType; @@ -4395,8 +4519,25 @@ function applyShieldsPolicySnapshot( } } - const context = inspectPolicyMutationContext(sandboxName, "restore the Shields policy snapshot"); - const rawLive = runCapture(buildPolicyGetCommand(sandboxName, context.gatewayName)); + const context = options.runtimeSelection + ? inspectPolicyMutationContext( + sandboxName, + "restore the Shields policy snapshot", + options.runtimeSelection.gatewayName, + options.runtimeSelection, + ) + : inspectPolicyMutationContext(sandboxName, "restore the Shields policy snapshot"); + const rawLive = runCapture( + buildPolicyGetCommand(sandboxName, context.gatewayName), + { + ...(options.runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(options.runtimeSelection), + replaceEnv: true as const, + } + : {}), + }, + ); const livePolicy = parseCurrentPolicy(rawLive); if (!livePolicy) throw new Error("Cannot read the current OpenShell policy for Shields restore"); const snapshotBinding = transition?.snapshotPolicy ?? state.shieldsPolicySnapshot; @@ -4428,7 +4569,7 @@ function applyShieldsPolicySnapshot( sandboxName, context.gatewayName, ), - { ignoreError: true }, + withSelectedOpenShellEnv({ ignoreError: true }, options.runtimeSelection), ); rejectFinalShieldsPolicySetResult(result, "restore the Shields policy snapshot"); if (result.status === 0) verifyAppliedPolicyDocument(sandboxName, restoredPolicy, context); @@ -4446,11 +4587,14 @@ function rollbackShieldsDown( initialState: LoadedShieldsState, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): ShieldsDownRollbackResult { console.error(" Rolling back — restoring policy from snapshot..."); let rollbackResult: ReturnType | null = null; try { - rollbackResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath); + rollbackResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + runtimeSelection, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(` Warning: Policy restore preparation failed during rollback: ${message}`); @@ -4467,7 +4611,7 @@ function rollbackShieldsDown( if (rollbackResult?.status === 0) { if (initialMode === "mutable_default" && target.agentName === "openclaw") { try { - unlockAgentConfigUnderMutationLock(sandboxName, target, false, protocol); + unlockAgentConfigUnderMutationLock(sandboxName, target, false, protocol, runtimeSelection); const timerCancellation = killTimer(sandboxName); timerAuthorityRevoked = timerCancellation.authorityRevoked; if (!timerCancellation.authorityRevoked) { @@ -4490,7 +4634,7 @@ function rollbackShieldsDown( // auto-restore path. Leaves the hashes null (→ "manual intervention" // below) when the lock will not re-confirm. const relock = relockAndReconfirm( - () => lockAgentConfigUnderMutationLock(sandboxName, target, true, protocol), + () => lockAgentConfigUnderMutationLock(sandboxName, target, true, protocol, runtimeSelection), { confirm: hermesProviderLockConfirmation(sandboxName, target, protocol) }, ); if (relock.ok && relock.lastResult) { @@ -4598,7 +4742,15 @@ function activateLockdownFromSnapshot( // otherwise leave the same DRIFTED state #4663 is about. relockAndReconfirm // fails closed (ok:false) when the lock will not hold past the settle window. const relock = relockAndReconfirm( - () => lockAgentConfig(sandboxName, target, false, allowLegacyHermesProtocol, protocol), + () => + lockAgentConfig( + sandboxName, + target, + false, + allowLegacyHermesProtocol, + protocol, + restoreOptions.runtimeSelection, + ), { confirm: hermesProviderLockConfirmation(sandboxName, target, protocol) }, ); if (!relock.ok || !relock.lastResult) { @@ -4617,6 +4769,7 @@ function activateLockdownFromSnapshot( function recoverExpiredAutoRestoreInline( sandboxName: string, state: ShieldsState & { _isCorrupt?: boolean; _corruptError?: string }, + runtimeSelection?: OpenShellRuntimeSelection, ): { attempted: boolean; restored: boolean } { if (state._isCorrupt) return { attempted: false, restored: false }; if (state.shieldsDown !== true) return { attempted: false, restored: false }; @@ -4654,6 +4807,7 @@ function recoverExpiredAutoRestoreInline( expiredTimerRecovery: true, retainTransition: true, assertTakeoverAuthority: () => assertTimerMarkerGeneration(sandboxName, marker), + runtimeSelection, }); } catch (error) { if (isDurableContainmentFailure(error)) throw error; @@ -4682,8 +4836,9 @@ function recoverExpiredAutoRestoreInline( ? { transitionProcessToken: recoveryProcessToken, ...(marker ? { deadlineAuthoritative: true, expiredTimerRecovery: true } : {}), + runtimeSelection, } - : {}, + : { runtimeSelection }, ); const nowIso = new Date().toISOString(); if (!activation.ok) { @@ -4747,6 +4902,7 @@ function recoverExpiredAutoRestoreInline( function recoverExpiredAutoRestoreGate( sandboxName: string, allowInlineRecovery = true, + runtimeSelection?: OpenShellRuntimeSelection, ): LoadedShieldsState { const state = loadShieldsState(sandboxName); if (!allowInlineRecovery) return state; @@ -4754,7 +4910,7 @@ function recoverExpiredAutoRestoreGate( return state; } - const recovery = recoverExpiredAutoRestoreInline(sandboxName, state); + const recovery = recoverExpiredAutoRestoreInline(sandboxName, state, runtimeSelection); if (!recovery.restored) return state; return loadShieldsState(sandboxName); } @@ -4780,6 +4936,7 @@ interface ShieldsDownOpts { /** Return a one-shot receipt bound to this exact transition for host backup relock. */ issuePolicySnapshotRecovery?: boolean; processToken?: string; + runtimeSelection?: OpenShellRuntimeSelection; } type RecoveredShieldsDownCompletion = { @@ -4856,11 +5013,14 @@ function prepareRecoveredShieldsDownCompletion( function applyRecoveredShieldsDownForwardPolicy( sandboxName: string, completion: RecoveredShieldsDownCompletion, + runtimeSelection?: OpenShellRuntimeSelection, ): void { if (!completion.authority) return; const policyContext = assertShieldsPolicyMutationContext( sandboxName, "reapply the interrupted Shields down policy", + undefined, + runtimeSelection, ); assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); const policyPath = requireShieldsDownForwardPolicy(completion.authority); @@ -4869,9 +5029,10 @@ function applyRecoveredShieldsDownForwardPolicy( "reapply the interrupted Shields down policy", policyContext, ); - const result = run(buildPolicySetCommand(policyPath, sandboxName, policyContext.gatewayName), { - ignoreError: true, - }); + const result = run( + buildPolicySetCommand(policyPath, sandboxName, policyContext.gatewayName), + withSelectedOpenShellEnv({ ignoreError: true }, runtimeSelection), + ); rejectFinalShieldsPolicySetResult(result, "reapply the interrupted Shields down policy"); if (result.status !== 0) { throw new Error("Interrupted Shields down forward policy could not be reapplied"); @@ -4932,11 +5093,12 @@ function resolveReleasedProviderShieldsDownTarget( function finishRecoveredHermesShieldsDown( sandboxName: string, completion: RecoveredShieldsDownCompletion, + runtimeSelection?: OpenShellRuntimeSelection, ): void { if (completion.authority) { assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); } - const convergence = waitForHermesInferenceRouteConvergence(sandboxName, { run }); + const convergence = waitForSelectedHermesInferenceRouteConvergence(sandboxName, runtimeSelection); if (!convergence.ok) { const status = convergence.httpStatus > 0 ? `HTTP ${convergence.httpStatus}` : "unavailable"; throw new Error( @@ -4962,6 +5124,7 @@ function failRecoveredHermesShieldsDown( allowLegacyHermesProtocol: boolean, error: unknown, throwOnError: boolean | undefined, + runtimeSelection?: OpenShellRuntimeSelection, ): never { const message = error instanceof Error ? error.message : String(error); const rollback = rollbackShieldsDown( @@ -4972,6 +5135,7 @@ function failRecoveredHermesShieldsDown( state, allowLegacyHermesProtocol, "provider-state-mutation-v2", + runtimeSelection, ); if (completion.transition && rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, completion.transition.processToken); @@ -5007,6 +5171,7 @@ function startFreshShieldsDownTimer(input: { allowLegacyHermesProtocol: boolean; deferAutoRestoreWhileOwnerAlive: boolean; policyFile: string; + runtimeSelection?: OpenShellRuntimeSelection; }): FreshShieldsDownTimerStart { const { sandboxName, @@ -5018,6 +5183,7 @@ function startFreshShieldsDownTimer(input: { allowLegacyHermesProtocol, deferAutoRestoreWhileOwnerAlive, policyFile, + runtimeSelection, } = input; const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); const timerScript = path.join(__dirname, "timer.ts"); @@ -5068,6 +5234,13 @@ function startFreshShieldsDownTimer(input: { { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], + ...(runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection, { + NEMOCLAW_GATEWAY_PORT: String(resolveShieldsStateGatewayPort()), + }), + } + : {}), }, ); if (!timerChild.pid) throw new Error("auto-restore timer did not report a process id"); @@ -5148,13 +5321,14 @@ function completeInterruptedShieldsDown( // restrictive rollback. Reconcile the recorded mutable posture and verify // it before treating this retry as complete. try { - applyRecoveredShieldsDownForwardPolicy(sandboxName, completion); + applyRecoveredShieldsDownForwardPolicy(sandboxName, completion, opts.runtimeSelection); if (retainedProviderTarget) { runHermesProviderProtectionTransition( sandboxName, retainedProviderTarget, "locked", "locked", + opts.runtimeSelection, ); } if (completion.authority) { @@ -5165,11 +5339,12 @@ function completeInterruptedShieldsDown( completionTarget, false, "provider-state-mutation-v2", + opts.runtimeSelection, ); if (completion.authority) { assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); } - finishRecoveredHermesShieldsDown(sandboxName, completion); + finishRecoveredHermesShieldsDown(sandboxName, completion, opts.runtimeSelection); } catch (error) { return failRecoveredHermesShieldsDown( sandboxName, @@ -5179,6 +5354,7 @@ function completeInterruptedShieldsDown( opts.allowLegacyHermesProtocol === true, error, opts.throwOnError, + opts.runtimeSelection, ); } if (!completion.alreadyCommitted) { @@ -5231,6 +5407,7 @@ function shieldsDownWithoutHostLock( retainedProviderTarget, "locked", "locked", + opts.runtimeSelection, ); } console.error(" Shields state is corrupt; refusing to unlock."); @@ -5241,7 +5418,13 @@ function shieldsDownWithoutHostLock( } let recoveredProviderTarget: AgentConfigTarget | null = null; if (retainedProviderTarget && state.shieldsDown !== true) { - runHermesProviderProtectionTransition(sandboxName, retainedProviderTarget, "locked", "locked"); + runHermesProviderProtectionTransition( + sandboxName, + retainedProviderTarget, + "locked", + "locked", + opts.runtimeSelection, + ); recoveredProviderTarget = retainedProviderTarget; } const initialMode = deriveShieldsMode(state, state._hasStateFile); @@ -5261,7 +5444,7 @@ function shieldsDownWithoutHostLock( } if (isEquivalentShieldsDownRequest(state, timeoutSeconds, reason, policyName)) { if (!hasEquivalentShieldsDownTimerAuthority(sandboxName, state)) { - recoverExpiredAutoRestoreInline(sandboxName, state); + recoverExpiredAutoRestoreInline(sandboxName, state, opts.runtimeSelection); console.error( " Cannot accept equivalent shields down request without live auto-restore timer authority.", ); @@ -5280,7 +5463,12 @@ function shieldsDownWithoutHostLock( return failShieldsCommand(`Config is already unlocked for ${sandboxName}`, opts.throwOnError); } - const policyContext = assertShieldsPolicyMutationContext(sandboxName, "lower Shields"); + const policyContext = assertShieldsPolicyMutationContext( + sandboxName, + "lower Shields", + undefined, + opts.runtimeSelection, + ); // Resolve the old-image compatibility contract before touching timers, // host state, policy, or sandbox files. A transport failure or an @@ -5326,7 +5514,18 @@ function shieldsDownWithoutHostLock( console.log(" Capturing current policy snapshot..."); let rawPolicy: string; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName, policyContext.gatewayName)); + rawPolicy = runCapture( + buildPolicyGetCommand(sandboxName, policyContext.gatewayName), + { + ignoreError: true, + ...(opts.runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(opts.runtimeSelection), + replaceEnv: true as const, + } + : {}), + }, + ); } catch { rawPolicy = ""; } @@ -5430,6 +5629,7 @@ function shieldsDownWithoutHostLock( allowLegacyHermesProtocol: opts.allowLegacyHermesProtocol === true, deferAutoRestoreWhileOwnerAlive: opts.deferAutoRestoreWhileOwnerAlive === true, policyFile, + runtimeSelection: opts.runtimeSelection, }); } catch (error) { cleanupRuntimePolicyFile(); @@ -5482,6 +5682,7 @@ function shieldsDownWithoutHostLock( state, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); if (rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); @@ -5508,9 +5709,7 @@ function shieldsDownWithoutHostLock( assertShieldsPolicyMutationContext(sandboxName, "apply the Shields down policy", policyContext); policySetResult = run( buildPolicySetCommand(policyPathForApply, sandboxName, policyContext.gatewayName), - { - ignoreError: true, - }, + withSelectedOpenShellEnv({ ignoreError: true }, opts.runtimeSelection), ); } finally { cleanupRuntimePolicyFile(); @@ -5605,11 +5804,15 @@ function shieldsDownWithoutHostLock( initialMode === "locked", opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); } if (target.agentName === "hermes") { console.log(" Confirming Hermes inference route after policy transition..."); - const convergence = waitForHermesInferenceRouteConvergence(sandboxName, { run }); + const convergence = waitForSelectedHermesInferenceRouteConvergence( + sandboxName, + opts.runtimeSelection, + ); if (!convergence.ok) { inferenceRouteConvergenceFailed = true; const status = @@ -5629,6 +5832,7 @@ function shieldsDownWithoutHostLock( state, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); transition = persistIncompleteShieldsDownPosture( sandboxName, @@ -5688,6 +5892,7 @@ function shieldsDownWithoutHostLock( state, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); if (rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); @@ -5748,6 +5953,7 @@ function shieldsDown( () => shieldsDownWithoutHostLock(sandboxName, effectiveOpts), processToken, opts.assertCommandAvailable, + opts.runtimeSelection, ); } catch (error) { return completeDeferredShieldsExit(error, opts.throwOnError === true); @@ -5766,13 +5972,17 @@ type ShieldsUpOpts = { allowLegacyHermesProtocol?: boolean; policySnapshotRecovery?: ShieldsPolicySnapshotRecovery; assertCommandAvailable?: () => void; + runtimeSelection?: OpenShellRuntimeSelection; }; function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}): void { validateName(sandboxName, "sandbox name"); const state = loadShieldsState(sandboxName); - const recoveredProviderTarget = recoverActiveHermesRuntimeProviderMutation(sandboxName); + const recoveredProviderTarget = recoverActiveHermesRuntimeProviderMutation( + sandboxName, + opts.runtimeSelection, + ); if (state._isCorrupt) { console.error(" Shields state is corrupt; refusing to raise shields."); console.error( @@ -5816,7 +6026,13 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) target.agentName === "hermes" && inspectHermesShieldsProtocol(sandboxName, target) === "provider-state-mutation-v2" ) { - runHermesProviderProtectionTransition(sandboxName, target, "locked", "locked"); + runHermesProviderProtectionTransition( + sandboxName, + target, + "locked", + "locked", + opts.runtimeSelection, + ); } const { issues } = verifyShieldsLockState(sandboxName, target, { verifyChattr: state.chattrApplied === true, @@ -5924,6 +6140,7 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) true, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ), { confirm: hermesProviderLockConfirmation(sandboxName, target, protocol) }, ); @@ -5990,6 +6207,7 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) opts.allowLegacyHermesProtocol === true, target, protocol, + { runtimeSelection: opts.runtimeSelection }, ); if (!activation.ok) { const configLocked = @@ -6032,6 +6250,7 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) false, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ), { confirm: hermesProviderLockConfirmation(sandboxName, target, protocol) }, ); @@ -6107,6 +6326,7 @@ function shieldsUp(sandboxName: string, opts: ShieldsUpOpts = {}): void { () => shieldsUpWithoutHostLock(sandboxName, opts), undefined, opts.assertCommandAvailable, + opts.runtimeSelection, ); } catch (error) { return completeDeferredShieldsExit(error, opts.throwOnError === true); @@ -6392,8 +6612,12 @@ function shieldsStatus( * pending. User-facing callers should use getShieldsPosture() so fresh state * is labeled as "not configured" instead of "down". */ -function isShieldsDown(sandboxName: string, allowInlineRecovery = false): boolean { - const posture = getShieldsPosture(sandboxName, allowInlineRecovery); +function isShieldsDown( + sandboxName: string, + allowInlineRecovery = false, + runtimeSelection?: OpenShellRuntimeSelection, +): boolean { + const posture = getShieldsPosture(sandboxName, allowInlineRecovery, runtimeSelection); return posture.mode === "mutable_default" || posture.mode === "temporarily_unlocked"; } diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index 47a76dcd12a..442e70307b0 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -9,13 +9,23 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { HERMES_PROVIDER_CAPABILITY_PATH as CAPABILITY_PATH, + HERMES_TEST_GUARD as HERMES_GUARD, + HERMES_TEST_PYTHON as HERMES_PYTHON, + commandFromCall, createFailingCapabilityProbeResponse, createHermesShieldsProviderConsumerHarness, createRetainedUnlockSimulation, createTimerAuthorizationSender, createTransitionFailureForPosture, + forwardPolicyFailureFixtures, hermesProviderConsumerSandbox as sandbox, hermesProviderConsumerTarget as target, + hermesTestStateLockPlan as STATE_LOCK_PLAN, + hermesTestTarget as hermesTarget, + isHermesGuardAction as isGuardAction, + isInlinePython, + isIsolatedInlinePython, + isRuntimeStateMutationCapabilityProbe, writeBoundForwardPolicy, writeBoundPolicySnapshot, writeTimerAuthorizationProof, @@ -26,10 +36,6 @@ import { testTimeout } from "../../../test/helpers/timeouts"; const requireSource = createRequire(import.meta.url); const INDEX_MODULE = "./index.js"; -const HERMES_PYTHON = "/opt/hermes/.venv/bin/python"; -const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; -const RUNTIME_STATE_MUTATION_CAPABILITY = - "/usr/local/share/nemoclaw/runtime-state-mutation-publisher-v1.json"; const LOCK_TOKEN = "a".repeat(64); const OLD_GUARD_HELP = "usage: guard {ensure-api-key,refresh-hashes,provider-placeholders}"; const PARTIAL_GUARD_HELP = "begin-shields-transition --rollback-shields-mode"; @@ -58,129 +64,6 @@ const CURRENT_GUARD_HELP = [ type ShieldsModule = typeof import("./index"); -const STATE_LOCK_PLAN = { - version: 1 as const, - readOnlyRoots: ["skills"], - confidentialRoots: ["pairing"], - readOnlyPrefixes: [], - confidentialPrefixes: [], - writableSubpaths: [], -}; - -function hermesTarget() { - return { - agentName: "hermes", - configPath: "/sandbox/.hermes/config.yaml", - configDir: "/sandbox/.hermes", - format: "yaml", - configFile: "config.yaml", - sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], - stateLockPlan: STATE_LOCK_PLAN, - stateLockPlanInImage: true, - }; -} - -function commandFromCall(call: unknown[]): string[] { - return call[0] as string[]; -} - -function isGuardAction(cmd: string[], action: string): boolean { - const guardIndex = cmd.indexOf(HERMES_GUARD); - return guardIndex >= 0 && cmd[guardIndex + 1] === action; -} - -function isInlinePython(cmd: string[]): boolean { - return cmd[0] === "python3" && cmd.includes("-c"); -} - -function isIsolatedInlinePython(cmd: string[]): boolean { - return isInlinePython(cmd) && cmd[1] === "-I" && cmd[2] === "-c"; -} - -function isRuntimeStateMutationCapabilityProbe(cmd: string[]): boolean { - return ( - cmd[0] === HERMES_PYTHON && - cmd[1] === "-I" && - cmd[2] === "-c" && - cmd[3]?.includes("os.lstat") === true && - cmd.at(-1) === RUNTIME_STATE_MUTATION_CAPABILITY - ); -} - -type ForwardPolicyFailureSetup = (input: { - readonly forwardPolicyPath: string; - readonly routeSpy: MockInstance; - readonly timerPath: string; -}) => void; - -type ForwardPolicyFailureAssertion = (input: { - readonly routeSpy: MockInstance; - readonly runSpy: MockInstance; - readonly transitionPath: string; - readonly transitionSpy: MockInstance; -}) => void; - -function removeForwardPolicy({ - forwardPolicyPath, -}: Parameters[0]): void { - fs.rmSync(forwardPolicyPath); -} - -function tamperForwardPolicy({ - forwardPolicyPath, -}: Parameters[0]): void { - fs.writeFileSync(forwardPolicyPath, "tampered\n", { mode: 0o600 }); -} - -function replaceTimerDuringRoute({ - routeSpy, - timerPath, -}: Parameters[0]): void { - routeSpy.mockImplementation(() => { - const marker = JSON.parse(fs.readFileSync(timerPath, "utf-8")); - fs.writeFileSync( - timerPath, - JSON.stringify({ ...marker, timerProcessStartIdentity: "replacement-timer-start" }), - ); - return { ok: true, attempts: 1, httpStatus: 200 }; - }); -} - -function expectForwardPolicyRejectedBeforeMutation({ - routeSpy, - runSpy, - transitionSpy, -}: Parameters[0]): void { - expect(runSpy).not.toHaveBeenCalled(); - expect(transitionSpy).not.toHaveBeenCalled(); - expect(routeSpy).not.toHaveBeenCalled(); -} - -function expectTimerReplacementRejectedAfterMutation({ - routeSpy, - runSpy, - transitionPath, - transitionSpy, -}: Parameters[0]): void { - expect(runSpy).toHaveBeenCalled(); - expect(transitionSpy).toHaveBeenCalled(); - expect(routeSpy).toHaveBeenCalledTimes(1); - expect(fs.existsSync(transitionPath)).toBe(true); -} - -const forwardPolicyFailureFixtures: ReadonlyArray< - readonly [string, ForwardPolicyFailureSetup, RegExp, ForwardPolicyFailureAssertion] -> = [ - ["missing", removeForwardPolicy, /forward policy/u, expectForwardPolicyRejectedBeforeMutation], - ["tampered", tamperForwardPolicy, /forward policy/u, expectForwardPolicyRejectedBeforeMutation], - [ - "timer-replaced", - replaceTimerDuringRoute, - /auto-restore authority changed|timer generation/iu, - expectTimerReplacementRejectedAfterMutation, - ], -]; - describe("legacy Hermes shields compatibility", () => { let homeDir: string; let shields: ShieldsModule; diff --git a/src/lib/shields/transition-lock.ts b/src/lib/shields/transition-lock.ts index 8cf56ef2fc9..ff6f71212dc 100644 --- a/src/lib/shields/transition-lock.ts +++ b/src/lib/shields/transition-lock.ts @@ -12,7 +12,10 @@ import { NAME_MAX_LENGTH, NAME_VALID_PATTERN, } from "../name-validation"; -import { resolveNemoclawStateDir } from "../state/paths"; +import { + resolveNemoclawStateDir, + resolveNemoclawStateGatewayPort, +} from "../state/paths"; import { isProcessAlive, readProcessStartIdentity } from "./timer-control"; /** Shared state-root authority for callers already serialized by this facade. */ @@ -20,6 +23,11 @@ export function resolveShieldsStateDir(homeDir?: string): string { return resolveNemoclawStateDir(homeDir); } +/** Validated gateway port paired with the Shields state directory. */ +export function resolveShieldsStateGatewayPort(): number { + return resolveNemoclawStateGatewayPort(); +} + const LOCK_VERSION = 1; const MAX_OWNER_BYTES = 16 * 1024; const DEFAULT_WAIT_TIMEOUT_MS = 30_000; diff --git a/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts b/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts index bc12eb63148..0aed12ec536 100644 --- a/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts +++ b/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mockManagedEndpointlessProviderProfileRun } from "../../helpers/onboard-script-mocks.cjs"; @@ -27,7 +27,8 @@ vi.mock("../../../src/lib/adapters/openshell/provider-command", () => ({ runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, })); -vi.mock("../../../src/lib/gateway-runtime-action", () => ({ +vi.mock("../../../src/lib/gateway-runtime-action", async (importOriginal) => ({ + ...(await importOriginal()), recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, })); @@ -51,11 +52,19 @@ vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.106"); const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_GATEWAY_MANAGEMENT = process.env.NEMOCLAW_GATEWAY_MANAGEMENT; const ORIGINAL_OPENSHELL_BIN = process.env.NEMOCLAW_OPENSHELL_BIN; const ORIGINAL_OPENSHELL_GATEWAY = process.env.OPENSHELL_GATEWAY; const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-legacy-")); +const GATEWAY_MANAGEMENT = path.join(TMP_HOME, "gateway-management.json"); + +fs.writeFileSync( + GATEWAY_MANAGEMENT, + JSON.stringify({ version: 1, mode: "nemoclaw-managed", requiredCapabilities: [] }), +); process.env.HOME = TMP_HOME; +process.env.NEMOCLAW_GATEWAY_MANAGEMENT = GATEWAY_MANAGEMENT; process.env.NEMOCLAW_OPENSHELL_BIN = MATCHING_OPENSHELL; const registry = await import("../../../src/lib/state/registry"); @@ -99,13 +108,19 @@ function restoreEnvironmentVariable(name: string, value: string | undefined): vo afterAll(() => { restoreEnvironmentVariable("HOME", ORIGINAL_HOME); + restoreEnvironmentVariable("NEMOCLAW_GATEWAY_MANAGEMENT", ORIGINAL_GATEWAY_MANAGEMENT); restoreEnvironmentVariable("NEMOCLAW_OPENSHELL_BIN", ORIGINAL_OPENSHELL_BIN); restoreEnvironmentVariable("OPENSHELL_GATEWAY", ORIGINAL_OPENSHELL_GATEWAY); fs.rmSync(TMP_HOME, { recursive: true, force: true }); }); +afterEach(() => { + restoreEnvironmentVariable("NEMOCLAW_GATEWAY_MANAGEMENT", ORIGINAL_GATEWAY_MANAGEMENT); +}); + beforeEach(() => { fs.rmSync(path.dirname(registry.REGISTRY_FILE), { recursive: true, force: true }); + process.env.NEMOCLAW_GATEWAY_MANAGEMENT = GATEWAY_MANAGEMENT; restoreEnvironmentVariable("OPENSHELL_GATEWAY", ORIGINAL_OPENSHELL_GATEWAY); providerExists = true; diff --git a/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts b/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts index 2a7a4013c1d..5e04b0f6539 100644 --- a/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts +++ b/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ executeGatewaySupervisorAction: vi.fn(), executeSandboxCommand: vi.fn(), + getSandbox: vi.fn(), })); vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ @@ -13,24 +14,46 @@ vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ executeSandboxCommand: mocks.executeSandboxCommand, })); +vi.mock("../../../src/lib/state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: mocks.getSandbox, +})); + import { assertAgentMcpMutationRuntimeCapability } from "../../../src/lib/actions/sandbox/mcp-bridge-adapters"; +beforeEach(() => { + mocks.getSandbox.mockReset().mockReturnValue({ + agent: "langchain-deepagents-code", + gatewayName: "nemoclaw-8091", + name: "deepagents-box", + }); +}); + type ProbeResult = { status: number; stdout: string; stderr: string } | null; function runDeepAgentsProbe(result: ProbeResult) { mocks.executeSandboxCommand.mockReset().mockReturnValue(result); + const runtimeSelection = { + gatewayName: "nemoclaw-8091", + workspace: "default", + } as const; let message = ""; try { - assertAgentMcpMutationRuntimeCapability("deepagents-box", "deepagents-config"); + assertAgentMcpMutationRuntimeCapability( + "deepagents-box", + "deepagents-config", + runtimeSelection, + ); } catch (error) { message = error instanceof Error ? error.message : String(error); } return { - calls: mocks.executeSandboxCommand.mock.calls.map(([sandboxName, command]) => ({ + calls: mocks.executeSandboxCommand.mock.calls.map(([sandboxName, command, options]) => ({ sandboxName, command, + runtimeSelection: options?.runtimeSelection, })), message, }; @@ -49,6 +72,10 @@ describe("Deep Agents managed MCP runtime capability", () => { { sandboxName: "deepagents-box", command: "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability", + runtimeSelection: { + gatewayName: "nemoclaw-8091", + workspace: "default", + }, }, ], message: "", diff --git a/test/agents/hermes/hermes-mcp-startup-probe.test.ts b/test/agents/hermes/hermes-mcp-startup-probe.test.ts index de2e5ab8d7c..c6b182cb18d 100644 --- a/test/agents/hermes/hermes-mcp-startup-probe.test.ts +++ b/test/agents/hermes/hermes-mcp-startup-probe.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ executeGatewaySupervisorAction: vi.fn(), + getSandbox: vi.fn(), isShieldsDown: vi.fn(), runOpenshellProviderCommand: vi.fn(), sleepMs: vi.fn(), @@ -26,6 +27,11 @@ vi.mock("../../../src/lib/core/wait", () => ({ waitUntil: mocks.waitUntil, })); +vi.mock("../../../src/lib/state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: mocks.getSandbox, +})); + vi.mock("../../../src/lib/shields", () => ({ isShieldsDown: mocks.isShieldsDown, })); @@ -40,11 +46,21 @@ function runHermesProbe( shieldsDown = true, supervisorResults: SupervisorResult[] = [], ) { + const runtimeSelection = { + gatewayName: "nemoclaw-8091", + workspace: "default", + } as const; let calls = 0; let recoveryCalls = 0; const recoveryActions: Array<{ action: string; timeout: number }> = []; - mocks.runOpenshellProviderCommand.mockImplementation(() => results[calls++]); + mocks.runOpenshellProviderCommand.mockImplementation((_args, options) => { + expect(options?.runtimeSelection).toEqual({ + gatewayName: "nemoclaw-8091", + workspace: "default", + }); + return results[calls++]; + }); mocks.executeGatewaySupervisorAction.mockImplementation( (_sandbox: string, action: string, timeout: number) => { recoveryActions.push({ action, timeout }); @@ -70,7 +86,7 @@ function runHermesProbe( let message = ""; try { - assertAgentMcpMutationRuntimeCapability("hermes-box", "hermes-config"); + assertAgentMcpMutationRuntimeCapability("hermes-box", "hermes-config", runtimeSelection); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -80,6 +96,11 @@ function runHermesProbe( beforeEach(() => { vi.resetAllMocks(); + mocks.getSandbox.mockReturnValue({ + agent: "hermes", + gatewayName: "nemoclaw-8091", + name: "hermes-box", + }); }); const starting: ProbeResult = { @@ -158,16 +179,19 @@ describe("Hermes managed MCP startup probe", () => { "GATEWAY_HEALTH_TIMEOUT", "SUPERVISOR_TIMEOUT", "SUPERVISOR_BUSY", - ])("fails typed managed-recovery integrity refusal %s without another sandbox probe", (marker) => { - const result = runHermesProbe([starting, starting, starting, ready], true, [ - { status: 1, stdout: "", stderr: marker }, - ]); - - expect(result.calls).toBe(3); - expect(result.recoveryActions).toEqual([{ action: "recover", timeout: 210_000 }]); - expect(result.message).toContain("managed gateway recovery failed before MCP mutation"); - expect(result.message).toContain(marker); - }); + ])( + "fails typed managed-recovery integrity refusal %s without another sandbox probe", + (marker) => { + const result = runHermesProbe([starting, starting, starting, ready], true, [ + { status: 1, stdout: "", stderr: marker }, + ]); + + expect(result.calls).toBe(3); + expect(result.recoveryActions).toEqual([{ action: "recover", timeout: 210_000 }]); + expect(result.message).toContain("managed gateway recovery failed before MCP mutation"); + expect(result.message).toContain(marker); + }, + ); it.each([ { diff --git a/test/helpers/hermes-shields-provider-consumer-harness.ts b/test/helpers/hermes-shields-provider-consumer-harness.ts index 557a8d6f85c..65a7f4c4c4f 100644 --- a/test/helpers/hermes-shields-provider-consumer-harness.ts +++ b/test/helpers/hermes-shields-provider-consumer-harness.ts @@ -6,7 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { type MockInstance, vi } from "vitest"; +import { expect, type MockInstance, vi } from "vitest"; import type { SandboxEntry } from "../../src/lib/state/registry"; import { livePolicyMutationContext } from "./shields-flow-harness"; @@ -26,6 +26,133 @@ const SEALED_PLAN_HELP = [ "--state-lock-plan-json", ].join(" "); +export const HERMES_TEST_PYTHON = "/opt/hermes/.venv/bin/python"; +export const HERMES_TEST_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; +export const HERMES_TEST_RUNTIME_STATE_MUTATION_CAPABILITY = + "/usr/local/share/nemoclaw/runtime-state-mutation-publisher-v1.json"; +export const hermesTestStateLockPlan = { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: ["pairing"], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], +}; + +export function hermesTestTarget() { + return { + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes", + format: "yaml", + configFile: "config.yaml", + sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], + stateLockPlan: hermesTestStateLockPlan, + stateLockPlanInImage: true, + }; +} + +export function commandFromCall(call: unknown[]): string[] { + return call[0] as string[]; +} + +export function isHermesGuardAction(cmd: string[], action: string): boolean { + const guardIndex = cmd.indexOf(HERMES_TEST_GUARD); + return guardIndex >= 0 && cmd[guardIndex + 1] === action; +} + +export function isInlinePython(cmd: string[]): boolean { + return cmd[0] === "python3" && cmd.includes("-c"); +} + +export function isIsolatedInlinePython(cmd: string[]): boolean { + return isInlinePython(cmd) && cmd[1] === "-I" && cmd[2] === "-c"; +} + +export function isRuntimeStateMutationCapabilityProbe(cmd: string[]): boolean { + return ( + cmd[0] === HERMES_TEST_PYTHON && + cmd[1] === "-I" && + cmd[2] === "-c" && + cmd[3]?.includes("os.lstat") === true && + cmd.at(-1) === HERMES_TEST_RUNTIME_STATE_MUTATION_CAPABILITY + ); +} + +type ForwardPolicyFailureSetup = (input: { + readonly forwardPolicyPath: string; + readonly routeSpy: MockInstance; + readonly timerPath: string; +}) => void; + +type ForwardPolicyFailureAssertion = (input: { + readonly routeSpy: MockInstance; + readonly runSpy: MockInstance; + readonly transitionPath: string; + readonly transitionSpy: MockInstance; +}) => void; + +function removeForwardPolicy({ + forwardPolicyPath, +}: Parameters[0]): void { + fs.rmSync(forwardPolicyPath); +} + +function tamperForwardPolicy({ + forwardPolicyPath, +}: Parameters[0]): void { + fs.writeFileSync(forwardPolicyPath, "tampered\n", { mode: 0o600 }); +} + +function replaceTimerDuringRoute({ + routeSpy, + timerPath, +}: Parameters[0]): void { + routeSpy.mockImplementation(() => { + const marker = JSON.parse(fs.readFileSync(timerPath, "utf-8")); + fs.writeFileSync( + timerPath, + JSON.stringify({ ...marker, timerProcessStartIdentity: "replacement-timer-start" }), + ); + return { ok: true, attempts: 1, httpStatus: 200 }; + }); +} + +function expectForwardPolicyRejectedBeforeMutation({ + routeSpy, + runSpy, + transitionSpy, +}: Parameters[0]): void { + expect(runSpy).not.toHaveBeenCalled(); + expect(transitionSpy).not.toHaveBeenCalled(); + expect(routeSpy).not.toHaveBeenCalled(); +} + +function expectTimerReplacementRejectedAfterMutation({ + routeSpy, + runSpy, + transitionPath, + transitionSpy, +}: Parameters[0]): void { + expect(runSpy).toHaveBeenCalled(); + expect(transitionSpy).toHaveBeenCalled(); + expect(routeSpy).toHaveBeenCalledTimes(1); + expect(fs.existsSync(transitionPath)).toBe(true); +} + +export const forwardPolicyFailureFixtures: ReadonlyArray< + readonly [string, ForwardPolicyFailureSetup, RegExp, ForwardPolicyFailureAssertion] +> = [ + ["missing", removeForwardPolicy, /forward policy/u, expectForwardPolicyRejectedBeforeMutation], + ["tampered", tamperForwardPolicy, /forward policy/u, expectForwardPolicyRejectedBeforeMutation], + [ + "timer-replaced", + replaceTimerDuringRoute, + /auto-restore authority changed|timer generation/iu, + expectTimerReplacementRejectedAfterMutation, + ], +]; + export const hermesProviderConsumerTarget = { agentName: "hermes", configPath: "/sandbox/.hermes/config.yaml", @@ -299,7 +426,6 @@ export function createHermesShieldsProviderConsumerHarness( String(name), ]), vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(livePolicyMutationContext), - vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(livePolicyMutationContext), vi.spyOn(policy, "recheckPolicyMutationContext").mockReturnValue(livePolicyMutationContext), vi.spyOn(policy, "verifyAppliedPolicyDocument").mockImplementation(() => undefined), registrySpy, diff --git a/test/helpers/shields-flow-harness.ts b/test/helpers/shields-flow-harness.ts index f90a46fe897..9986a33e3e8 100644 --- a/test/helpers/shields-flow-harness.ts +++ b/test/helpers/shields-flow-harness.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -163,6 +164,111 @@ export function writeShieldsTimerAuthorizationProof( ); } +export function writeBoundPolicySnapshot( + snapshotPath: string, + content = "version: 1\nnetwork_policies:\n test: {}\n", +) { + fs.writeFileSync(snapshotPath, content, { mode: 0o600 }); + fs.chmodSync(snapshotPath, 0o600); + const metadata = fs.statSync(snapshotPath); + return { + schemaVersion: 1 as const, + path: snapshotPath, + sha256: createHash("sha256").update(content).digest("hex"), + size: Buffer.byteLength(content), + mode: 0o600, + uid: metadata.uid, + gid: metadata.gid, + nlink: 1 as const, + }; +} + +export function writeActivePolicyTransition( + stateDir: string, + sandboxName: string, + processToken: string, + snapshotPath: string, + snapshotPolicy: ReturnType, +): void { + const forwardPolicy = writeBoundPolicySnapshot( + path.join(stateDir, `policy-forward-${processToken.slice(0, 8)}.yaml`), + ); + fs.writeFileSync( + path.join(stateDir, `shields-transition-${sandboxName}-${processToken}.json`), + JSON.stringify({ + version: 1, + phase: "active", + ownerPid: 2_147_483_647, + ownerStartIdentity: "test-timer-owner", + processToken, + sandboxName, + snapshotPath, + snapshotPolicy, + forwardPolicy, + }), + { mode: 0o600 }, + ); +} + +export const timerAuthorityFixtures: ReadonlyArray< + readonly [string, (markerPath: string) => void] +> = [ + ["missing", () => undefined], + ["malformed", (markerPath) => fs.writeFileSync(markerPath, "{not-json")], +]; + +export function writeExpiredShieldsFixture( + tmpDir: string, + currentProcessStartIdentity: string | null, + processToken: string, + reason: string, + ownerState: "dead" | "live", +) { + const liveOwner = ownerState === "live"; + const sandboxName = "openclaw"; + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, `snapshot-${processToken.slice(0, 8)}.yaml`); + const timerMarkerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); + const transitionLockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); + fs.mkdirSync(stateDir, { recursive: true }); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ + shieldsDown: true, + shieldsDownAt: new Date(Date.now() - 120_000).toISOString(), + shieldsDownTimeout: 60, + shieldsDownReason: reason, + shieldsDownPolicy: "permissive", + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, + }), + ); + fs.writeFileSync( + timerMarkerPath, + JSON.stringify({ + pid: liveOwner ? 2_147_483_647 : 4242, + sandboxName, + snapshotPath, + restoreAt: new Date(Date.now() - 60_000).toISOString(), + processToken, + }), + ); + fs.writeFileSync( + transitionLockPath, + JSON.stringify({ + version: 1, + sandboxName, + pid: liveOwner ? process.pid : 4242, + processStartIdentity: liveOwner ? currentProcessStartIdentity : "dead-timer", + command: liveOwner ? "shields down" : "shields auto-restore", + acquiredAtMs: Date.now() - 60_000, + takeoverToken: processToken, + }), + ); + return { stateDir, timerMarkerPath, transitionLockPath }; +} + function throwHarnessError(error: Error): never { throw error; } diff --git a/test/mcp/mcp-adapter-teardown-rollback.test.ts b/test/mcp/mcp-adapter-teardown-rollback.test.ts index 8dc5303424a..12b3b605554 100644 --- a/test/mcp/mcp-adapter-teardown-rollback.test.ts +++ b/test/mcp/mcp-adapter-teardown-rollback.test.ts @@ -43,6 +43,7 @@ const entry: McpBridgeEntry = { addedAt: "2026-06-27T00:00:00.000Z", }; const sandbox: SandboxEntry = { name: "alpha" }; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" } as const; describe("MCP adapter teardown rollback", () => { beforeEach(() => { @@ -54,15 +55,19 @@ describe("MCP adapter teardown rollback", () => { const opaqueRevision = "v4067750153477477215"; testState.observeCredentialRevision.mockReturnValue(opaqueRevision); - const failures = rollbackScrubbedMcpAdapters("alpha", sandbox, [ - { ...entry, credentialRevision: "v1" }, - ]); + const failures = rollbackScrubbedMcpAdapters( + "alpha", + sandbox, + [{ ...entry, credentialRevision: "v1" }], + runtimeSelection, + ); expect(failures).toEqual([]); expect(testState.registerAdapter).toHaveBeenCalledWith( "alpha", "mcporter", expect.objectContaining({ server: "github" }), + runtimeSelection, {}, opaqueRevision, { replaceExisting: true, teardownRollback: true }, @@ -74,9 +79,12 @@ describe("MCP adapter teardown rollback", () => { (observation) => { testState.observeCredentialRevision.mockReturnValue(observation); - const failures = rollbackScrubbedMcpAdapters("alpha", sandbox, [ - { ...entry, credentialRevision: "v4067750153477477215" }, - ]); + const failures = rollbackScrubbedMcpAdapters( + "alpha", + sandbox, + [{ ...entry, credentialRevision: "v4067750153477477215" }], + runtimeSelection, + ); expect(failures).toEqual([ "Could not restore the managed adapter entry for MCP server 'github' without its observed credential revision.", diff --git a/test/mcp/mcp-add-crash-consistency.test.ts b/test/mcp/mcp-add-crash-consistency.test.ts index f31cfb26bdb..b0393723c95 100644 --- a/test/mcp/mcp-add-crash-consistency.test.ts +++ b/test/mcp/mcp-add-crash-consistency.test.ts @@ -35,11 +35,13 @@ function buildAddProcessScript( crashAfter: CrashBoundary, includeSecret = true, initializeSandbox = true, + forbidRuntimeSelection = false, ): string { return String.raw` process.env.HOME = ${JSON.stringify(home)}; const includeSecret = ${JSON.stringify(includeSecret)}; const initializeSandbox = ${JSON.stringify(initializeSandbox)}; +const forbidRuntimeSelection = ${JSON.stringify(forbidRuntimeSelection)}; includeSecret ? (process.env.FAKE_MCP_SECRET = "host-only-secret") : delete process.env.FAKE_MCP_SECRET; const fs = require("node:fs"); const path = require("node:path"); @@ -86,6 +88,7 @@ let credentialObservationAfterRepublishCountThisProcess = 0; const registry = require("./src/lib/state/registry.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const runner = require("./src/lib/runner.js"); @@ -103,6 +106,13 @@ const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const ownershipLocks = require("./src/lib/state/mcp-lifecycle-lock/credential-ownership.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => { + if (forbidRuntimeSelection) { + throw new Error("runtime selection resolved before local MCP add validation"); + } + return { gatewayName: "nemoclaw", workspace: "default" }; +}; + if (crashAfter === "credential-command-race") { const withMcpCredentialOwnershipLock = ownershipLocks.withMcpCredentialOwnershipLock; ownershipLocks.withMcpCredentialOwnershipLock = (operation) => { @@ -134,7 +144,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { if (crashAfter === "late-race" && providerGetCount === 3) mark("provider"); return marked("provider") ? { status: 0, stdout: "Id: " + (marked("foreign-provider") ? foreignProviderId : providerId) + "\nType: nemoclaw-mcp-v1\nResource version: " + providerVersion() + "\nCredential keys: FAKE_MCP_SECRET\n", stderr: "" } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "provider" && (args[1] === "create" || args[1] === "update")) { if (credentialProjectionScenario) { @@ -421,8 +431,19 @@ function initializeSandboxRegistry(home: string): void { ).toBe(0); } -function runAddProcess(home: string, crashAfter: CrashBoundary, includeSecret = true) { - const script = buildAddProcessScript(home, crashAfter, includeSecret); +function runAddProcess( + home: string, + crashAfter: CrashBoundary, + includeSecret = true, + forbidRuntimeSelection = false, +) { + const script = buildAddProcessScript( + home, + crashAfter, + includeSecret, + true, + forbidRuntimeSelection, + ); return spawnSync(process.execPath, ["-e", script], { cwd: process.cwd(), encoding: "utf8", @@ -529,11 +550,17 @@ const providerId = "11111111-2222-4333-8444-555555555555"; let observedProviderName = null; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); + gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, attempted: false, @@ -551,7 +578,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { observedProviderName = args[2]; return marked("provider") ? { status: 0, stdout: "Id: " + providerId + "\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: FAKE_MCP_SECRET\n", stderr: "" } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { observedProviderName = args[4]; @@ -581,7 +608,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { } if (args[0] === "provider" && args[1] === "delete") { if (!marked("provider")) { - return { status: 1, stdout: "", stderr: "NotFound: provider" }; + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } fs.rmSync(marker("provider"), { force: true }); if (crashAfterProviderDelete) process.exit(87); @@ -629,11 +656,17 @@ function runStatusProcess(home: string) { const script = String.raw` process.env.HOME = ${JSON.stringify(home)}; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); + gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, attempted: false, @@ -830,7 +863,7 @@ describe("MCP add crash consistency", () => { it("rejects a missing host credential before creating durable MCP state", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-missing-secret-")); try { - const result = runAddProcess(home, "", false); + const result = runAddProcess(home, "", false, true); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(2); expect(result.stderr).toContain("Host environment variable 'FAKE_MCP_SECRET' is required"); diff --git a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts index 92e693793de..95d66fce6c9 100644 --- a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts +++ b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts @@ -44,7 +44,14 @@ function runNodeScript( home: string, script: string, ): { status: number | null; stdout: string; stderr: string } { - const result = spawnSync(process.execPath, ["-e", script], { + const runtimeSelectionSetup = ` +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); +`; + const result = spawnSync(process.execPath, ["-e", `${runtimeSelectionSetup}\n${script}`], { cwd: process.cwd(), encoding: "utf8", env: { ...process.env, HOME: home, NODE_OPTIONS: sourceNodeOptions }, @@ -241,7 +248,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { stdout: "Id: " + expectedId + "\nType: nemoclaw-mcp-v1\nResource version: 4\nCredential keys: EXPECTED_TOKEN\n", stderr: "", } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { events.push(attached ? "provider:list:attached" : "provider:list:detached"); diff --git a/test/mcp/mcp-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index 8bd10be0537..498d0ca81a3 100644 --- a/test/mcp/mcp-destroy-lifecycle.test.ts +++ b/test/mcp/mcp-destroy-lifecycle.test.ts @@ -49,6 +49,7 @@ const testState = vi.hoisted(() => { removePreset: vi.fn(), runOpenshell: vi.fn(), runOpenshellProviderCommand: vi.fn(), + runtimeSelection: { gatewayName: "nemoclaw", workspace: "default" } as const, stopNimContainer: vi.fn(), stopNimContainerByName: vi.fn(), warnUnpreservedUserManagedFiles: vi.fn(), @@ -69,7 +70,8 @@ vi.mock("../../src/lib/adapters/openshell/runtime", async (importOriginal) => ({ runOpenshell: testState.runOpenshell, })); -vi.mock("../../src/lib/gateway-runtime-action", () => ({ +vi.mock("../../src/lib/gateway-runtime-action", async (importOriginal) => ({ + ...(await importOriginal()), recoverNamedGatewayRuntime: testState.recoverNamedGatewayRuntime, })); @@ -87,6 +89,11 @@ vi.mock("../../src/lib/actions/sandbox/process-recovery", () => ({ executeSandboxExecCommand: testState.executeSandboxExecCommand, })); +vi.mock("../../src/lib/actions/sandbox/mcp-bridge-provider", async (importOriginal) => ({ + ...(await importOriginal()), + getMcpProviderInspectionRuntimeSelection: vi.fn(() => testState.runtimeSelection), +})); + vi.mock("../../src/lib/actions/sandbox/policy-get", () => ({ getSandboxPolicy: testState.getSandboxPolicy, })); @@ -647,6 +654,31 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(testState.removePreset).not.toHaveBeenCalled(); }); + it("rejects changed gateway authority before exec-unavailable provider inspection (#10514)", async () => { + registerAlphaGithubBridge(); + const before = registry.getSandbox("alpha"); + + const message = await captureMessage(() => + bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha", { + gatewayName: "nemoclaw-19080", + workspace: "default", + }), + ); + + expect(message).toContain( + "Recorded OpenShell target gateway 'nemoclaw-19080' no longer matches observed target gateway 'nemoclaw'", + ); + expect(message).toContain("NemoClaw did not delete the original sandbox"); + expect(message).toContain( + "Restore recorded gateway 'nemoclaw-19080', confirm it is healthy, then retry", + ); + expect(registry.getSandbox("alpha")).toEqual(before); + expect(testState.calls).toEqual([]); + expect(testState.adapterCalls).toEqual([]); + expect(testState.applyPresetContent).not.toHaveBeenCalled(); + expect(testState.removePreset).not.toHaveBeenCalled(); + }); + it("rejects a credential-key collision during host-side rebuild recovery (#9388)", async () => { registerAlphaGithubBridge(); testState.providers.set("example-api", { @@ -923,7 +955,7 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { registry.updateSandbox("alpha", { gatewayPort: 19080 }); expect(() => preparation.assertDeleteEdgeUnchanged?.()).toThrow( - /changed its recorded gateway/i, + "changed its recorded gateway after host-side rebuild preflight from 'nemoclaw' to 'nemoclaw-19080'. NemoClaw did not delete the original sandbox. Restore recorded gateway 'nemoclaw', confirm it is healthy, then retry.", ); expect(registry.getSandbox("alpha")?.gatewayPort).toBe(19080); expect(testState.adapterCalls).toEqual([]); diff --git a/test/mcp/mcp-provider-detach-retry.test.ts b/test/mcp/mcp-provider-detach-retry.test.ts index c96df17b054..4584c1e9e5e 100644 --- a/test/mcp/mcp-provider-detach-retry.test.ts +++ b/test/mcp/mcp-provider-detach-retry.test.ts @@ -16,7 +16,14 @@ const foreignId = "99999999-8888-4777-8666-555555555555"; let attached = true; let liveId = expectedId; let detachCalls = 0; -providerCommands.runOpenshellProviderCommand = (args) => { +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; +providerCommands.runOpenshellProviderCommand = (args, options) => { + if ( + options?.runtimeSelection?.gatewayName !== runtimeSelection.gatewayName || + options?.runtimeSelection?.workspace !== runtimeSelection.workspace + ) { + throw new Error("provider command did not retain the recorded runtime selection"); + } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return attached ? { status: 0, stdout: "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-fake nemoclaw-mcp-v1 1 0\n", stderr: "" } @@ -62,7 +69,7 @@ const entry = { let outcome = null; let message = null; try { - outcome = providerActions.detachProvider("alpha", entry); + outcome = providerActions.detachProvider("alpha", entry, { runtimeSelection }); } catch (error) { message = error.message; } diff --git a/test/mcp/mcp-provider-ownership.test.ts b/test/mcp/mcp-provider-ownership.test.ts index 02c0acda9e5..49a8ef4b40d 100644 --- a/test/mcp/mcp-provider-ownership.test.ts +++ b/test/mcp/mcp-provider-ownership.test.ts @@ -19,12 +19,17 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; const foreignId = "99999999-8888-4777-8666-555555555555"; let liveId = expectedId; let attached = true; let policyState = "match"; const calls = []; +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => { throw new Error("persisted adapter must be used"); }; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, @@ -117,11 +122,16 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; let providerExists = true; let attached = true; let policyState = "match"; const calls = []; +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => { throw new Error("persisted adapter must be used"); }; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, @@ -139,7 +149,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { stdout: "Id: " + expectedId + "\nType: nemoclaw-mcp-v1\nResource version: 4\nCredential keys: LD_PRELOAD\n", stderr: "", } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return { @@ -261,7 +271,12 @@ const registry = require("./src/lib/state/registry.js"); const agentDefs = require("./src/lib/agent/defs.js"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => ({ name: "openclaw", displayName: "OpenClaw", @@ -340,10 +355,11 @@ process.env.HOME = ${JSON.stringify(home)}; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; const attached = new Set(["alpha-mcp-fake", "alpha-mcp-second"]); +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); if (args[0] === "provider" && args[1] === "get") { - return { status: 1, stdout: "", stderr: "NotFound: provider" }; + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return attached.size > 0 @@ -372,17 +388,17 @@ const entry = { adapter: "mcporter", addedAt: "2026-06-01T00:00:00.000Z", }; -const before = providerActions.inspectMcpProviderAttachments("alpha"); -const firstOutcome = providerActions.detachMissingProviderReference("alpha", entry); -const afterFirst = providerActions.inspectMcpProviderAttachments("alpha"); +const before = providerActions.inspectMcpProviderAttachments("alpha", runtimeSelection); +const firstOutcome = providerActions.detachMissingProviderReference("alpha", entry, runtimeSelection); +const afterFirst = providerActions.inspectMcpProviderAttachments("alpha", runtimeSelection); const secondOutcome = providerActions.detachMissingProviderReference("alpha", { ...entry, server: "second", providerName: "alpha-mcp-second", providerId: "22222222-3333-4444-8555-666666666666", policyName: "mcp-bridge-second", -}); -const after = providerActions.inspectMcpProviderAttachments("alpha"); +}, runtimeSelection); +const after = providerActions.inspectMcpProviderAttachments("alpha", runtimeSelection); process.stdout.write(JSON.stringify({ before, firstOutcome, afterFirst, secondOutcome, after, calls })); `; const result = spawnSync(process.execPath, ["-e", script], { @@ -429,6 +445,7 @@ process.env.EXPECTED_TOKEN = "host-only-secret"; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; let resourceVersion = 4; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); if (args[0] === "provider" && args[1] === "get") { @@ -457,6 +474,7 @@ try { { allowExisting: true, expectedProviderId: "11111111-2222-4333-8444-555555555555", + runtimeSelection, }, ); } catch (error) { @@ -494,10 +512,11 @@ process.env.HOME = ${JSON.stringify(home)}; process.env.EXPECTED_TOKEN = "host-only-secret"; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); if (args[0] === "provider" && args[1] === "get") { - return { status: 1, stdout: "", stderr: "NotFound: provider" }; + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } throw new Error("unexpected call: " + args.join(" ")); }; @@ -511,6 +530,7 @@ try { allowExisting: true, expectedProviderId: "11111111-2222-4333-8444-555555555555", requireExisting: true, + runtimeSelection, }, ); } catch (error) { @@ -543,7 +563,12 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const calls = []; +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => { throw new Error("persisted adapter must be used"); }; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, diff --git a/test/mcp/mcp-restart-policy-order.test.ts b/test/mcp/mcp-restart-policy-order.test.ts index 90f0fcace30..c7594dac67a 100644 --- a/test/mcp/mcp-restart-policy-order.test.ts +++ b/test/mcp/mcp-restart-policy-order.test.ts @@ -21,12 +21,18 @@ process.env.FIRST_MCP_TOKEN = "first-host-only-secret"; process.env.SECOND_MCP_TOKEN = "second-host-only-secret"; const registry = require("./src/lib/state/registry.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const generated = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); + const providerCalls = []; let policyApplyCalls = 0; const operation = ${JSON.stringify(operation)}; @@ -78,7 +84,9 @@ providerCommands.runOpenshellProviderCommand = (args) => { }; } const entry = Object.values(entries).find((candidate) => candidate.providerName === args[2]); - if (!entry) return { status: 1, stdout: "", stderr: "NotFound: provider" }; + if (!entry) { + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; + } return { status: 0, stdout: "Id: " + entry.providerId + "\nType: nemoclaw-mcp-v1\nResource version: " + (updatedProviders.has(entry.providerName) ? "2" : "1") + "\nCredential keys: " + entry.env[0] + "\n", @@ -170,12 +178,18 @@ process.env.HOME = ${JSON.stringify(home)}; process.env.MCP_TOKEN = "host-only-secret"; const registry = require("./src/lib/state/registry.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const generated = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); + let resourceVersion = 1; let registeredProviderGets = 0; const observations = []; @@ -295,4 +309,47 @@ bridge.restartMcpBridge("alpha", "example").then( expect(payload.proofScripts).toHaveLength(6); expect(payload.proofScripts.join("\n")).not.toMatch(/\/tmp|snapshot/); }); + + it("returns the OpenClaw no-server result before resolving runtime authority (#10514)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-restart-empty-")); + const script = ` +process.env.HOME = ${JSON.stringify(home)}; +const registry = require("./src/lib/state/registry.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => { + throw new Error("runtime selection resolved before the local no-server result"); +}; +registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", +}); +const lines = []; +const originalLog = console.log; +console.log = (...args) => lines.push(args.join(" ")); +require("./src/lib/actions/sandbox/mcp-bridge.js").restartMcpBridge("alpha").then( + () => { + console.log = originalLog; + process.stdout.write(JSON.stringify({ lines })); + }, + (error) => { + console.log = originalLog; + console.error(error); + process.exit(1); + }, +); +`; + const result = spawnSync(process.execPath, ["-e", script], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, HOME: home }, + timeout: 30_000, + }); + fs.rmSync(home, { recursive: true, force: true }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + lines: [" No MCP servers for sandbox 'alpha'."], + }); + }); }); From dbcb8c878f98f98370cd9ba61905087311f6fb47 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 3 Sep 2026 12:47:20 -0700 Subject: [PATCH 3/8] fix(openshell): fail closed for selected exec Signed-off-by: Prekshi Vyas --- .../sandbox/process-recovery-temp-ssh.test.ts | 22 +++++++++++++++++++ src/lib/actions/sandbox/process-recovery.ts | 7 +++++- .../docker-driver-gateway-env-service.test.ts | 1 - ...river-gateway-service-version-gate.test.ts | 1 - src/lib/onboard/gateway/late-binding.test.ts | 10 ++++----- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts index a928f998502..c9d4cd96e64 100644 --- a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts +++ b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts @@ -162,6 +162,28 @@ describe("executeSandboxCommand temp SSH config", () => { expect(dockerSpawnSync).not.toHaveBeenCalled(); }); + it("refuses local fallback when selected OpenShell exec is inconclusive (#10514)", () => { + vi.mocked(spawnSync).mockReturnValue({ + status: 1, + stdout: "untrusted output", + stderr: "", + pid: 1234, + output: [], + signal: null, + }); + + expect( + executeSandboxExecCommand("alpha", "printf revision-1", undefined, { + allowLocalDockerFallback: true, + runtimeSelection: { + gatewayName: "external-http", + workspace: "default", + }, + }), + ).toBeNull(); + expect(dockerSpawnSync).not.toHaveBeenCalled(); + }); + it("uses the exact legacy alias while backing up a pre-upgrade sandbox", () => { captureSandboxSshConfig.mockReturnValue({ status: 0, diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 9021bd98d8f..4e607cdb7da 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -248,7 +248,12 @@ export function executeSandboxExecCommand( timeout, { ...transportOptions, - ...(runtimeSelection ? { gatewayName: runtimeSelection.gatewayName } : {}), + ...(runtimeSelection + ? { + allowLocalDockerFallback: false, + gatewayName: runtimeSelection.gatewayName, + } + : {}), ...(runtimeEnv ? { runtimeEnv } : {}), }, ); diff --git a/src/lib/onboard/docker-driver-gateway-env-service.test.ts b/src/lib/onboard/docker-driver-gateway-env-service.test.ts index 52a09485303..98fc1a0a9f0 100644 --- a/src/lib/onboard/docker-driver-gateway-env-service.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env-service.test.ts @@ -63,7 +63,6 @@ describe("package-managed Docker-driver gateway env service", () => { }), ).resolves.toBe(true); - expect(observedEnv).toBe(selectedEnv); expect(observedEnv).toEqual( expect.objectContaining({ OPENSHELL_GATEWAY: "nemoclaw", diff --git a/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts b/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts index da81bacfdc8..dffebd967d7 100644 --- a/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts +++ b/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts @@ -95,7 +95,6 @@ describe("package-managed gateway version gate (#8094)", () => { }); expect(verdict.supported).toBe(true); - expect(observedEnv).toBe(selectedEnv); expect(observedEnv?.OPENSHELL_GATEWAY).toBe("nemoclaw"); expect(observedEnv?.OPENSHELL_WORKSPACE).toBe("default"); expect(observedEnv?.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index 3703714694b..28cbaa95f20 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -106,9 +106,9 @@ describe("gateway lifecycle late binding", () => { }), replaceEnv: true, }); - expect(runCaptureOpenshell.mock.calls[0]?.[1]?.env).toBe(selectedOptions?.env); - expect(runCaptureOpenshell.mock.calls[1]?.[1]?.env).toBe(selectedOptions?.env); - expect(runCaptureOpenshell.mock.calls[2]?.[1]?.env).toBe(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[0]?.[1]?.env).toEqual(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[1]?.[1]?.env).toEqual(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[2]?.[1]?.env).toEqual(selectedOptions?.env); expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_TLS"); @@ -292,7 +292,7 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; - expect(runtimeIdentityOptions?.env).toBe(managedOptions?.env); + expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( expect.objectContaining({ OPENSHELL_GATEWAY: "resumed", @@ -317,7 +317,7 @@ describe("gateway lifecycle late binding", () => { }), replaceEnv: true, }); - expect(statusOptions?.env).toBe(versionOptions?.env); + expect(statusOptions?.env).toEqual(versionOptions?.env); expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_TLS"); From 98346abd159d7d93be5f2f2a5a47b4cc987b0f16 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 1 Sep 2026 13:41:36 -0700 Subject: [PATCH 4/8] refactor(mcp): bind lifecycle to recorded runtime Signed-off-by: Apurv Kumaria (cherry picked from commit 69d87705ad335a3b62fd5619e0078576abdf7531) Signed-off-by: Prekshi Vyas --- src/lib/actions/sandbox/gateway-target.ts | 15 + ...cp-bridge-adapter-deepagents-capability.ts | 10 +- .../mcp-bridge-adapter-deepagents-command.ts | 4 +- ...cp-bridge-adapter-deepagents-inspection.ts | 3 + ...-bridge-adapter-deepagents-registration.ts | 13 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 3 + ...mcp-bridge-adapter-hermes-branding.test.ts | 88 +++- .../sandbox/mcp-bridge-adapter-hermes.ts | 110 ++--- .../sandbox/mcp-bridge-adapter-inspection.ts | 4 +- .../mcp-bridge-adapter-openclaw.test.ts | 5 +- .../sandbox/mcp-bridge-adapter-openclaw.ts | 17 +- .../mcp-bridge-adapter-registration.test.ts | 86 +++- .../mcp-bridge-adapter-teardown.test.ts | 68 +-- .../sandbox/mcp-bridge-adapter-teardown.ts | 10 +- .../actions/sandbox/mcp-bridge-adapters.ts | 34 +- .../actions/sandbox/mcp-bridge-add-restart.ts | 138 ++++-- .../mcp-bridge-destroy-preflight.test.ts | 92 ++++ .../sandbox/mcp-bridge-destroy-preflight.ts | 57 ++- src/lib/actions/sandbox/mcp-bridge-destroy.ts | 87 +++- .../mcp-bridge-hermes-reconciliation.test.ts | 7 +- .../mcp-bridge-hermes-reconciliation.ts | 8 + .../sandbox/mcp-bridge-input-targets.test.ts | 8 +- .../actions/sandbox/mcp-bridge-policy.test.ts | 43 +- src/lib/actions/sandbox/mcp-bridge-policy.ts | 28 +- .../mcp-bridge-private-lifecycle.test.ts | 2 + .../mcp-bridge-provider-attachments.ts | 38 +- .../mcp-bridge-provider-inspection.test.ts | 212 ++++++++++ .../sandbox/mcp-bridge-provider-inspection.ts | 122 +++++- .../sandbox/mcp-bridge-provider-mutation.ts | 67 ++- .../mcp-bridge-provider-profile.test.ts | 20 +- .../sandbox/mcp-bridge-provider-readiness.ts | 25 +- .../sandbox/mcp-bridge-provider.test.ts | 400 ++++++++++++++++-- .../actions/sandbox/mcp-bridge-provider.ts | 2 + .../mcp-bridge-rebuild-exec-unavailable.ts | 63 ++- src/lib/actions/sandbox/mcp-bridge-rebuild.ts | 131 ++++-- .../actions/sandbox/mcp-bridge-recovery.ts | 10 +- src/lib/actions/sandbox/mcp-bridge-remove.ts | 47 +- .../mcp-bridge-resolution-probe.test.ts | 49 ++- .../sandbox/mcp-bridge-resolution-probe.ts | 6 +- src/lib/actions/sandbox/mcp-bridge-restart.ts | 134 ++++-- .../mcp-bridge-runtime-capabilities.ts | 13 +- src/lib/actions/sandbox/mcp-bridge-state.ts | 14 +- .../mcp-bridge-status-boundaries.test.ts | 112 ++++- src/lib/actions/sandbox/mcp-bridge-status.ts | 48 ++- .../sandbox/mcp-bridge-tool-discovery.ts | 4 +- src/lib/actions/sandbox/mcp-bridge.ts | 37 +- .../adapters/openshell/provider-command.ts | 12 +- src/lib/policy/index.ts | 33 +- src/lib/policy/policy-live-state.test.ts | 24 ++ .../deepagents-mcp-legacy-lifecycle.test.ts | 19 +- .../deepagents-mcp-runtime-capability.test.ts | 33 +- .../hermes/hermes-mcp-startup-probe.test.ts | 108 +---- .../mcp/mcp-adapter-teardown-rollback.test.ts | 20 +- test/mcp/mcp-add-crash-consistency.test.ts | 45 +- ...mcp-bridge-destroy-marker-recovery.test.ts | 13 +- test/mcp/mcp-provider-detach-retry.test.ts | 11 +- test/mcp/mcp-provider-ownership.test.ts | 41 +- 57 files changed, 2297 insertions(+), 556 deletions(-) create mode 100644 src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts create mode 100644 src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts diff --git a/src/lib/actions/sandbox/gateway-target.ts b/src/lib/actions/sandbox/gateway-target.ts index a36b2cfbf9c..be9c8441455 100644 --- a/src/lib/actions/sandbox/gateway-target.ts +++ b/src/lib/actions/sandbox/gateway-target.ts @@ -4,6 +4,7 @@ import { GATEWAY_PORT } from "../../core/ports"; import { resolveGatewayName, + resolveGatewayPortFromName, resolveSandboxGatewayName, type SandboxGatewayBinding, } from "../../onboard/gateway-binding"; @@ -31,6 +32,20 @@ export function getPersistedSandboxTargetGatewayName(sandbox: SandboxGatewayBind return resolveSandboxGatewayName(sandbox); } +/** Resolve the complete canonical gateway binding from one persisted sandbox row. */ +export function getPersistedSandboxTargetGateway(sandbox: SandboxGatewayBinding): { + gatewayName: string; + gatewayPort: number; + selectedInProcess: boolean; +} { + const gatewayName = getPersistedSandboxTargetGatewayName(sandbox); + const gatewayPort = resolveGatewayPortFromName(gatewayName); + if (gatewayPort === null) { + throw new Error(`Invalid persisted OpenShell gateway '${gatewayName}'.`); + } + return { gatewayName, gatewayPort, selectedInProcess: gatewayPort === GATEWAY_PORT }; +} + export function gatewayNamePattern(gatewayName: string): RegExp { return new RegExp( `Gateway:\\s+${gatewayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts index b6bbcaa64f9..80d8a57860a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts @@ -2,14 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 import { McpBridgeError } from "./mcp-bridge-contracts"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { executeSandboxCommand } from "./process-recovery"; const DEEPAGENTS_MCP_CAPABILITY_MARKER = "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2"; const DEEPAGENTS_MCP_CAPABILITY_COMMAND = "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability"; -export function assertDeepAgentsMcpMutationRuntimeCapability(sandboxName: string): void { - const result = executeSandboxCommand(sandboxName, DEEPAGENTS_MCP_CAPABILITY_COMMAND); +export function assertDeepAgentsMcpMutationRuntimeCapability( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + const result = executeSandboxCommand(sandboxName, DEEPAGENTS_MCP_CAPABILITY_COMMAND, { + runtimeSelection, + }); if (result?.status !== 0 || result.stdout.trim() !== DEEPAGENTS_MCP_CAPABILITY_MARKER) { throw new McpBridgeError( `LangChain Deep Agents Code sandbox '${sandboxName}' does not contain managed MCP capability v2. Rebuild the sandbox before changing authenticated MCP state.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts index f7994e40eb9..4d03b6ba4cb 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts @@ -5,6 +5,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import type { AdapterMutationOptions } from "./mcp-bridge-adapter-inspection"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { executeSandboxCommand } from "./process-recovery"; export function runDeepAgentsAdapterCommand( @@ -12,9 +13,10 @@ export function runDeepAgentsAdapterCommand( entry: Pick, command: string, failureMessage: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): string { - const result = executeSandboxCommand(sandboxName, command); + const result = executeSandboxCommand(sandboxName, command, { runtimeSelection }); const output = redactBridgeSecretsForDisplay( [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), entry, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts index bbe998a330d..3dc30f4dd9c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { type AdapterRegistrationInspection, inspectAdapterRegistrationCommand, @@ -12,11 +13,13 @@ import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; export function inspectDeepAgentsAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): AdapterRegistrationInspection { return inspectAdapterRegistrationCommand( sandboxName, entry, buildDeepAgentsMcpStatusCommand(entry, credentialRevision), + runtimeSelection, ); } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 0b9365de1b0..20ed008d3b8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -17,6 +17,7 @@ import { pythonJsonLiteral, } from "./mcp-bridge-adapter-status"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { McpBridgeError } from "./mcp-bridge-contracts"; export function buildDeepAgentsMcpRegisterCommand( @@ -118,9 +119,15 @@ function registryOwnedDeepAgentsEntries( function verifyDeepAgentsAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): void { - const inspection = inspectDeepAgentsAdapterRegistration(sandboxName, entry, credentialRevision); + const inspection = inspectDeepAgentsAdapterRegistration( + sandboxName, + entry, + runtimeSelection, + credentialRevision, + ); if (inspection.state === "registered") return; const detail = inspection.state === "error" ? inspection.detail : inspection.state; throw new McpBridgeError( @@ -131,6 +138,7 @@ function verifyDeepAgentsAdapterRegistration( export function registerDeepAgentsAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, teardownRollback = false, @@ -147,6 +155,7 @@ export function registerDeepAgentsAdapter( credentialRevision, ), `Deep Agents Code MCP config registration failed for '${entry.server}'.`, + runtimeSelection, { envValues }, ); if (teardownRollback) { @@ -156,6 +165,6 @@ export function registerDeepAgentsAdapter( ); } } else { - verifyDeepAgentsAdapterRegistration(sandboxName, entry, credentialRevision); + verifyDeepAgentsAdapterRegistration(sandboxName, entry, runtimeSelection, credentialRevision); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index c27d89dc2f3..9899c12f83c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import { runDeepAgentsAdapterCommand } from "./mcp-bridge-adapter-deepagents-command"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, @@ -180,6 +181,7 @@ export function buildDeepAgentsMcpRemoveCommand( export function unregisterDeepAgentsAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): AdapterRemovalOutcome { const stdout = runDeepAgentsAdapterCommand( @@ -187,6 +189,7 @@ export function unregisterDeepAgentsAdapter( entry, buildDeepAgentsMcpRemoveCommand(entry, options.force === true, options.teardown === true), `Deep Agents Code MCP config removal failed for '${entry.server}'.`, + runtimeSelection, options, ); const marker = stdout.match(/NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=(removed|absent|unowned)/); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts index fd7b2bbe665..b3360e8a75e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes-branding.test.ts @@ -3,33 +3,109 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; +import type { McpBridgeEntry } from "../../state/registry"; + const mocks = vi.hoisted(() => ({ - runOpenshellProviderCommand: vi.fn(), + getSandboxOrThrow: vi.fn(), + runOpenshell: vi.fn(), })); -vi.mock("../../adapters/openshell/provider-command", () => ({ - runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, +vi.mock("./mcp-bridge-state", () => ({ + getSandboxOrThrow: mocks.getSandboxOrThrow, })); -import { assertHermesMcpMutationRuntimeCapability } from "./mcp-bridge-adapter-hermes"; +import { + assertHermesMcpMutationRuntimeCapability, + unregisterHermesAdapter, +} from "./mcp-bridge-adapter-hermes"; + +const entry: McpBridgeEntry = { + server: "github", + agent: "hermes", + adapter: "hermes-config", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: new Date(0).toISOString(), +}; + +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; describe("Hermes MCP recovery guidance", () => { beforeEach(() => { vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemohermes"); - mocks.runOpenshellProviderCommand.mockReset().mockReturnValue({ + mocks.getSandboxOrThrow.mockReset().mockReturnValue({ + agent: "hermes", + gatewayName: "nemoclaw-8091", + name: "alpha", + }); + mocks.runOpenshell.mockReset().mockReturnValue({ status: 1, stdout: "", stderr: "Hermes gateway is not running under the managed service lifecycle", }); + setProviderCommandRuntimeHooksForTest({ runOpenshell: mocks.runOpenshell }); }); afterEach(() => { + setProviderCommandRuntimeHooksForTest({}); vi.unstubAllEnvs(); }); it("uses the invoked CLI name when the managed lifecycle is unavailable", () => { - expect(() => assertHermesMcpMutationRuntimeCapability("alpha")).toThrow( + expect(() => assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection)).toThrow( "Run `nemohermes alpha recover` and retry.", ); }); + + it("pins Hermes MCP lifecycle commands to the recorded runtime 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"); + mocks.runOpenshell.mockImplementation((_args, options) => { + expect(options).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-8091", + OPENSHELL_WORKSPACE: "default", + }), + replaceEnv: true, + }), + ); + expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE"); + expect(options?.env).not.toHaveProperty("OPENSHELL_LOCAL_TLS_DIR"); + expect(options?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + return { + status: 0, + stdout: JSON.stringify({ changed: true, ok: true, reloaded: true }), + stderr: "", + }; + }); + + expect(() => + assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection), + ).not.toThrow(); + expect(() => unregisterHermesAdapter("alpha", entry, runtimeSelection)).not.toThrow(); + expect(mocks.runOpenshell).toHaveBeenCalledTimes(2); + }); + + it("refuses host-local recovery when the selected Hermes gateway is not ready (#10514)", () => { + mocks.runOpenshell.mockReturnValue({ + status: 1, + stdout: "", + stderr: "Hermes gateway is not running for managed MCP reload", + }); + + expect(() => assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection)).toThrow( + "NemoClaw did not attempt host-local supervisor recovery.", + ); + expect(mocks.runOpenshell).toHaveBeenCalledTimes(3); + }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts index 6f84341e5cc..3c2fea22e4b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts @@ -5,10 +5,6 @@ import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-c import { getAgentBranding } from "../../cli/branding"; import { waitUntil } from "../../core/wait"; import type { McpBridgeEntry } from "../../state/registry"; -import { - classifyGatewayRestartFailure, - parseManagedGatewayControlCompletion, -} from "./gateway-restart"; import { type AdapterMutationOptions, type AdapterRegistrationInspection, @@ -19,18 +15,13 @@ import { entryHeaders, HERMES_MCP_TRANSACTION_HELPER, } from "./mcp-bridge-adapter-status"; -import { - type McpAttachedCredentialRevision, - observeMcpCredentialRevision, -} from "./mcp-bridge-provider-readiness"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; -import { executeGatewaySupervisorAction } from "./process-recovery"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; +import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; const HERMES_MCP_EXEC_TIMEOUT_SECONDS = 620; const HERMES_MCP_PROBE_TIMEOUT_SECONDS = 30; -const HERMES_MCP_STARTUP_TIMEOUT_SECONDS = 90; -const HERMES_MCP_RECOVERY_TIMEOUT_MS = 210_000; const HERMES_MCP_INITIAL_PROBE_ATTEMPTS = 3; const HERMES_MCP_GATEWAY_NOT_READY = "Hermes gateway is not running for managed MCP reload"; const HERMES_MCP_LIFECYCLE_NOT_READY = @@ -85,12 +76,14 @@ export function buildHermesMcpProbeCommand(): string[] { export function inspectHermesAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): AdapterRegistrationInspection { return inspectAdapterRegistrationCommand( sandboxName, entry, buildHermesMcpStatusCommand(entry, credentialRevision), + runtimeSelection, ); } @@ -113,7 +106,10 @@ function parseLastJsonObject(output: string): Record | null { * and can invoke it through OpenShell current main's ordinary exec path before * changing a global provider, policy, attachment, or adapter. */ -export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): void { +export function assertHermesMcpMutationRuntimeCapability( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { let lastDetail = ""; const probe = (): boolean => { let result: ReturnType; @@ -126,6 +122,7 @@ export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): v ), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], timeout: 45_000, }, @@ -161,55 +158,9 @@ export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): v return; } - let recovery: ReturnType = null; - let recoveryFailureDetail = ""; - try { - recovery = executeGatewaySupervisorAction( - sandboxName, - "recover", - HERMES_MCP_RECOVERY_TIMEOUT_MS, - ); - } catch (error) { - recoveryFailureDetail = error instanceof Error ? error.message : String(error); - } - const recoveryCompleted = parseManagedGatewayControlCompletion(recovery) !== null; - if (!recoveryCompleted) { - recoveryFailureDetail ||= recovery ? commandOutput(recovery).trim() : "no controller result"; - const classification = classifyGatewayRestartFailure(recovery); - const claimsInvalidCompletion = - recovery !== null && (recovery.status === 0 || recovery.stdout.trim().length > 0); - const terminalIntegrityFailure = - claimsInvalidCompletion || - classification.layer === "secret-boundary refusal" || - classification.layer === "unsafe config path" || - classification.layer === "config hash mismatch" || - classification.layer === "relaunch quarantined" || - classification.layer === "health timeout" || - recoveryFailureDetail.includes("SUPERVISOR_REBUILD_REQUIRED") || - recoveryFailureDetail.includes("SUPERVISOR_UNSAFE_CONTROL_DIR") || - recoveryFailureDetail.includes("SUPERVISOR_BUSY") || - recoveryFailureDetail.includes("SUPERVISOR_INVALID_") || - recoveryFailureDetail.includes("GATEWAY_GUARDS_MISSING"); - if (terminalIntegrityFailure) { - throw new McpBridgeError( - `Hermes sandbox '${sandboxName}' managed gateway recovery failed before MCP mutation: ${recoveryFailureDetail || classification.detail}.`, - ); - } - } - - // A privileged controller completion never authorizes mutation by itself. - // Even when transient controller unavailability lets the managed lifecycle - // finish naturally, the ordinary sandbox identity must freshly prove the - // packaged helper and a stable, trusted gateway topology before any MCP - // provider, policy, attachment, or adapter side effect. - if (!waitUntil(probe, HERMES_MCP_STARTUP_TIMEOUT_SECONDS, 1_000)) { - const recoveryDetail = recoveryFailureDetail - ? ` Managed recovery attempt did not complete: ${recoveryFailureDetail}.` - : ""; - throw new McpBridgeError( - `Hermes sandbox '${sandboxName}' cannot invoke the managed MCP transaction helper after managed gateway recovery. Rebuild the sandbox before changing authenticated MCP state${lastDetail ? `: ${lastDetail}` : "."}${recoveryDetail}`, - ); - } + throw new McpBridgeError( + `Hermes sandbox '${sandboxName}' gateway is not ready on recorded OpenShell target '${runtimeSelection.gatewayName}'. Run \`${getAgentBranding().cli} ${sandboxName} recover\` and retry. NemoClaw did not attempt host-local supervisor recovery.`, + ); } function runHermesAdapterCommand( @@ -217,6 +168,7 @@ function runHermesAdapterCommand( entry: McpBridgeEntry, command: readonly string[], failureMessage: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions & { requireReload?: boolean } = {}, ): void { // OpenShell current main executes this fixed helper argv with ordinary @@ -227,6 +179,7 @@ function runHermesAdapterCommand( try { result = runOpenshellProviderCommand(buildHermesMcpExecArgs(sandboxName, command), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], // The remote supervisor enforces 620s; keep a small transport margin so // remote termination is observed before this local subprocess is killed. @@ -274,9 +227,15 @@ function runHermesAdapterCommand( function verifyHermesAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, credentialRevision?: McpAttachedCredentialRevision, ): void { - const inspection = inspectHermesAdapterRegistration(sandboxName, entry, credentialRevision); + const inspection = inspectHermesAdapterRegistration( + sandboxName, + entry, + runtimeSelection, + credentialRevision, + ); if (inspection.state === "registered") return; const detail = inspection.state === "error" ? inspection.detail : inspection.state; throw new McpBridgeError( @@ -287,6 +246,7 @@ function verifyHermesAdapterRegistration( export function registerHermesAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, credentialRevision?: McpAttachedCredentialRevision, @@ -296,35 +256,16 @@ export function registerHermesAdapter( entry, buildHermesMcpRegisterCommand(entry, replaceExisting, credentialRevision), `Hermes MCP config registration failed for '${entry.server}'.`, + runtimeSelection, { envValues, requireReload: true }, ); - verifyHermesAdapterRegistration(sandboxName, entry, credentialRevision); - if (credentialRevision === undefined) return; - const afterReloadRevision = observeMcpCredentialRevision(sandboxName, entry); - if (afterReloadRevision === credentialRevision) return; - if (afterReloadRevision === "absent" || afterReloadRevision === "canonical") { - throw new McpBridgeError( - `Hermes MCP credential revision was unavailable after reloading '${entry.server}'.`, - ); - } - runHermesAdapterCommand( - sandboxName, - entry, - buildHermesMcpRegisterCommand(entry, true, afterReloadRevision), - `Hermes MCP config convergence failed for '${entry.server}'.`, - { envValues, requireReload: true }, - ); - verifyHermesAdapterRegistration(sandboxName, entry, afterReloadRevision); - if (observeMcpCredentialRevision(sandboxName, entry) !== afterReloadRevision) { - throw new McpBridgeError( - `Hermes MCP credential revision did not converge after reloading '${entry.server}'.`, - ); - } + verifyHermesAdapterRegistration(sandboxName, entry, runtimeSelection, credentialRevision); } export function unregisterHermesAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): void { runHermesAdapterCommand( @@ -332,6 +273,7 @@ export function unregisterHermesAdapter( entry, buildHermesMcpRemoveCommand(entry, options.force === true), `Hermes MCP config removal failed for '${entry.server}'.`, + runtimeSelection, options, ); } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts index 773e87eace0..b5236992462 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { executeSandboxCommand, type SandboxCommandResult } from "./process-recovery"; export type AdapterRegistrationInspection = @@ -50,8 +51,9 @@ export function inspectAdapterRegistrationCommand( sandboxName: string, entry: McpBridgeEntry, command: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): AdapterRegistrationInspection { - const result = executeSandboxCommand(sandboxName, command); + const result = executeSandboxCommand(sandboxName, command, { runtimeSelection }); if (!result) return { state: "error", detail: "sandbox unreachable" }; return parseAdapterRegistrationInspection(result, entry); } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts index 20816107c41..b83a839963c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts @@ -547,8 +547,9 @@ processRecovery.executeSandboxCommand = (_sandboxName, command) => { }; const adapter = require("./src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.js"); const entry = ${JSON.stringify(baseEntry)}; -adapter.registerOpenClawAdapter("custom-root-lifecycle", entry); -adapter.unregisterOpenClawAdapter("custom-root-lifecycle", entry); +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; +adapter.registerOpenClawAdapter("custom-root-lifecycle", entry, runtimeSelection); +adapter.unregisterOpenClawAdapter("custom-root-lifecycle", entry, runtimeSelection); process.stdout.write(JSON.stringify(commands)); `; const result = spawnSync(process.execPath, ["-e", script], { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts index 9103e3d0d5a..689d95925f7 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts @@ -19,6 +19,7 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { quoteMcpBridgeShellArg } from "./mcp-bridge-runtime-command"; import { getAgentConfigDir } from "./mcp-bridge-state"; @@ -39,8 +40,11 @@ function mcporterRootForEntry(entry: McpBridgeEntry): string { : OPENCLAW_MCPORTER_ROOT; } -function ensureMcporter(sandboxName: string): void { - const check = executeSandboxCommand(sandboxName, "command -v mcporter"); +function ensureMcporter( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + const check = executeSandboxCommand(sandboxName, "command -v mcporter", { runtimeSelection }); if (check?.status === 0 && check.stdout.trim()) return; throw new McpBridgeError( `mcporter is not available in sandbox '${sandboxName}'. Rebuild with a NemoClaw image that includes mcporter@${MCPORTER_VERSION}.`, @@ -130,27 +134,31 @@ export function buildOpenClawMcporterRemoveCommand( export function inspectOpenClawAdapterRegistration( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): AdapterRegistrationInspection { const root = mcporterRootForEntry(entry); return inspectAdapterRegistrationCommand( sandboxName, entry, buildOpenClawMcporterInspectCommand(entry, false, root), + runtimeSelection, ); } export function registerOpenClawAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, credentialRevision?: McpAttachedCredentialRevision, ): void { - ensureMcporter(sandboxName); + ensureMcporter(sandboxName, runtimeSelection); const root = mcporterRootForEntry(entry); const result = executeSandboxCommand( sandboxName, buildOpenClawMcporterRegisterCommand(entry, replaceExisting, root, credentialRevision), + { runtimeSelection }, ); const output = redactBridgeSecretsForDisplay( [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), @@ -168,6 +176,7 @@ export function registerOpenClawAdapter( const verification = executeSandboxCommand( sandboxName, buildOpenClawMcporterInspectCommand(entry, true, root, credentialRevision), + { runtimeSelection }, ); const verificationOutput = redactBridgeSecretsForDisplay( [verification?.stdout, verification?.stderr].filter(Boolean).join("\n").trim(), @@ -188,12 +197,14 @@ export function registerOpenClawAdapter( export function unregisterOpenClawAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): void { const root = mcporterRootForEntry(entry); const result = executeSandboxCommand( sandboxName, buildOpenClawMcporterRemoveCommand(entry, options.force === true, root), + { runtimeSelection }, ); const output = redactBridgeSecretsForDisplay( [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts index beebc882fc6..470097cf3ed 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts @@ -48,6 +48,7 @@ import { buildHermesMcpStatusCommand, registerAgentAdapter, registerAgentAdapterAtCurrentCredentialRevision, + unregisterAgentAdapter, } from "./mcp-bridge-adapters"; import { registerOpenClawAdapter } from "./mcp-bridge-adapter-openclaw"; import { entryHeaders, mcporterHeadersMatchExpected } from "./mcp-bridge-adapter-status"; @@ -72,6 +73,8 @@ const lifecycleSuccess = { const commandSuccess = { status: 0, stdout: "", stderr: "" }; const registered = { status: 0, stdout: "registered\n", stderr: "" }; const mismatch = { status: 0, stdout: "mismatch\n", stderr: "" }; +const sandbox = { name: "alpha", agent: "hermes", gatewayName: "nemoclaw-8091" }; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; interface AdapterCase { name: string; @@ -166,14 +169,14 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { mocks.executeSandboxExecCommand.mockReset(); mocks.executeGatewaySupervisorAction.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("re-reads the persisted definition before registration succeeds", () => { adapterCase.arrangeInspection(registered); expect(() => - registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, { + registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, runtimeSelection, { GITHUB_TOKEN: "host-only-secret", }), ).not.toThrow(); @@ -181,6 +184,10 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { expect(mocks.executeSandboxCommand).toHaveBeenLastCalledWith( "alpha", adapterCase.statusCommand(adapterCase.entry), + { runtimeSelection }, + ); + expect(mocks.executeSandboxCommand.mock.calls.map((call) => call[2])).toEqual( + Array(mocks.executeSandboxCommand.mock.calls.length).fill({ runtimeSelection }), ); }); @@ -188,7 +195,7 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { adapterCase.arrangeInspection(mismatch); expect(() => - registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, { + registerAgentAdapter("alpha", adapterCase.adapter, adapterCase.entry, runtimeSelection, { GITHUB_TOKEN: "host-only-secret", }), ).toThrow(`${adapterCase.adapter} config verification failed after adding 'github': mismatch.`); @@ -198,7 +205,7 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { describe("OpenClaw MCP adapter registration", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("rejects a v11 post-write observation after registering the readiness-proven v12", () => { @@ -219,7 +226,14 @@ describe("OpenClaw MCP adapter registration", () => { .mockReturnValueOnce(verification); expect(() => - registerOpenClawAdapter("alpha", entry, { GITHUB_TOKEN: "host-only-secret" }, false, "v12"), + registerOpenClawAdapter( + "alpha", + entry, + runtimeSelection, + { GITHUB_TOKEN: "host-only-secret" }, + false, + "v12", + ), ).toThrow("mcporter config verification failed after adding 'github': mismatch"); expect(mocks.executeSandboxCommand.mock.calls[1]?.[1]).toContain( @@ -228,13 +242,16 @@ describe("OpenClaw MCP adapter registration", () => { expect(mocks.executeSandboxCommand.mock.calls[2]?.[1]).toContain( "Bearer openshell:resolve:env:v12_GITHUB_TOKEN", ); + expect(mocks.executeSandboxCommand.mock.calls.map((call) => call[2])).toEqual( + Array(mocks.executeSandboxCommand.mock.calls.length).fill({ runtimeSelection }), + ); }); }); describe("Deep Agents MCP adapter credential revision", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("writes and verifies the readiness-proven revision", () => { @@ -250,6 +267,7 @@ describe("Deep Agents MCP adapter credential revision", () => { "alpha", "deepagents-config", entry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, { credentialRevision: "v12" }, ), @@ -272,7 +290,7 @@ describe("Hermes MCP adapter credential revision", () => { mocks.executeSandboxCommand.mockReset(); mocks.executeSandboxExecCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("writes and verifies the readiness-proven revision", () => { @@ -289,6 +307,7 @@ describe("Hermes MCP adapter credential revision", () => { "alpha", "hermes-config", baseEntry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, { credentialRevision: "v12" }, ), @@ -311,6 +330,7 @@ describe.each(reconciliationCases)("$name MCP credential revision reconciliation mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReturnValue(sandbox); mocks.observeMcpCredentialRevision.mockReset(); mocks.observeMcpCredentialRevision.mockReturnValue("v12"); }); @@ -324,6 +344,7 @@ describe.each(reconciliationCases)("$name MCP credential revision reconciliation "alpha", adapterCase.adapter, adapterCase.entry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, "v11", ), @@ -340,10 +361,56 @@ describe("MCP adapter credential revision reconciliation failures", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); mocks.observeMcpCredentialRevision.mockReset(); }); + it("keeps one operation target after the registry target changes (#10514)", () => { + const operationSelection = { + gatewayName: "nemoclaw-8091", + localTlsDir: "/authority/gateway-8091/tls", + workspace: "default", + } as const; + const entry: McpBridgeEntry = { + ...baseEntry, + agent: "openclaw", + adapter: "mcporter", + }; + mocks.executeSandboxCommand.mockImplementation((_sandbox, command: string) => + command === "command -v mcporter" + ? { status: 0, stdout: "/usr/bin/mcporter\n", stderr: "" } + : command.includes("config' 'add") + ? commandSuccess + : registered, + ); + mocks.observeMcpCredentialRevision.mockImplementation(() => { + mocks.getSandbox.mockReturnValue({ + agent: "openclaw", + gatewayName: "foreign-gateway", + name: "alpha", + }); + return "v11"; + }); + + expect( + registerAgentAdapterAtCurrentCredentialRevision( + "alpha", + "mcporter", + entry, + operationSelection, + {}, + "v11", + ), + ).toBe("v11"); + expect(mocks.getSandbox()).toMatchObject({ gatewayName: "foreign-gateway" }); + expect( + mocks.executeSandboxCommand.mock.calls.map((call) => call[2]?.runtimeSelection), + ).toEqual(Array(mocks.executeSandboxCommand.mock.calls.length).fill(operationSelection)); + expect(mocks.observeMcpCredentialRevision.mock.calls.map((call) => call[2])).toEqual( + Array(mocks.observeMcpCredentialRevision.mock.calls.length).fill(operationSelection), + ); + }); + it.each(["absent", "canonical"] as const)( "fails closed when reconciliation observes %s credential authority", (observation) => { @@ -361,6 +428,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v11", ), @@ -384,6 +452,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v10", ), @@ -409,6 +478,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v10", ), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts index d4d0f672f6d..95968809c92 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts @@ -8,13 +8,13 @@ import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; const mocks = vi.hoisted(() => ({ assertMcpDestroyNotPending: vi.fn(), bridgeState: vi.fn(), - detachProvider: vi.fn(), discardSafeIncompleteMcpAdds: vi.fn(), ensureSandboxGatewaySelected: vi.fn(), getBridgeAdapter: vi.fn(), getSandboxAgent: vi.fn(), captureRecordedSandboxBasePolicy: vi.fn(), getSandboxOrThrow: vi.fn(), + inspectExactMcpDestroyProvider: vi.fn(), inspectMcpProvider: vi.fn(), observeMcpCredentialRevision: vi.fn(), removeGeneratedPolicy: vi.fn(), @@ -42,7 +42,11 @@ vi.mock("./mcp-bridge-provider", () => ({ assertMcpProviderRecoverable: vi.fn(), assertNoProviderCredentialCollisions: vi.fn(), assertNoRegisteredProviderCredentialCollisions: vi.fn(), - detachProvider: mocks.detachProvider, + detachProvider: vi.fn(), + getMcpProviderInspectionRuntimeSelection: vi.fn(() => ({ + gatewayName: "nemoclaw-8091", + workspace: "default", + })), inspectMcpProvider: mocks.inspectMcpProvider, preflightMcpEntryTargets: vi.fn(), waitForDetachedMcpCredential: vi.fn(), @@ -51,7 +55,7 @@ vi.mock("./mcp-bridge-provider", () => ({ vi.mock("./mcp-bridge-destroy-preflight", () => ({ cloneMcpBridgeEntry: vi.fn((entry: McpBridgeEntry) => ({ ...entry, env: [...entry.env] })), discardSafeIncompleteMcpAdds: mocks.discardSafeIncompleteMcpAdds, - inspectExactMcpDestroyProvider: vi.fn(), + inspectExactMcpDestroyProvider: mocks.inspectExactMcpDestroyProvider, })); vi.mock("./mcp-bridge-policy", () => ({ @@ -71,7 +75,6 @@ vi.mock("./mcp-bridge-restart", () => ({ })); vi.mock("./mcp-bridge-runtime-capabilities", () => ({ - assertMcpAdapterConfigMutationsAllowed: vi.fn(), assertMcpAdapterTeardownRuntimeCapabilities: vi.fn(), })); @@ -96,6 +99,7 @@ import { prepareMcpBridgesForRebuild } from "./mcp-bridge-rebuild"; import { scrubManagedMcpAdapterOrThrow } from "./mcp-bridge-adapter-teardown"; const sandbox = { agent: "hermes" } as SandboxEntry; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" } as const; const entry: McpBridgeEntry = { server: "github", agent: "hermes", @@ -111,7 +115,6 @@ const entry: McpBridgeEntry = { describe("MCP adapter teardown rollback", () => { beforeEach(() => { mocks.bridgeState.mockReset().mockReturnValue({ github: entry }); - mocks.detachProvider.mockReset().mockReturnValue("detached"); mocks.discardSafeIncompleteMcpAdds.mockReset().mockResolvedValue(sandbox); mocks.ensureSandboxGatewaySelected.mockReset().mockResolvedValue(undefined); mocks.getBridgeAdapter.mockReset().mockReturnValue("hermes-config"); @@ -120,6 +123,13 @@ describe("MCP adapter teardown rollback", () => { .mockReset() .mockReturnValue("version: 1\nnetwork_policies:\n mcp_bridge_github: {}\n"); mocks.getSandboxOrThrow.mockReset().mockReturnValue(sandbox); + mocks.inspectExactMcpDestroyProvider.mockReset().mockReturnValue({ + credentialKeys: ["GITHUB_TOKEN"], + exists: true, + id: entry.providerId, + resourceVersion: 12, + type: "nemoclaw-mcp-v1", + }); mocks.inspectMcpProvider.mockReset().mockReturnValue({ exists: false }); mocks.observeMcpCredentialRevision.mockReset().mockReturnValue("v12"); mocks.removeGeneratedPolicy.mockReset().mockImplementation(() => { @@ -150,6 +160,7 @@ describe("MCP adapter teardown rollback", () => { "alpha", "hermes-config", expect.objectContaining({ ...entry, credentialRevision: "v12" }), + runtimeSelection, {}, "v13", { @@ -161,27 +172,6 @@ describe("MCP adapter teardown rollback", () => { }, ); - it("rejects a credential-bearing MCP rebuild capture before teardown side effects", async () => { - mocks.captureRecordedSandboxBasePolicy.mockReturnValue( - [ - "version: 1", - "network_policies:", - " mcp_bridge_github: {}", - "process:", - " environment:", - " SERVICE_API_KEY: opaque-late-policy-credential", - "", - ].join("\n"), - ); - - await expect(prepareMcpBridgesForRebuild("alpha")).rejects.toThrow( - "Cannot prepare the MCP rebuild policy handoff for sandbox 'alpha' because its live OpenShell policy contains a literal credential value.", - ); - expect(mocks.removeGeneratedPolicy).not.toHaveBeenCalled(); - expect(mocks.detachProvider).not.toHaveBeenCalled(); - expect(mocks.unregisterAgentAdapter).not.toHaveBeenCalled(); - }); - it("does not derive a Hermes credential revision from an exact provider resource version", () => { mocks.observeMcpCredentialRevision.mockReturnValue("absent"); mocks.inspectMcpProvider.mockReturnValue({ @@ -192,11 +182,35 @@ describe("MCP adapter teardown rollback", () => { type: "nemoclaw-mcp-v1", }); - expect(() => scrubManagedMcpAdapterOrThrow("alpha", sandbox, entry)).toThrow( + expect(() => + scrubManagedMcpAdapterOrThrow("alpha", sandbox, entry, runtimeSelection), + ).toThrow( "Could not prove a revision-scoped credential before removing the managed adapter entry for MCP server 'github'.", ); expect(mocks.inspectMcpProvider).not.toHaveBeenCalled(); expect(mocks.unregisterAgentAdapter).not.toHaveBeenCalled(); expect(mocks.registerAgentAdapterAtCurrentCredentialRevision).not.toHaveBeenCalled(); }); + + it("recovers the recorded gateway before initial destroy provider inspection (#10514)", async () => { + const events: string[] = []; + mocks.ensureSandboxGatewaySelected.mockImplementation(async () => { + events.push("gateway-selected"); + }); + mocks.inspectExactMcpDestroyProvider.mockImplementation(() => { + events.push("provider-inspected"); + return { + credentialKeys: ["GITHUB_TOKEN"], + exists: true, + id: entry.providerId, + resourceVersion: 12, + type: "nemoclaw-mcp-v1", + }; + }); + + await expect(prepareMcpBridgesForDestroy("alpha")).rejects.toThrow( + "forced lifecycle failure after adapter scrub", + ); + expect(events.slice(0, 2)).toEqual(["gateway-selected", "provider-inspected"]); + }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts index c219ba7aa66..4a5cd0d3a63 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts @@ -12,6 +12,7 @@ import { observeMcpCredentialRevision, type McpAttachedCredentialRevision, } from "./mcp-bridge-provider-readiness"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { getBridgeAdapter, getSandboxAgent } from "./mcp-bridge-state"; export type McpScrubbedAdapterEntry = McpBridgeEntry & { @@ -33,8 +34,9 @@ export function scrubManagedMcpAdapterOrThrow( sandboxName: string, sandbox: SandboxEntry, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): McpScrubbedAdapterEntry { - const observation = observeMcpCredentialRevision(sandboxName, entry); + const observation = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); if (observation === "absent" || observation === "canonical") { throw new McpBridgeError( `Could not prove a revision-scoped credential before removing the managed adapter entry for MCP server '${entry.server}'.`, @@ -42,7 +44,7 @@ export function scrubManagedMcpAdapterOrThrow( } const credentialRevision: McpAttachedCredentialRevision = observation; const adapter = resolveManagedMcpAdapter(sandbox, entry); - const removal = unregisterAgentAdapter(sandboxName, adapter, entry, { + const removal = unregisterAgentAdapter(sandboxName, adapter, entry, runtimeSelection, { envValues: {}, teardown: true, }); @@ -62,12 +64,13 @@ export function rollbackScrubbedMcpAdapters( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpScrubbedAdapterEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): string[] { const failures: string[] = []; for (const entry of entries) { let credentialRevision: McpAttachedCredentialRevision | undefined; try { - const current = observeMcpCredentialRevision(sandboxName, entry); + const current = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); if (current !== "absent" && current !== "canonical") credentialRevision = current; } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); @@ -84,6 +87,7 @@ export function rollbackScrubbedMcpAdapters( sandboxName, resolveManagedMcpAdapter(sandbox, entry), entry, + runtimeSelection, {}, credentialRevision, { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapters.ts b/src/lib/actions/sandbox/mcp-bridge-adapters.ts index 161622ffab8..1cbc1f7b69c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -31,6 +31,9 @@ import { type McpAttachedCredentialRevision, observeMcpCredentialRevision, } from "./mcp-bridge-provider-readiness"; +import { + type McpProviderInspectionRuntimeSelection, +} from "./mcp-bridge-provider-inspection"; import { waitForMcpBridgeCondition } from "./mcp-bridge/timing"; const STABLE_CREDENTIAL_REVISION_OBSERVATIONS = 3; @@ -68,27 +71,29 @@ export function inspectAgentAdapterRegistration( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): AdapterRegistrationInspection { switch (adapter) { case "mcporter": - return inspectOpenClawAdapterRegistration(sandboxName, entry); + return inspectOpenClawAdapterRegistration(sandboxName, entry, runtimeSelection); case "hermes-config": - return inspectHermesAdapterRegistration(sandboxName, entry); + return inspectHermesAdapterRegistration(sandboxName, entry, runtimeSelection); case "deepagents-config": - return inspectDeepAgentsAdapterRegistration(sandboxName, entry); + return inspectDeepAgentsAdapterRegistration(sandboxName, entry, runtimeSelection); } } export function assertAgentMcpMutationRuntimeCapability( sandboxName: string, adapter: AgentMcpAdapter, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { switch (adapter) { case "deepagents-config": - assertDeepAgentsMcpMutationRuntimeCapability(sandboxName); + assertDeepAgentsMcpMutationRuntimeCapability(sandboxName, runtimeSelection); return; case "hermes-config": - assertHermesMcpMutationRuntimeCapability(sandboxName); + assertHermesMcpMutationRuntimeCapability(sandboxName, runtimeSelection); return; case "mcporter": return; @@ -105,9 +110,10 @@ export function assertAgentMcpMutationRuntimeCapability( export function assertAgentMcpTeardownRuntimeCapability( sandboxName: string, adapter: AgentMcpAdapter, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { if (adapter === "hermes-config") { - assertAgentMcpMutationRuntimeCapability(sandboxName, adapter); + assertAgentMcpMutationRuntimeCapability(sandboxName, adapter, runtimeSelection); } } @@ -115,6 +121,7 @@ export function registerAgentAdapter( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, options: { replaceExisting?: boolean; @@ -127,6 +134,7 @@ export function registerAgentAdapter( registerOpenClawAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.credentialRevision, @@ -136,6 +144,7 @@ export function registerAgentAdapter( registerHermesAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.credentialRevision, @@ -145,6 +154,7 @@ export function registerAgentAdapter( registerDeepAgentsAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.teardownRollback === true, @@ -159,6 +169,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record, initialCredentialRevision: McpAttachedCredentialRevision, options: { replaceExisting?: boolean; teardownRollback?: boolean } = {}, @@ -174,7 +185,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( registration <= MAX_CREDENTIAL_REVISION_REGISTRATIONS; registration += 1 ) { - registerAgentAdapter(sandboxName, adapter, entry, envValues, { + registerAgentAdapter(sandboxName, adapter, entry, runtimeSelection, envValues, { replaceExisting, teardownRollback: options.teardownRollback === true, credentialRevision, @@ -184,7 +195,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( let observedRevision: McpAttachedCredentialRevision | undefined; const stable = waitForMcpBridgeCondition( () => { - const observation = observeMcpCredentialRevision(sandboxName, entry); + const observation = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); if (observation === "absent" || observation === "canonical") { throw mcpAdapterCredentialRevisionUnavailableError(entry.server); } @@ -222,16 +233,17 @@ export function unregisterAgentAdapter( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: AdapterMutationOptions = {}, ): AdapterRemovalOutcome { switch (adapter) { case "mcporter": - unregisterOpenClawAdapter(sandboxName, entry, options); + unregisterOpenClawAdapter(sandboxName, entry, runtimeSelection, options); return "removed"; case "hermes-config": - unregisterHermesAdapter(sandboxName, entry, options); + unregisterHermesAdapter(sandboxName, entry, runtimeSelection, options); return "removed"; case "deepagents-config": - return unregisterDeepAgentsAdapter(sandboxName, entry, options); + return unregisterDeepAgentsAdapter(sandboxName, entry, runtimeSelection, options); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index 407c4e5d8f7..4963d4c1955 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -38,6 +38,7 @@ import { detachMissingProviderReference, detachProvider, ensureMcpBridgeProviderProfile, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, type McpCredentialRevisionObservation, observeMcpCredentialRevision, @@ -95,8 +96,14 @@ function assertPreparedMcpAddResourcesAbsent( adapter: AgentMcpAdapter, entry: McpBridgeEntry, target: McpBridgeTargetValidation, + providerRuntimeSelection: ReturnType, ): void { - const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry); + const adapterInspection = inspectAgentAdapterRegistration( + sandboxName, + adapter, + entry, + providerRuntimeSelection, + ); if (adapterInspection.state !== "absent") { const detail = adapterInspection.state === "error" @@ -107,7 +114,7 @@ function assertPreparedMcpAddResourcesAbsent( ); } - const providerInspection = inspectMcpProvider(entry.providerName); + const providerInspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (providerInspection.exists !== false) { const detail = providerInspection.exists === null @@ -125,7 +132,12 @@ function assertPreparedMcpAddResourcesAbsent( target, entry.providerName ?? "", ); - const policyState = policies.getPresetContentGatewayState(sandboxName, policyContent); + const policyState = policies.getPresetContentGatewayState( + sandboxName, + policyContent, + undefined, + providerRuntimeSelection, + ); if (policyState !== "absent") { throw new McpBridgeError( `MCP add preflight for '${entry.server}' could not prove generated policy key '${buildMcpBridgePolicyKey(entry.server)}' absent (state: ${policyState ?? "unreachable"}). The durable add manifest was preserved without claiming it.`, @@ -136,7 +148,7 @@ function assertPreparedMcpAddResourcesAbsent( export async function addMcpBridge( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise> { return withMcpLifecycleLock(sandboxName, () => { assertHermesPortableCommandUnavailable(sandboxName, "sandbox:mcp:add"); return addMcpBridgeUnlocked(sandboxName, options); @@ -146,7 +158,7 @@ export async function addMcpBridge( async function addMcpBridgeUnlocked( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise> { validateSandboxName(sandboxName); validateMcpServerName(options.server); assertAuthenticatedCredentialReference(options.env); @@ -297,16 +309,17 @@ async function addMcpBridgeUnlocked( 1, ); } + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); // Bind the static credential-name deny-list to the OpenShell binary before // persisting ownership or mutating a provider, policy, or adapter. assertMcpCredentialBoundaryRuntimeVersion(); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); if (!existingEntry) { await withMcpCredentialOwnershipLock(() => { // Publish the durable MCP reservation under the same cross-command lock // used by credentials add. Neither command can pass its collision check // before the other records its credential-key reservation. - assertNoProviderCredentialCollisions(sandboxName, [entry]); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); writeBridgeEntry(sandboxName, entry); }); } @@ -318,7 +331,7 @@ async function addMcpBridgeUnlocked( try { let detachedMissingProviderReference = false; if (resumingPreflightedAdd) { - const providerInspection = inspectMcpProvider(entry.providerName); + const providerInspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (providerInspection.exists === null) { throw new McpBridgeError( providerInspection.error ?? @@ -332,35 +345,49 @@ async function addMcpBridgeUnlocked( // one recovery side effect that must precede the image capability // probe. It neither reads nor replaces credential material, and the // durable add manifest retains ownership if the later probe fails. - detachMissingProviderReference(sandboxName, entry); + detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); detachedMissingProviderReference = true; } } - assertAgentMcpMutationRuntimeCapability(sandboxName, adapter); + assertAgentMcpMutationRuntimeCapability(sandboxName, adapter, providerRuntimeSelection); if (detachedMissingProviderReference) { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); } if (resumingPreflightedAdd && !Object.hasOwn(adapterEnvValues, entry.env[0])) { try { // A retry may reuse an exact provider without re-exporting its secret, // but recreating a missing provider cannot. This check and any owned // policy cleanup happen only after the running-image capability probe. - assertMcpProviderRecoverable(entry); + assertMcpProviderRecoverable(entry, providerRuntimeSelection); } catch (error) { - removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); + removeGeneratedPolicy(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); throw error; } } if (entry.addState === "prepared") { - assertPreparedMcpAddResourcesAbsent(sandboxName, adapter, entry, target); + assertPreparedMcpAddResourcesAbsent( + sandboxName, + adapter, + entry, + target, + providerRuntimeSelection, + ); entry = { ...entry, addState: "preflighted" }; // This second durable boundary proves the derived resource names and the // adapter slot were absent before any side effect. After a crash, retries // may therefore reuse only missing or exact resources, never drift. writeBridgeEntry(sandboxName, entry); } - const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry); + const adapterInspection = inspectAgentAdapterRegistration( + sandboxName, + adapter, + entry, + providerRuntimeSelection, + ); if ( adapterInspection.state !== "absent" && !(resumingPreflightedAdd && adapterInspection.state === "registered") @@ -376,13 +403,16 @@ async function addMcpBridgeUnlocked( // Credential keys are sandbox-global. Prove this key is not already // supplied by a foreign attachment before opening its MCP route, then check // again after provider creation to close the intervening race. - assertNoProviderCredentialCollisions(sandboxName, [entry]); - ensureMcpBridgeProviderProfile(); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); // Load the real protocol:mcp policy without a credential binding before // provider mutation. OpenShell requires the endpointless provider to be // attached before it accepts credential_binding.provider, and withholds // that provider's static credential until the bound policy is active. - applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false }); + applyGeneratedPolicy(sandboxName, entry, target, { + bindCredential: false, + runtimeSelection: providerRuntimeSelection, + }); policyApplied = true; const providerResult = upsertMcpProvider(providerName ?? "", options.env, { // A first mutation must still observe the absence proven above. Only a @@ -390,12 +420,17 @@ async function addMcpBridgeUnlocked( // provider whose immutable ID was already persisted by this add. allowExisting: resumingPreflightedAdd, expectedProviderId: entry.providerId, + runtimeSelection: providerRuntimeSelection, prepareMutation: (action) => { // A fresh create has no prior revision to compare. Observe only the // bounded placeholder classification for an actual update, after the // running supervisor has accepted the authenticated MCP policy. if (action === "update") { - previousCredentialRevision = observeMcpCredentialRevision(sandboxName, entry); + previousCredentialRevision = observeMcpCredentialRevision( + sandboxName, + entry, + providerRuntimeSelection, + ); } }, }); @@ -413,17 +448,23 @@ async function addMcpBridgeUnlocked( // adapter mutations. A process death before this write fails closed. writeBridgeEntry(sandboxName, entry); } - assertNoProviderCredentialCollisions(sandboxName, [entry]); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); if (providerResult.action === "updated" && previousCredentialRevision === undefined) { throw new McpBridgeError( `Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`, ); } providerAttachAttempted = true; - attachProvider(sandboxName, entry); - applyGeneratedPolicy(sandboxName, entry, target); + attachProvider(sandboxName, entry, providerRuntimeSelection); + applyGeneratedPolicy(sandboxName, entry, target, { + runtimeSelection: providerRuntimeSelection, + }); let refreshedAfterObservedAbsence = false; - let credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { + let credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { ...(providerResult.action === "updated" ? { previousRevision: previousCredentialRevision, @@ -450,10 +491,14 @@ async function addMcpBridgeUnlocked( allowExisting: true, expectedProviderId: entry.providerId, requireExisting: true, + runtimeSelection: providerRuntimeSelection, }); - if (republished.action !== "updated") refreshMcpProviderEnvironment(entry); + if (republished.action !== "updated") { + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); + } }, - }); + }, + ); if (Object.hasOwn(adapterEnvValues, entry.env[0]) && !refreshedAfterObservedAbsence) { // OpenShell 0.0.106 polls provider state every ten seconds. First prove // the pre-republish generation is installed, then republish while the @@ -464,15 +509,19 @@ async function addMcpBridgeUnlocked( allowExisting: true, expectedProviderId: entry.providerId, requireExisting: true, + runtimeSelection: providerRuntimeSelection, }); - credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { - previousRevision: credentialRevision, - }); + credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { previousRevision: credentialRevision }, + ); } // The adapter was proven absent above, so cleanup is safe even when a // command commits config and then fails during its runtime reload. adapterMutationAttempted = true; - registerAgentAdapter(sandboxName, adapter, entry, adapterEnvValues, { + registerAgentAdapter(sandboxName, adapter, entry, providerRuntimeSelection, adapterEnvValues, { // An exact adapter entry is evidence of a post-commit process death. // Replacing it is idempotent and, for Hermes, re-verifies runtime reload. // The wait above already proved the same revision stable in consecutive @@ -481,43 +530,57 @@ async function addMcpBridgeUnlocked( replaceExisting: resumingPreflightedAdd && adapterInspection.state === "registered", credentialRevision, }); - if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + if (adapter === "hermes-config") { + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } const { addState: _completedAddState, ...committedEntry } = entry; writeBridgeEntry(sandboxName, committedEntry); } catch (error) { const rollbackProviderInspection = (providerAttachAttempted || providerCreated) && entry.providerId - ? inspectMcpProvider(providerName) + ? inspectMcpProvider(providerName, providerRuntimeSelection) : undefined; const rollbackProviderOwned = !!rollbackProviderInspection && providerMatchesCredential(rollbackProviderInspection, entry.env[0], entry.providerId); if (adapterMutationAttempted) { - unregisterAgentAdapter(sandboxName, adapter, entry, { + unregisterAgentAdapter(sandboxName, adapter, entry, providerRuntimeSelection, { force: false, bestEffort: true, envValues: adapterEnvValues, }); } if (policyApplied) { - removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); + removeGeneratedPolicy(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); } const detachOutcome = providerAttachAttempted - ? detachProvider(sandboxName, entry, { bestEffort: true }) + ? detachProvider(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }) : "absent"; let reservationCleanupProved = !providerAttachAttempted; if (providerAttachAttempted && detachOutcome !== "unknown") { try { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); reservationCleanupProved = true; } catch { reservationCleanupProved = false; } } if (providerCreated && rollbackProviderOwned && reservationCleanupProved) { - const beforeDelete = inspectMcpProvider(providerName); + const beforeDelete = inspectMcpProvider(providerName, providerRuntimeSelection); if (providerMatchesCredential(beforeDelete, entry.env[0], entry.providerId)) { - deleteProvider(entry, { allowMissing: true, bestEffort: true }); + deleteProvider(entry, { + allowMissing: true, + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); } } // Exception rollback is best-effort and process death skips it entirely. @@ -525,4 +588,5 @@ async function addMcpBridgeUnlocked( // proves and cleans each exact resource. throw error; } + return providerRuntimeSelection; } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts new file mode 100644 index 00000000000..243041fa06a --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.test.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; + +const mocks = vi.hoisted(() => ({ + assertGeneratedPolicyRegistrationMutationSafe: vi.fn(), + ensureSandboxGatewaySelected: vi.fn(), + getMcpProviderInspectionRuntimeSelection: vi.fn(), + getSandboxOrThrow: vi.fn(), + inspectMcpProvider: vi.fn(), + removeGeneratedPolicy: vi.fn(), + setBridgeState: vi.fn(), +})); + +vi.mock("./mcp-bridge-policy", () => ({ + assertGeneratedPolicyRegistrationMutationSafe: + mocks.assertGeneratedPolicyRegistrationMutationSafe, + removeGeneratedPolicy: mocks.removeGeneratedPolicy, +})); + +vi.mock("./mcp-bridge-provider", () => ({ + getMcpProviderInspectionRuntimeSelection: mocks.getMcpProviderInspectionRuntimeSelection, + inspectMcpProvider: mocks.inspectMcpProvider, + providerMatchesManagedCredential: vi.fn(), + providerShapeDetail: vi.fn(), +})); + +vi.mock("./mcp-bridge-state", () => ({ + bridgeState: (sandbox: SandboxEntry) => sandbox.mcp?.bridges ?? {}, + ensureSandboxGatewaySelected: mocks.ensureSandboxGatewaySelected, + getSandboxOrThrow: mocks.getSandboxOrThrow, + setBridgeState: mocks.setBridgeState, +})); + +vi.mock("./mcp-bridge-validation", () => ({ + assertAuthenticatedBridgeEntry: vi.fn(), + validateSandboxName: vi.fn(), +})); + +import { discardSafeIncompleteMcpAdds } from "./mcp-bridge-destroy-preflight"; + +const preparedEntry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + policyName: "mcp-bridge-github", + addedAt: "2026-09-01T00:00:00.000Z", + addState: "prepared", +}; + +describe("incomplete MCP add discard", () => { + beforeEach(() => { + mocks.assertGeneratedPolicyRegistrationMutationSafe.mockReset(); + mocks.ensureSandboxGatewaySelected.mockReset(); + mocks.getMcpProviderInspectionRuntimeSelection.mockReset().mockImplementation(() => { + throw new Error("runtime selection resolved for a prepared-only discard"); + }); + mocks.getSandboxOrThrow.mockReset().mockReturnValue({ + name: "alpha", + agent: "openclaw", + }); + mocks.inspectMcpProvider.mockReset(); + mocks.removeGeneratedPolicy.mockReset(); + mocks.setBridgeState.mockReset(); + }); + + it("drops a prepared-only manifest before resolving runtime authority (#10514)", async () => { + const sandbox: SandboxEntry = { + name: "alpha", + agent: "openclaw", + mcp: { bridges: { github: preparedEntry } }, + }; + + await expect(discardSafeIncompleteMcpAdds("alpha", sandbox)).resolves.toEqual({ + name: "alpha", + agent: "openclaw", + }); + + expect(mocks.setBridgeState).toHaveBeenCalledWith("alpha", {}); + expect(mocks.getMcpProviderInspectionRuntimeSelection).not.toHaveBeenCalled(); + expect(mocks.ensureSandboxGatewaySelected).not.toHaveBeenCalled(); + expect(mocks.inspectMcpProvider).not.toHaveBeenCalled(); + expect(mocks.removeGeneratedPolicy).not.toHaveBeenCalled(); + expect(mocks.assertGeneratedPolicyRegistrationMutationSafe).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts index b2570134d11..4a224e298de 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts @@ -10,7 +10,9 @@ import { removeGeneratedPolicy, } from "./mcp-bridge-policy"; import { + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, providerMatchesManagedCredential, providerShapeDetail, @@ -31,6 +33,8 @@ export interface McpDestroyPreparation { destroyAlreadyPrepared: boolean; /** True when a previous destroy already confirmed the sandbox was absent. */ destroyAlreadyPending: boolean; + /** One authority-derived OpenShell target frozen for this destroy attempt. */ + runtimeSelection?: McpProviderInspectionRuntimeSelection; /** True when `--force` continued without scrubbing the retained-volume adapter entry. */ adapterScrubSkipped?: true; } @@ -66,20 +70,29 @@ function mcpBridgeEntriesEqual(left: McpBridgeEntry, right: McpBridgeEntry): boo export async function discardSafeIncompleteMcpAdds( sandboxName: string, sandbox: SandboxEntry, - options: { sandboxAbsent?: boolean } = {}, + options: { + runtimeSelection?: McpProviderInspectionRuntimeSelection; + sandboxAbsent?: boolean; + } = {}, ): Promise { const bridges = bridgeState(sandbox); const providerlessCandidates = Object.values(bridges).filter( (entry) => entry.addState === "preflighted" && !entry.providerId, ); - if (providerlessCandidates.length > 0) await ensureSandboxGatewaySelected(sandboxName); + const providerRuntimeSelection = + providerlessCandidates.length > 0 + ? (options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox)) + : undefined; + if (providerRuntimeSelection) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const remainingEntries: Array<[string, McpBridgeEntry]> = []; const providerlessPreflighted: McpBridgeEntry[] = []; for (const [server, entry] of Object.entries(bridges)) { if (entry.addState === "prepared") continue; if (entry.addState === "preflighted" && !entry.providerId) { assertAuthenticatedBridgeEntry(entry); - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection!); if (inspection.exists === false) { providerlessPreflighted.push(entry); continue; @@ -93,7 +106,9 @@ export async function discardSafeIncompleteMcpAdds( if (options.sandboxAbsent) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } else { - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection!, + }); } } // A prepared add precedes all external side effects, so destroy drops only @@ -124,7 +139,11 @@ export function assertMcpDestroySnapshotCurrent( export function inspectExactMcpDestroyProvider( entry: McpBridgeEntry, - options: { allowMissing: boolean; force?: boolean }, + options: { + allowMissing: boolean; + force?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): McpProviderInspection { assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { @@ -132,7 +151,7 @@ export function inspectExactMcpDestroyProvider( `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing destructive cleanup of same-name provider '${entry.providerName}'. Remove the legacy bridge with --force only after independently cleaning that provider.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${entry.providerName}'.`, @@ -162,17 +181,36 @@ export function inspectExactMcpDestroyProvider( /** Build cleanup state after a gateway-pinned list proves the sandbox absent. */ export async function prepareMcpBridgesForAbsentSandboxDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; + } = {}, ): Promise { validateSandboxName(sandboxName); - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, getSandboxOrThrow(sandboxName), { + const currentSandbox = getSandboxOrThrow(sandboxName); + const entriesRequiringExternalCleanup = Object.values(bridgeState(currentSandbox)).filter( + (entry) => entry.addState !== "prepared", + ); + let providerRuntimeSelection = options.runtimeSelection; + if (entriesRequiringExternalCleanup.length > 0) { + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(currentSandbox); + } + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + runtimeSelection: providerRuntimeSelection, sandboxAbsent: true, }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; + if (entries.length > 0) { + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(sandbox); + } for (const entry of entries) { - inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force }); + inspectExactMcpDestroyProvider(entry, { + allowMissing: true, + force: options.force, + runtimeSelection: providerRuntimeSelection!, + }); } return { entries, @@ -180,5 +218,6 @@ export async function prepareMcpBridgesForAbsentSandboxDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }; } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index 7b69f46ffdc..62bd8f58e2d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -20,6 +20,7 @@ import { import { deleteProvider, detachProvider, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; @@ -51,11 +52,23 @@ export { */ export async function prepareMcpBridgesForDestroy( sandboxName: string, - _options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpDestroyPreparation["runtimeSelection"]; + } = {}, ): Promise { validateSandboxName(sandboxName); const currentSandbox = getSandboxOrThrow(sandboxName); - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox); + const entriesRequiringExternalCleanup = Object.values(bridgeState(currentSandbox)).filter( + (entry) => entry.addState !== "prepared", + ); + let providerRuntimeSelection = options.runtimeSelection; + if (entriesRequiringExternalCleanup.length > 0) { + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(currentSandbox); + } + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + runtimeSelection: providerRuntimeSelection, + }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; @@ -72,15 +85,21 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }; } + providerRuntimeSelection ??= getMcpProviderInspectionRuntimeSelection(sandbox); + + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + // A pending marker is written only after OpenShell confirmed deletion. On // retry, a provider may therefore already be absent due to partial cleanup; // the retained entries are the durable, idempotent cleanup manifest. for (const entry of entries) { inspectExactMcpDestroyProvider(entry, { allowMissing: destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }); } if (destroyAlreadyPending) { @@ -90,6 +109,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending: true, + runtimeSelection: providerRuntimeSelection, }; } if (destroyAlreadyPrepared) { @@ -102,31 +122,46 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: entries.map(cloneMcpBridgeEntry), destroyAlreadyPrepared: true, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, }; } - await ensureSandboxGatewaySelected(sandboxName); - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); const detached: McpBridgeEntry[] = []; const scrubbedAdapters: McpScrubbedAdapterEntry[] = []; const removedPolicies: McpBridgeEntry[] = []; try { for (const entry of entries) { - scrubbedAdapters.push(scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry)); + scrubbedAdapters.push( + scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry, providerRuntimeSelection), + ); } for (const entry of entries) { - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); removedPolicies.push(entry); } for (const entry of entries) { - inspectExactMcpDestroyProvider(entry, { allowMissing: false }); - const detachOutcome = detachProvider(sandboxName, entry, { allowLegacyGeneric: true }); + inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); + const detachOutcome = detachProvider(sandboxName, entry, { + allowLegacyGeneric: true, + runtimeSelection: providerRuntimeSelection, + }); if (detachOutcome === "unknown") { throw new McpBridgeError( `Could not prove provider detach for MCP server '${entry.server}'.`, ); } - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); // Both an acknowledged detach and a freshly-proven absent binding are // rollback responsibilities until destroyPreparedAt is durable. This // closes retry-after-process-death gaps where an earlier attempt already @@ -156,6 +191,7 @@ export async function prepareMcpBridgesForDestroy( try { await restoreExistingMcpBridgeRuntime(sandboxName, removedPolicies, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (rollbackError) { @@ -165,7 +201,14 @@ export async function prepareMcpBridgesForDestroy( } } if (!runtimeRestored) { - rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); + rollbackFailures.push( + ...rollbackScrubbedMcpAdapters( + sandboxName, + sandbox, + scrubbedAdapters, + providerRuntimeSelection, + ), + ); } const current = registry.getSandbox(sandboxName); if (current?.mcp?.destroyPreparedAt) { @@ -199,6 +242,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: scrubbedAdapters, destroyAlreadyPrepared: false, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, }; } @@ -215,6 +259,8 @@ export async function restoreMcpBridgesAfterDestroyAbort( return; } const preparedSandbox = assertMcpDestroySnapshotCurrent(sandboxName, preparation.entries); + const providerRuntimeSelection = + preparation.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(preparedSandbox); const destroyPreparedAt = preparedSandbox.mcp?.destroyPreparedAt ?? nowIso(); const cleared = registry.updateSandbox(sandboxName, { mcp: { @@ -235,9 +281,13 @@ export async function restoreMcpBridgesAfterDestroyAbort( // Reattach only the exact existing providers. This restoration path never // reads host secret values and therefore cannot rotate preserved credentials. for (const entry of preparation.entries) - inspectExactMcpDestroyProvider(entry, { allowMissing: false }); + inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); await restoreExistingMcpBridgeRuntime(sandboxName, preparation.entries, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); } catch (error) { let markerRestoreFailure = ""; @@ -280,9 +330,10 @@ export async function finalizeMcpBridgesAfterSandboxDelete( const entries = preparation.entries; if (entries.length === 0) return; - await ensureSandboxGatewaySelected(sandboxName); - const sandbox = assertMcpDestroySnapshotCurrent(sandboxName, entries); + const providerRuntimeSelection = + preparation.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); if (!sandbox.mcp?.destroyPendingAt) { const marked = registry.updateSandbox(sandboxName, { mcp: { @@ -310,6 +361,7 @@ export async function finalizeMcpBridgesAfterSandboxDelete( inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force, + runtimeSelection: providerRuntimeSelection, }), ); for (const [index, entry] of entries.entries()) { @@ -317,10 +369,15 @@ export async function finalizeMcpBridgesAfterSandboxDelete( const beforeDelete = inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force, + runtimeSelection: providerRuntimeSelection, }); if (!beforeDelete.exists) continue; - deleteProvider(entry, { allowLegacyGeneric: true, allowMissing: true }); - const after = inspectMcpProvider(entry.providerName); + deleteProvider(entry, { + allowLegacyGeneric: true, + allowMissing: true, + runtimeSelection: providerRuntimeSelection, + }); + const after = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (after.exists !== false) { throw new McpBridgeError( after.error ?? diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts index b485c35c6c1..008ee4c18f0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts @@ -45,6 +45,7 @@ function sandbox(overrides: Partial = {}): SandboxEntry { return { name: "alpha", agent: "hermes", + gatewayName: "nemoclaw-8091", mcp: { bridges: { github: entry }, managedServerNames: ["github", "retired"], @@ -101,7 +102,11 @@ describe("Hermes MCP host reconciliation", () => { absent: ["retired"], }); expect(JSON.stringify(args)).not.toContain("host-only-secret"); - expect(options).toMatchObject({ ignoreError: true, timeout: 60_000 }); + expect(options).toMatchObject({ + ignoreError: true, + runtimeSelection: { gatewayName: "nemoclaw-8091", workspace: "default" }, + timeout: 60_000, + }); }); it("requires the observed credential revision during status reconciliation (#10079)", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts index 901916878f6..3e3c26dc402 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts @@ -11,6 +11,10 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import { + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, +} from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { sleepMcpBridgeRetry } from "./mcp-bridge/timing"; @@ -33,6 +37,7 @@ export interface HermesMcpReconciliationOptions { entries?: readonly McpBridgeEntry[]; managedServerNames?: readonly string[]; credentialRevisions?: ReadonlyMap; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } export function hermesMcpReconciliationRemediationLines(sandboxName: string): readonly string[] { @@ -161,10 +166,13 @@ export function inspectHermesMcpRuntimeIntent( managedServerNames, options.credentialRevisions, ); + const runtimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); let result: ReturnType; try { result = runOpenshellProviderCommand(buildInspectArgs(sandboxName, JSON.stringify(payload)), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], timeout: HERMES_MCP_INSPECT_TIMEOUT_MS, }); diff --git a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts index 67bd2660d55..573da580607 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts @@ -176,7 +176,6 @@ const validation = require("./src/lib/actions/sandbox/mcp-bridge-validation.js") const trusted = require("./src/lib/security/trusted-private-endpoint.js"); let admittedTarget; replace(policies, "getPresetContentGatewayState", () => "absent"); -replace(adapters, "assertAgentMcpConfigMutationAllowed", () => {}); replace(adapters, "assertAgentMcpMutationRuntimeCapability", () => {}); replace(adapters, "inspectAgentAdapterRegistration", () => ({ state: "absent" })); replace(adapters, "registerAgentAdapter", () => {}); @@ -209,7 +208,12 @@ replace(processRecovery, "executeSandboxExecCommand", () => ({ stdout: "v1\\n", stderr: "", })); -registry.registerSandbox({ name: "alpha", agent: "openclaw" }); +registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, +}); require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { server: "local", url: "https://mcp.corp.example/mcp", diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts index 0d0b08cf22e..dd40f939ed0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts @@ -29,6 +29,10 @@ const entry: McpBridgeEntry = { policyName: buildMcpBridgePolicyName("github"), addedAt: "2026-08-27T00:00:00.000Z", }; +const runtimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", +}; beforeEach(() => vi.restoreAllMocks()); @@ -44,7 +48,7 @@ describe("generated MCP policy", () => { it("applies directly to live OpenShell policy without a custom-policy registry row", () => { const livePolicy: { network_policies: Record } = { network_policies: {} }; - vi.spyOn(policies, "applyPresetContent").mockImplementation( + const applySpy = vi.spyOn(policies, "applyPresetContent").mockImplementation( (_sandboxName, _presetName, content) => { Object.assign( livePolicy.network_policies, @@ -53,7 +57,7 @@ describe("generated MCP policy", () => { return true; }, ); - vi.spyOn(policies, "getPresetContentGatewayState").mockImplementation( + const stateSpy = vi.spyOn(policies, "getPresetContentGatewayState").mockImplementation( (_sandboxName, content) => { const expected = (YAML.parse(content) as typeof livePolicy).network_policies; return Object.keys(expected).every((key) => key in livePolicy.network_policies) @@ -62,7 +66,12 @@ describe("generated MCP policy", () => { }, ); - applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }); + applyGeneratedPolicy( + "alpha", + entry, + { addresses: ["8.8.8.8"] }, + { runtimeSelection }, + ); expect(livePolicy.network_policies.mcp_bridge_github).toMatchObject({ endpoints: [ @@ -72,6 +81,18 @@ describe("generated MCP policy", () => { }), ], }); + expect(applySpy).toHaveBeenCalledWith( + "alpha", + entry.policyName, + expect.any(String), + expect.objectContaining({ runtimeSelection }), + ); + expect(stateSpy).toHaveBeenCalledWith( + "alpha", + expect.any(String), + undefined, + runtimeSelection, + ); }); it("removes generated content from the live policy", () => { @@ -88,7 +109,7 @@ describe("generated MCP policy", () => { ).network_policies.mcp_bridge_github, }, }; - vi.spyOn(policies, "removePreset").mockImplementation( + const removeSpy = vi.spyOn(policies, "removePreset").mockImplementation( (_sandboxName, _presetName, options) => { const removal = (YAML.parse(options?.presetContent ?? "") as typeof livePolicy) .network_policies; @@ -98,14 +119,24 @@ describe("generated MCP policy", () => { }, ); - removeGeneratedPolicy("alpha", entry); + removeGeneratedPolicy("alpha", entry, { runtimeSelection }); expect(livePolicy.network_policies).not.toHaveProperty("mcp_bridge_github"); + expect(removeSpy).toHaveBeenCalledWith( + "alpha", + entry.policyName, + expect.objectContaining({ runtimeSelection }), + ); }); it("refuses generated policy without exact public address pins", () => { expect(() => - applyGeneratedPolicy("alpha", { ...entry, allowedIps: [] }, { addresses: [] }), + applyGeneratedPolicy( + "alpha", + { ...entry, allowedIps: [] }, + { addresses: [] }, + { runtimeSelection }, + ), ).toThrow(/without exact public address pins/); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index 741216e43e4..db093789623 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -21,6 +21,7 @@ import { buildMcpBridgePolicyName, buildMcpBridgePolicyYaml, } from "./mcp-bridge-policy-render"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpBridgeTargetValidation } from "./mcp-bridge-url-validation"; export { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; @@ -36,7 +37,10 @@ export function applyGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, target: McpBridgeTargetValidation, - options: { bindCredential?: boolean } = {}, + options: { + bindCredential?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { const addresses = assertMcpBridgePolicyTarget(entry, target); if (addresses.length === 0) { @@ -58,8 +62,14 @@ export function applyGeneratedPolicy( if ( !policies.applyPresetContent(sandboxName, entry.policyName, content, { nonFatal: true, + runtimeSelection: options.runtimeSelection, }) || - policies.getPresetContentGatewayState(sandboxName, content) !== "match" + policies.getPresetContentGatewayState( + sandboxName, + content, + undefined, + options.runtimeSelection, + ) !== "match" ) { throw new McpBridgeError(`Failed to activate generated MCP policy '${entry.policyName}'.`); } @@ -161,13 +171,17 @@ export function assertGeneratedPolicyRegistrationMutationSafe( export function removeGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, - options: { bestEffort?: boolean } = {}, + options: { + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { const policyKey = buildMcpBridgePolicyKey(entry.server); const content = `network_policies:\n ${policyKey}: {}\n`; const removed = policies.removePreset(sandboxName, entry.policyName, { nonFatal: true, presetContent: content, + runtimeSelection: options.runtimeSelection, }); if (removed) return; if (options.bestEffort) return; @@ -193,9 +207,15 @@ export function getRegisteredGeneratedPolicy( export function getPolicyPresence( sandboxName: string, entry: McpBridgeEntry | undefined, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): boolean | null { const registered = getRegisteredGeneratedPolicy(sandboxName, entry); if (!registered) return entry ? null : false; - const state = policies.getPresetContentGatewayState(sandboxName, registered.content); + const state = policies.getPresetContentGatewayState( + sandboxName, + registered.content, + undefined, + runtimeSelection, + ); return state === "match" ? true : state === "absent" ? false : null; } diff --git a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts index 5feea6740e6..b61fcad9602 100644 --- a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts @@ -112,6 +112,8 @@ const registry = require("./src/lib/state/registry.js"); registry.registerSandbox({ name: "alpha", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { local: { server: "local", agent: "openclaw", diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts b/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts index eefa0691d02..48c093c4c71 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-attachments.ts @@ -17,6 +17,7 @@ import { inspectMcpProviderAttachments, type McpProviderAttachment, type McpProviderAttachmentInspection, + type McpProviderInspectionRuntimeSelection, providerMatchesCredential, providerMatchesManagedCredential, providerShapeDetail, @@ -29,8 +30,9 @@ import { function exactAttachment( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): { inspection: McpProviderAttachmentInspection; attachment?: McpProviderAttachment } { - const inspection = inspectMcpProviderAttachments(sandboxName); + const inspection = inspectMcpProviderAttachments(sandboxName, runtimeSelection); return { inspection, attachment: inspection.attachments?.find( @@ -52,7 +54,11 @@ function attachmentMatchesCurrentProviderSnapshot( ); } -export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void { +export function attachProvider( + sandboxName: string, + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { if (!entry.providerName) return; assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { @@ -60,7 +66,7 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing to attach same-name provider '${entry.providerName}'.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, runtimeSelection); if (inspection.exists === false) { throw new McpBridgeError( `OpenShell provider '${entry.providerName}' disappeared before attach.`, @@ -76,11 +82,11 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void } const result = runOpenshellProviderCommand( ["sandbox", "provider", "attach", sandboxName, entry.providerName], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + { ignoreError: true, runtimeSelection, stdio: ["ignore", "pipe", "pipe"] }, ) as OpenShellCommandResult; if (result.status !== 0) { const output = commandOutput(result); - const afterError = exactAttachment(sandboxName, entry); + const afterError = exactAttachment(sandboxName, entry, runtimeSelection); if (attachmentMatchesCurrentProviderSnapshot(afterError.attachment, entry)) return; throw new McpBridgeError( output || @@ -88,7 +94,7 @@ export function attachProvider(sandboxName: string, entry: McpBridgeEntry): void `Failed to attach MCP provider '${entry.providerName}'.`, ); } - const after = exactAttachment(sandboxName, entry); + const after = exactAttachment(sandboxName, entry, runtimeSelection); if (!attachmentMatchesCurrentProviderSnapshot(after.attachment, entry)) { throw new McpBridgeError( after.inspection.error ?? @@ -120,7 +126,11 @@ function isRetryableSandboxMutationConflict(status: number | null, output: strin export function detachProvider( sandboxName: string, entry: McpBridgeEntry, - options: { allowLegacyGeneric?: boolean; bestEffort?: boolean } = {}, + options: { + allowLegacyGeneric?: boolean; + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): ProviderDetachOutcome { if (!entry.providerName) return "absent"; assertPersistedAuthenticatedBridgeEntry(entry); @@ -131,7 +141,7 @@ export function detachProvider( ); } for (let attempt = 0; attempt < MCP_PROVIDER_DETACH_ATTEMPTS; attempt += 1) { - const provider = inspectMcpProvider(entry.providerName); + const provider = inspectMcpProvider(entry.providerName, options.runtimeSelection); if ( !providerMatchesManagedCredential(provider, entry.env[0], entry.providerId, { allowLegacyGeneric: options.allowLegacyGeneric, @@ -142,7 +152,7 @@ export function detachProvider( `OpenShell provider '${entry.providerName}' changed before detach. ${providerShapeDetail(provider, entry.env[0], entry.providerId)} Refusing to mutate it.`, ); } - const before = exactAttachment(sandboxName, entry); + const before = exactAttachment(sandboxName, entry, options.runtimeSelection); if (!before.inspection.attachments) { if (options.bestEffort) return "unknown"; throw new McpBridgeError( @@ -160,12 +170,13 @@ export function detachProvider( ["sandbox", "provider", "detach", sandboxName, entry.providerName], { ignoreError: true, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], suppressOutput: true, } as Record, ) as OpenShellCommandResult; const output = commandOutput(result); - const after = exactAttachment(sandboxName, entry); + const after = exactAttachment(sandboxName, entry, options.runtimeSelection); if (after.inspection.attachments && !after.attachment) { return providerDetachChangedState(result.status, output) ? "detached" : "absent"; } @@ -196,10 +207,11 @@ export function detachProvider( export function detachMissingProviderReference( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): ProviderDetachOutcome { if (!entry.providerName) return "absent"; assertPersistedAuthenticatedBridgeEntry(entry); - const before = inspectMcpProvider(entry.providerName); + const before = inspectMcpProvider(entry.providerName, runtimeSelection); if (before.exists !== false) { const detail = before.exists === null @@ -211,7 +223,7 @@ export function detachMissingProviderReference( } const result = runOpenshellProviderCommand( ["sandbox", "provider", "detach", sandboxName, entry.providerName], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + { ignoreError: true, runtimeSelection, stdio: ["ignore", "pipe", "pipe"] }, ) as OpenShellCommandResult; const output = commandOutput(result); if (result.status !== 0) { @@ -219,7 +231,7 @@ export function detachMissingProviderReference( output || `Failed to remove dangling provider reference '${entry.providerName}'.`, ); } - const afterProvider = inspectMcpProvider(entry.providerName); + const afterProvider = inspectMcpProvider(entry.providerName, runtimeSelection); if (afterProvider.exists !== false) { throw new McpBridgeError( afterProvider.error ?? diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts new file mode 100644 index 00000000000..419000f2547 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts @@ -0,0 +1,212 @@ +// 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 { afterEach, describe, expect, it, vi } from "vitest"; + +import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; +import { + getMcpProviderInspectionRuntimeSelection, + inspectMcpProvider, +} from "./mcp-bridge-provider-inspection"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + setProviderCommandRuntimeHooksForTest({}); + vi.unstubAllEnvs(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +function temporaryDirectory(prefix: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +function writeClientTlsBundle(localTlsDir: string): void { + fs.mkdirSync(path.join(localTlsDir, "client"), { recursive: true }); + fs.writeFileSync(path.join(localTlsDir, "ca.crt"), "ca"); + fs.writeFileSync(path.join(localTlsDir, "client", "tls.crt"), "cert"); + fs.writeFileSync(path.join(localTlsDir, "client", "tls.key"), "key"); +} + +function writeExternalGatewayDeclaration(home: string, endpoint: string, stateDir: string): string { + const declarationPath = path.join(home, "gateway-management.json"); + fs.writeFileSync( + declarationPath, + JSON.stringify({ + version: 1, + mode: "externally-supervised", + endpoint, + stateDir, + supervisor: { + kind: "systemd-user", + serviceName: "openshell-gateway.service", + execPath: "/usr/bin/openshell-gateway", + }, + requiredCapabilities: [], + }), + ); + return declarationPath; +} + +describe("MCP provider runtime selection", () => { + it("binds a nondefault managed gateway to its own client TLS directory (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + vi.stubEnv("HOME", home); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/tmp/ambient-gateway-tls"); + const localTlsDir = path.join( + home, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway-8091", + "tls", + ); + writeClientTlsBundle(localTlsDir); + + expect( + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toEqual({ + gatewayName: "nemoclaw-8091", + localTlsDir, + workspace: "default", + }); + }); + + it("uses the declared state directory for an external HTTPS gateway (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + const stateDir = path.join(home, "external-gateway"); + const localTlsDir = path.join(stateDir, "tls"); + writeClientTlsBundle(localTlsDir); + const declarationPath = writeExternalGatewayDeclaration( + home, + "https://127.0.0.1:8091", + stateDir, + ); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", declarationPath); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/tmp/ambient-gateway-tls"); + + expect( + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toEqual({ + gatewayName: "nemoclaw-8091", + localTlsDir, + workspace: "default", + }); + }); + + it("omits client TLS for an external HTTP gateway (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + const stateDir = path.join(home, "external-gateway"); + const declarationPath = writeExternalGatewayDeclaration( + home, + "http://127.0.0.1:8091", + stateDir, + ); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", declarationPath); + + expect( + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toEqual({ gatewayName: "nemoclaw-8091", workspace: "default" }); + }); + + it("refuses an incomplete external HTTPS client bundle (#10514)", () => { + const home = temporaryDirectory("nemoclaw-provider-runtime-"); + const stateDir = path.join(home, "external-gateway"); + const localTlsDir = path.join(stateDir, "tls"); + writeClientTlsBundle(localTlsDir); + fs.rmSync(path.join(localTlsDir, "client", "tls.key")); + const declarationPath = writeExternalGatewayDeclaration( + home, + "https://127.0.0.1:8091", + stateDir, + ); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", declarationPath); + + expect(() => + getMcpProviderInspectionRuntimeSelection({ + name: "alpha", + gatewayName: "nemoclaw-8091", + gatewayPort: 8091, + }), + ).toThrow("client/tls.key"); + }); +}); + +describe("MCP provider absence inspection", () => { + it("accepts only an exact provider-specific absence diagnostic (#10514)", () => { + setProviderCommandRuntimeHooksForTest({ + runOpenshell: (() => ({ + status: 1, + stdout: "", + stderr: "provider 'alpha-mcp-fake' not found", + })) as never, + }); + + expect(inspectMcpProvider("alpha-mcp-fake")).toEqual({ + exists: false, + id: null, + resourceVersion: null, + type: null, + credentialKeys: null, + }); + }); + + it.each([ + "NotFound", + "NotFound: provider", + "provider 'other-mcp-fake' not found", + 'status: NotFound, message: "gateway not found"', + "workspace 'default' does not exist", + "transport unavailable", + ])("keeps ambiguous lookup failure indeterminate: %s (#10514)", (diagnostic) => { + setProviderCommandRuntimeHooksForTest({ + runOpenshell: (() => ({ status: 1, stdout: "", stderr: diagnostic })) as never, + }); + + expect(inspectMcpProvider("alpha-mcp-fake")).toMatchObject({ + exists: null, + error: diagnostic, + }); + }); + + it.each([null, 2])( + "keeps exact-looking absence indeterminate for noncanonical exit %s (#10514)", + (status) => { + setProviderCommandRuntimeHooksForTest({ + runOpenshell: (() => ({ + status, + stdout: "", + stderr: "provider 'alpha-mcp-fake' not found", + })) as never, + }); + + expect(inspectMcpProvider("alpha-mcp-fake")).toMatchObject({ + exists: null, + error: "provider 'alpha-mcp-fake' not found", + }); + }, + ); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 0cbe4c3f04f..739209b587c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -1,10 +1,28 @@ // 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 { stripAnsi } from "../../adapters/openshell/client"; -import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { + type OpenShellRuntimeSelection, + runOpenshellProviderCommand, +} from "../../adapters/openshell/provider-command"; +import { OPENSHELL_DEFAULT_WORKSPACE } from "../../adapters/openshell/sandbox-ssh-host"; +import { getDockerDriverGatewayLocalTlsDir } from "../../onboard/docker-driver-gateway-local-tls"; +import { reportsExactProviderNotFound } from "../../adapters/openshell/provider-diagnostic-cli"; +import { resolveGatewayStateDirForPort } from "../../onboard/gateway/state-dir"; +import { resolveGatewayCredentialMutationAuthority } from "../../onboard/gateway-teardown-authority"; +import { + evaluateGatewayAttachmentConfiguration, + isExternallySupervised, + type GatewayOwner, +} from "../../onboard/gateway-ownership"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; -import { listExtraProviders, type McpBridgeEntry } from "../../state/registry"; +import { listExtraProviders, type McpBridgeEntry, type SandboxEntry } from "../../state/registry"; +import { getPersistedSandboxTargetGateway } from "./gateway-target"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output"; import type { McpBridgeTargetValidation } from "./mcp-bridge-url-validation"; @@ -36,6 +54,71 @@ export type McpProviderAttachmentInspection = { error?: string; }; +export type McpProviderInspectionRuntimeSelection = OpenShellRuntimeSelection; + +const GATEWAY_CLIENT_TLS_FILES = ["ca.crt", "client/tls.crt", "client/tls.key"] as const; + +function readableGatewayClientTlsDir(localTlsDir: string, required: boolean): string | undefined { + const observations = GATEWAY_CLIENT_TLS_FILES.map((relativePath) => { + const filePath = path.join(localTlsDir, relativePath); + try { + if (!fs.statSync(filePath).isFile()) throw new Error("not a file"); + fs.accessSync(filePath, fs.constants.R_OK); + return { filePath, readable: true }; + } catch { + return { filePath, readable: false }; + } + }); + if (observations.every(({ readable }) => readable)) return localTlsDir; + if (!required && observations.every(({ filePath }) => !fs.existsSync(filePath))) { + return undefined; + } + const unreadable = observations.find(({ readable }) => !readable)?.filePath ?? localTlsDir; + throw new McpBridgeError( + `OpenShell gateway TLS file is missing or unreadable: ${unreadable}`, + 1, + ); +} + +function providerRuntimeLocalTlsDir( + owner: GatewayOwner, + selectedInProcess: boolean, +): string | undefined { + if (isExternallySupervised(owner)) { + const configuration = evaluateGatewayAttachmentConfiguration(owner, owner.gatewayPort); + if (!configuration.ok) throw new McpBridgeError(configuration.message, 1); + if (!owner.endpoint || new URL(owner.endpoint).protocol !== "https:") return undefined; + if (!owner.stateDir) { + throw new McpBridgeError("Externally supervised HTTPS gateway requires a state directory.", 1); + } + return readableGatewayClientTlsDir(path.join(owner.stateDir, "tls"), true); + } + + const configuredStateDir = selectedInProcess + ? process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR + : undefined; + const stateDir = resolveGatewayStateDirForPort({ + configured: configuredStateDir, + home: process.env.HOME || os.homedir(), + port: owner.gatewayPort, + }); + return readableGatewayClientTlsDir(getDockerDriverGatewayLocalTlsDir(stateDir), false); +} + +export function getMcpProviderInspectionRuntimeSelection( + sandbox: SandboxEntry, +): McpProviderInspectionRuntimeSelection { + const providerRuntimeGateway = getPersistedSandboxTargetGateway(sandbox); + const { gatewayName, gatewayPort } = providerRuntimeGateway; + const owner = resolveGatewayCredentialMutationAuthority({ gatewayName, gatewayPort }); + const localTlsDir = providerRuntimeLocalTlsDir(owner, providerRuntimeGateway.selectedInProcess); + return { + gatewayName, + ...(localTlsDir ? { localTlsDir } : {}), + workspace: OPENSHELL_DEFAULT_WORKSPACE, + }; +} + const MCP_PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; export function parseMcpProviderMetadata(output: string): Omit { @@ -65,7 +148,10 @@ export function parseMcpProviderMetadata(output: string): Omit { - const provider = inspectMcpProvider(name); + const provider = inspectMcpProvider(name, runtimeSelection); if ( provider.exists !== true || !provider.id || @@ -169,10 +258,11 @@ export function inspectMcpProviderAttachments( export function assertNoAttachedProviderCredentialCollisions( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); - const inspection = inspectMcpProviderAttachments(sandboxName); + const inspection = inspectMcpProviderAttachments(sandboxName, runtimeSelection); if (!inspection.attachments) { throw new McpBridgeError( inspection.error ?? `Could not inspect providers attached to sandbox '${sandboxName}'.`, @@ -198,12 +288,15 @@ export function assertNoRegisteredProviderCredentialCollisions( deps: { listExtraProviders?: () => string[]; inspectProvider?: (providerName: string) => McpProviderInspection; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } = {}, ): void { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); const queryExtraProviders = deps.listExtraProviders ?? listExtraProviders; - const inspectProvider = deps.inspectProvider ?? inspectMcpProvider; + const inspectProvider = + deps.inspectProvider ?? + ((providerName: string) => inspectMcpProvider(providerName, deps.runtimeSelection)); for (const providerName of queryExtraProviders()) { const provider = inspectProvider(providerName); if (provider.exists === false) continue; @@ -230,9 +323,10 @@ export function assertNoRegisteredProviderCredentialCollisions( export function assertNoProviderCredentialCollisions( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { - assertNoAttachedProviderCredentialCollisions(sandboxName, entries); - assertNoRegisteredProviderCredentialCollisions(entries); + assertNoAttachedProviderCredentialCollisions(sandboxName, entries, runtimeSelection); + assertNoRegisteredProviderCredentialCollisions(entries, { runtimeSelection }); } export function providerMatchesCredential( @@ -306,7 +400,10 @@ export function providerShapeDetail( return `Expected ${MCP_BRIDGE_PROVIDER_TYPE} provider with only credential key '${expectedCredential ?? ""}', found type '${type}' with keys '${keys}'.`; } -export function assertMcpProviderRecoverable(entry: McpBridgeEntry): McpProviderInspection { +export function assertMcpProviderRecoverable( + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): McpProviderInspection { assertAuthenticatedBridgeEntry(entry); if (!entry.providerId) { throw new McpBridgeError( @@ -314,7 +411,7 @@ export function assertMcpProviderRecoverable(entry: McpBridgeEntry): McpProvider ); } const expectedCredential = entry.env[0]; - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${entry.providerName}'.`, @@ -406,9 +503,10 @@ export async function preflightMcpEntryTargets( export function providerAttached( sandboxName: string, providerName: string | undefined, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): boolean | null { if (!providerName) return null; - const inspection = inspectMcpProviderAttachments(sandboxName); + const inspection = inspectMcpProviderAttachments(sandboxName, runtimeSelection); if (!inspection.attachments) return null; return inspection.attachments.some((attachment) => attachment.name === providerName); } diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts index 6ca42e2019a..4c2c12f3bbe 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts @@ -22,12 +22,14 @@ import { registerCheckedInProviderProfile, } from "../../adapters/openshell/provider-profile-registration"; import { REPOSITORY_ROOT } from "../../core/repository-root"; +import { reportsExactProviderNotFound } from "../../adapters/openshell/provider-diagnostic-cli"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError, type ParsedEnvReference } from "./mcp-bridge-contracts"; import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output"; import { inspectMcpProvider, MCP_BRIDGE_PROVIDER_TYPE, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, providerMatchesCredential, providerMatchesManagedCredential, @@ -67,22 +69,32 @@ export { * removalCondition: remove this import when the minimum supported OpenShell * release classifies the `openai` inference credential as gateway-only itself. */ -function ensureOpenAiGatewayProviderProfile(): void { +function ensureOpenAiGatewayProviderProfile( + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { const result = checkOpenAiInferenceProviderProfile({ runOpenshell: (args, options) => - runOpenshellProviderCommand(args, options) as OpenShellCommandResult, + runOpenshellProviderCommand(args, { + ...options, + runtimeSelection, + }) as OpenShellCommandResult, }); if (result.ok) return; throw new McpBridgeError(result.messages.join("\n")); } /** Ensure the endpointless profile required by OpenShell static credential binding. */ -export function ensureMcpBridgeProviderProfile(): void { - ensureOpenAiGatewayProviderProfile(); +export function ensureMcpBridgeProviderProfile( + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + ensureOpenAiGatewayProviderProfile(runtimeSelection); const result = registerCheckedInProviderProfile({ profilePath: endpointlessProviderProfilePath(REPOSITORY_ROOT, MCP_BRIDGE_PROVIDER_TYPE), runOpenshell: (args, options) => - runOpenshellProviderCommand(args, options) as OpenShellCommandResult, + runOpenshellProviderCommand(args, { + ...options, + runtimeSelection, + }) as OpenShellCommandResult, }); if (result.ok) return; if (result.operation === "import") { @@ -128,6 +140,7 @@ export function upsertMcpProvider( expectedProviderId?: string; requireExisting?: boolean; prepareMutation?: (action: "create" | "update") => void; + runtimeSelection: McpProviderInspectionRuntimeSelection; }, ): { action: "created" | "updated" | "reused" | "none"; @@ -147,7 +160,7 @@ export function upsertMcpProvider( }; } const envValues = resolveCredentialEnv(env); - const inspection = inspectMcpProvider(providerName); + const inspection = inspectMcpProvider(providerName, options.runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${providerName}'.`, @@ -200,7 +213,7 @@ export function upsertMcpProvider( // removalCondition: use native immutable provider IDs or caller-supplied CAS // once OpenShell exposes them, then remove this inspect-mutate-inspect // compensation. - const beforeMutation = inspectMcpProvider(providerName); + const beforeMutation = inspectMcpProvider(providerName, options.runtimeSelection); if (action === "create" && beforeMutation.exists !== false) { const detail = beforeMutation.exists === null @@ -223,6 +236,7 @@ export function upsertMcpProvider( { ignoreError: true, env: envValues, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], }, ) as OpenShellCommandResult; @@ -234,7 +248,7 @@ export function upsertMcpProvider( commandOutput(result, envValues) || `Failed to ${action} MCP provider '${providerName}'.`, ); } - const after = inspectMcpProvider(providerName); + const after = inspectMcpProvider(providerName, options.runtimeSelection); if (after.exists !== true || !after.id) { throw new McpBridgeError( after.error ?? @@ -261,14 +275,17 @@ export function upsertMcpProvider( * revision without reading or rotating the stored credential, giving the * sidecar a post-policy generation to synchronize. */ -export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProviderInspection { +export function refreshMcpProviderEnvironment( + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): McpProviderInspection { assertPersistedAuthenticatedBridgeEntry(entry); if (!entry.providerName || !entry.providerId) { throw new McpBridgeError( `MCP server '${entry.server}' has no stable OpenShell provider identity for credential synchronization.`, ); } - const before = inspectMcpProvider(entry.providerName); + const before = inspectMcpProvider(entry.providerName, runtimeSelection); if (!providerMatchesCredential(before, entry.env[0], entry.providerId)) { throw new McpBridgeError( `OpenShell provider '${entry.providerName}' changed before credential synchronization. ${providerShapeDetail(before, entry.env[0], entry.providerId)} Refusing to mutate it.`, @@ -276,6 +293,7 @@ export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProvide } const result = runOpenshellProviderCommand(["provider", "update", entry.providerName], { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], }) as OpenShellCommandResult; if (result.status !== 0) { @@ -284,7 +302,7 @@ export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProvide `Failed to synchronize MCP provider '${entry.providerName}' after policy binding.`, ); } - const after = inspectMcpProvider(entry.providerName); + const after = inspectMcpProvider(entry.providerName, runtimeSelection); if ( !providerMatchesCredential(after, entry.env[0], entry.providerId) || !after.resourceVersion || @@ -299,7 +317,12 @@ export function refreshMcpProviderEnvironment(entry: McpBridgeEntry): McpProvide function inspectMcpProviderForDeletion( entry: McpBridgeEntry, - options: { allowLegacyGeneric?: boolean; allowMissing?: boolean; bestEffort?: boolean } = {}, + options: { + allowLegacyGeneric?: boolean; + allowMissing?: boolean; + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): McpProviderInspection | null { if (!entry.providerName) return null; try { @@ -309,7 +332,7 @@ function inspectMcpProviderForDeletion( `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing to delete same-name provider '${entry.providerName}'.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (inspection.exists === false) { if (options.allowMissing) return inspection; throw new McpBridgeError( @@ -334,23 +357,35 @@ function inspectMcpProviderForDeletion( export function deleteProvider( entry: McpBridgeEntry, - options: { allowLegacyGeneric?: boolean; allowMissing?: boolean; bestEffort?: boolean } = {}, + options: { + allowLegacyGeneric?: boolean; + allowMissing?: boolean; + bestEffort?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { if (!entry.providerName) return; const inspection = inspectMcpProviderForDeletion(entry, options); if (!inspection?.exists || !inspection.id || !inspection.resourceVersion) return; const result = runOpenshellProviderCommand(["provider", "delete", entry.providerName], { ignoreError: true, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], suppressOutput: true, } as Record) as OpenShellCommandResult; if (result.status !== 0) { const output = commandOutput(result); - if (options.allowMissing && /not\s+found|NotFound/i.test(output)) return; + if ( + options.allowMissing && + result.status === 1 && + reportsExactProviderNotFound(output, entry.providerName, output.length) + ) { + return; + } if (options.bestEffort) return; throw new McpBridgeError(output || `Failed to delete MCP provider '${entry.providerName}'.`); } - const after = inspectMcpProvider(entry.providerName); + const after = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (after.exists !== false && !options.bestEffort) { throw new McpBridgeError( after.error ?? `OpenShell provider '${entry.providerName}' still exists after delete.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts index 0cea3dc802e..2c517b97d9f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; import { ensureMcpBridgeProviderProfile, MCP_BRIDGE_PROVIDER_TYPE } from "./mcp-bridge-provider"; +const runtimeSelection = { gatewayName: "nemoclaw-8080", workspace: "default" }; + beforeEach(() => { setProviderCommandRuntimeHooksForTest({}); }); @@ -44,7 +46,7 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).not.toThrow(); + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).not.toThrow(); expect(runOpenshell).toHaveBeenCalledTimes(6); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "profile", "import", "--file", expect.stringMatching(/openai\.yaml$/)], @@ -71,7 +73,7 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).not.toThrow(); + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).not.toThrow(); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "profile", "export", "openai", "--output", "json"], expect.any(Object), @@ -103,7 +105,7 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow( + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( /does not match NemoClaw's endpointless credential contract/, ); }); @@ -118,7 +120,7 @@ describe("OpenShell MCP provider profile", () => { let message = ""; try { - ensureMcpBridgeProviderProfile(); + ensureMcpBridgeProviderProfile(runtimeSelection); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -146,7 +148,7 @@ describe("OpenShell MCP provider profile", () => { let message = ""; try { - ensureMcpBridgeProviderProfile(); + ensureMcpBridgeProviderProfile(runtimeSelection); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -183,7 +185,7 @@ describe("OpenShell MCP provider profile", () => { const runOpenshell = vi.fn().mockReturnValueOnce({ status: 0, stdout, stderr: "" }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow( + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( /does not match NemoClaw's endpointless inference contract/, ); expect(runOpenshell).toHaveBeenCalledOnce(); @@ -201,7 +203,9 @@ describe("OpenShell MCP provider profile", () => { }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow(/could not be read for validation/); + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( + /could not be read for validation/, + ); expect(runOpenshell).toHaveBeenCalledOnce(); }); @@ -216,7 +220,7 @@ describe("OpenShell MCP provider profile", () => { .mockReturnValueOnce({ status: 1, stdout: "", stderr: "export rejected" }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); - expect(() => ensureMcpBridgeProviderProfile()).toThrow( + expect(() => ensureMcpBridgeProviderProfile(runtimeSelection)).toThrow( /nemoclaw-mcp-v1.*could not be exported for validation/u, ); expect(runOpenshell).toHaveBeenCalledTimes(2); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts index fc357f47f98..f7e4b1739a4 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts @@ -4,6 +4,7 @@ import { shellQuote } from "../../runner"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError } from "./mcp-bridge-contracts"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { waitForMcpBridgeCondition } from "./mcp-bridge/timing"; import { assertAuthenticatedBridgeEntry, @@ -46,12 +47,14 @@ type McpCredentialRevisionAttempt = function executeMcpCredentialProofCommand( sandboxName: string, command: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): ReturnType { // OpenShell preserves the proof as one multiline command argument. The // script classifies placeholder shape/revision only and never prints a raw // credential value or writes sandbox state. return executeSandboxExecCommand(sandboxName, command, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); } @@ -115,10 +118,12 @@ function parseMcpCredentialRevisionObservation( function tryObserveMcpCredentialRevision( sandboxName: string, envName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): McpCredentialRevisionAttempt { const result = executeMcpCredentialProofCommand( sandboxName, buildMcpCredentialRevisionObservationCommand(envName), + runtimeSelection, ); if (!result) return { kind: "transport-unavailable" }; if (result.status !== 0) return { kind: "command-failed", status: result.status }; @@ -142,9 +147,10 @@ function describeMcpCredentialRevisionAttempt(attempt: McpCredentialRevisionAtte export function observeMcpCredentialRevision( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): McpCredentialRevisionObservation { assertAuthenticatedBridgeEntry(entry); - const attempt = tryObserveMcpCredentialRevision(sandboxName, entry.env[0]); + const attempt = tryObserveMcpCredentialRevision(sandboxName, entry.env[0], runtimeSelection); if (attempt.kind !== "observation") { throw new McpBridgeError( `Could not observe the current OpenShell credential revision for sandbox '${sandboxName}'.`, @@ -156,6 +162,7 @@ export function observeMcpCredentialRevision( export function waitForAttachedMcpCredential( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, options: { previousRevision?: McpCredentialRevisionObservation; refreshAfterObservedAbsence?: () => void; @@ -182,7 +189,7 @@ export function waitForAttachedMcpCredential( // Each exec is a fresh OpenShell process. Only the bounded placeholder // classification crosses back to the host, where the comparison cannot // be influenced by a same-UID sandbox process rewriting a snapshot file. - let attempt = tryObserveMcpCredentialRevision(sandboxName, envName); + let attempt = tryObserveMcpCredentialRevision(sandboxName, envName, runtimeSelection); lastAttempt = attempt; if ( attempt.kind === "observation" && @@ -192,7 +199,7 @@ export function waitForAttachedMcpCredential( ) { refreshedAfterObservedAbsence = true; options.refreshAfterObservedAbsence(); - attempt = tryObserveMcpCredentialRevision(sandboxName, envName); + attempt = tryObserveMcpCredentialRevision(sandboxName, envName, runtimeSelection); lastAttempt = attempt; } const observation = attempt.kind === "observation" ? attempt.observation : null; @@ -241,7 +248,11 @@ export function buildMcpCredentialDetachedCommand(envName: string): string { return `[ -z "\${${envName}+x}" ]`; } -export function waitForDetachedMcpCredential(sandboxName: string, entry: McpBridgeEntry): void { +export function waitForDetachedMcpCredential( + sandboxName: string, + entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { assertPersistedAuthenticatedBridgeEntry(entry); const envName = entry.env[0]; try { @@ -258,7 +269,11 @@ export function waitForDetachedMcpCredential(sandboxName: string, entry: McpBrid ); const revoked = waitForMcpBridgeCondition( () => - executeMcpCredentialProofCommand(sandboxName, buildMcpCredentialDetachedCommand(envName)) + executeMcpCredentialProofCommand( + sandboxName, + buildMcpCredentialDetachedCommand(envName), + runtimeSelection, + ) ?.status === 0, Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 30, 1_000, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts index 0eaaff30d83..546637d0589 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -20,16 +20,30 @@ import { providerMatchesManagedCredential, } from "./mcp-bridge-provider-inspection"; import { + attachProvider, assertMcpProviderRecoverable, + deleteProvider, + detachMissingProviderReference, + detachProvider, + ensureMcpBridgeProviderProfile, + MCP_BRIDGE_PROVIDER_TYPE, observeMcpCredentialRevision, refreshMcpProviderEnvironment, + upsertMcpProvider, waitForAttachedMcpCredential, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; import * as processRecovery from "./process-recovery"; +const runtimeSelection = { + gatewayName: "nemoclaw-8091", + localTlsDir: "/recorded/gateway/tls", + workspace: "default", +} as const; + describe("OpenShell MCP provider state", () => { afterEach(() => { + providerCommand.setProviderCommandRuntimeHooksForTest({}); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); @@ -93,11 +107,7 @@ Provider: }; expect( - providerMatchesCredential( - inspection, - "GITHUB_TOKEN", - "11111111-2222-4333-8444-555555555555", - ), + providerMatchesCredential(inspection, "GITHUB_TOKEN", "11111111-2222-4333-8444-555555555555"), ).toBe(false); expect( providerMatchesManagedCredential( @@ -135,7 +145,7 @@ Provider: addedAt: "2026-08-19T00:00:00.000Z", }; - expect(() => assertMcpProviderRecoverable(entry)).toThrow( + expect(() => assertMcpProviderRecoverable(entry, runtimeSelection)).toThrow( /legacy generic profile.*cannot bind to an MCP endpoint/, ); }); @@ -168,7 +178,188 @@ Provider: .mockReturnValueOnce(providerResult(8)); expect( - refreshMcpProviderEnvironment({ + refreshMcpProviderEnvironment( + { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: id, + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }, + { + gatewayName: "nemoclaw-8080", + workspace: "default", + }, + ), + ).toMatchObject({ resourceVersion: 8 }); + expect(run.mock.calls[1]?.[0]).toEqual(["provider", "update", "alpha-mcp-github"]); + expect(run.mock.calls[1]?.[0]).not.toContain("--credential"); + }); + + it("pins every managed MCP provider lifecycle read and write to the recorded runtime target (#10514)", () => { + vi.stubEnv("EXPECTED_TOKEN", "host-only-secret"); + vi.stubEnv("OPENSHELL_GATEWAY", "ambient-gateway"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "http://ambient.invalid"); + vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true"); + vi.stubEnv("OPENSHELL_WORKSPACE", "ambient-workspace"); + + const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + const providerId = "11111111-2222-4333-8444-555555555555"; + const commandFamilies = new Set(); + let providerExists = false; + let providerAttached = false; + let resourceVersion = 0; + const providerOutput = () => + [ + `Id: ${providerId}`, + `Type: ${MCP_BRIDGE_PROVIDER_TYPE}`, + `Resource version: ${resourceVersion}`, + "Credential keys: EXPECTED_TOKEN", + ].join("\n"); + const profileOutput = (id: string, inferenceCapable: boolean) => + JSON.stringify({ + id, + credentials: [], + endpoints: [], + binaries: [], + inference_capable: inferenceCapable, + }); + + const runOpenshell = vi.fn((args: string[], options: { env?: Record }) => { + const env = options.env ?? {}; + expect( + Object.keys(env) + .filter((name) => name.startsWith("OPENSHELL_")) + .sort(), + ).toEqual(["OPENSHELL_GATEWAY", "OPENSHELL_WORKSPACE"]); + expect(env.OPENSHELL_GATEWAY).toBe(runtimeSelection.gatewayName); + expect(env.OPENSHELL_WORKSPACE).toBe(runtimeSelection.workspace); + + const command = `${args[0]} ${args[1]} ${args[2] ?? ""}`; + switch (command) { + case "provider profile export": + commandFamilies.add("profile"); + return { + status: 0, + stdout: profileOutput(args[3], args[3] === "openai"), + stderr: "", + }; + case "provider get alpha-mcp-fake": + commandFamilies.add("get"); + return providerExists + ? { status: 0, stdout: providerOutput(), stderr: "" } + : { status: 1, stdout: "", stderr: `provider '${args[2]}' not found` }; + case "provider create --name": + commandFamilies.add("create"); + providerExists = true; + resourceVersion = 1; + return { status: 0, stdout: "Created", stderr: "" }; + case "provider update alpha-mcp-fake": + commandFamilies.add("update"); + resourceVersion += 1; + return { status: 0, stdout: "Updated", stderr: "" }; + case "provider delete alpha-mcp-fake": + commandFamilies.add("delete"); + providerExists = false; + return { status: 0, stdout: "Deleted", stderr: "" }; + case "sandbox provider list": + commandFamilies.add("list"); + return providerAttached + ? { + status: 0, + stdout: `NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-fake ${MCP_BRIDGE_PROVIDER_TYPE} 1 0\n`, + stderr: "", + } + : { + status: 0, + stdout: "No providers attached to sandbox alpha.\n", + stderr: "", + }; + case "sandbox provider attach": + commandFamilies.add("attach"); + providerAttached = true; + return { status: 0, stdout: "Attached", stderr: "" }; + case "sandbox provider detach": { + commandFamilies.add("detach"); + const changed = providerAttached; + providerAttached = false; + return { + status: 0, + stdout: changed + ? "Detached provider alpha-mcp-fake from sandbox alpha." + : "Provider alpha-mcp-fake was not attached to sandbox alpha.", + stderr: "", + }; + } + default: + throw new Error(`Unexpected OpenShell command: ${args.join(" ")}`); + } + }); + providerCommand.setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); + + ensureMcpBridgeProviderProfile(runtimeSelection); + const created = upsertMcpProvider("alpha-mcp-fake", [{ name: "EXPECTED_TOKEN" }], { + allowExisting: false, + runtimeSelection, + }); + const entry: McpBridgeEntry = { + server: "fake", + agent: "openclaw", + adapter: "mcporter", + url: "https://mcp.example.test/mcp", + env: ["EXPECTED_TOKEN"], + providerName: "alpha-mcp-fake", + providerId: created.inspection.id ?? undefined, + policyName: "mcp-bridge-fake", + addedAt: "2026-06-01T00:00:00.000Z", + }; + attachProvider("alpha", entry, runtimeSelection); + refreshMcpProviderEnvironment(entry, runtimeSelection); + expect(detachProvider("alpha", entry, { runtimeSelection })).toBe("detached"); + deleteProvider(entry, { runtimeSelection }); + expect(detachMissingProviderReference("alpha", entry, runtimeSelection)).toBe("absent"); + + expect(commandFamilies).toEqual( + new Set(["profile", "get", "create", "attach", "list", "update", "detach", "delete"]), + ); + }); + + it.each([ + "NotFound: provider", + "provider 'other-mcp-github' not found", + 'status: NotFound, message: "gateway nemoclaw-8091 not found"', + ])( + "rejects ambiguous provider-delete output %s while cleanup is retryable (#10514)", + (diagnostic) => { + const id = "11111111-2222-4333-8444-555555555555"; + const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + const run = vi + .spyOn(providerCommand, "runOpenshellProviderCommand") + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + "", + ], + stdout: `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + stderr: "", + }) + .mockReturnValueOnce({ + pid: 1234, + status: 1, + signal: null, + output: [null, "", diagnostic], + stdout: "", + stderr: diagnostic, + }); + const entry: McpBridgeEntry = { server: "github", agent: "openclaw", adapter: "mcporter", @@ -178,10 +369,54 @@ Provider: providerId: id, policyName: "mcp-bridge-github", addedAt: "2026-08-19T00:00:00.000Z", - }), - ).toMatchObject({ resourceVersion: 8 }); - expect(run.mock.calls[1]?.[0]).toEqual(["provider", "update", "alpha-mcp-github"]); - expect(run.mock.calls[1]?.[0]).not.toContain("--credential"); + }; + + expect(() => deleteProvider(entry, { allowMissing: true, runtimeSelection })).toThrow( + diagnostic, + ); + expect(run).toHaveBeenCalledTimes(2); + }, + ); + + it("accepts an exact provider-delete absence while cleanup is retryable (#10514)", () => { + const id = "11111111-2222-4333-8444-555555555555"; + const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + const run = vi + .spyOn(providerCommand, "runOpenshellProviderCommand") + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + "", + ], + stdout: `Id: ${id}\nType: nemoclaw-mcp-v1\nResource version: 7\nCredential keys: GITHUB_TOKEN\n`, + stderr: "", + }) + .mockReturnValueOnce({ + pid: 1234, + status: 1, + signal: null, + output: [null, "", "provider 'alpha-mcp-github' not found"], + stdout: "", + stderr: "provider 'alpha-mcp-github' not found", + }); + const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: id, + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }; + + expect(() => deleteProvider(entry, { allowMissing: true, runtimeSelection })).not.toThrow(); + expect(run).toHaveBeenCalledTimes(2); }); it("distinguishes a real detach from OpenShell's idempotent success", () => { @@ -223,9 +458,9 @@ alpha-mcp-slack generic 1 0 addedAt: "2026-06-01T00:00:00.000Z", }; - expect(() => assertNoAttachedProviderCredentialCollisions("alpha", [entry])).toThrow( - "MCP server 'example' has no complete authenticated credential binding", - ); + expect(() => + assertNoAttachedProviderCredentialCollisions("alpha", [entry], runtimeSelection), + ).toThrow("MCP server 'example' has no complete authenticated credential binding"); expect(() => assertNoRegisteredProviderCredentialCollisions([entry], { listExtraProviders: () => ["foreign-registered"], @@ -262,6 +497,99 @@ alpha-mcp-slack generic 1 0 ); }); + it("pins attachment collision inspection to the recorded runtime target (#10514)", () => { + const runtimeSelection = { gatewayName: "nemoclaw-9090", workspace: "default" }; + const run = vi + .spyOn(providerCommand, "runOpenshellProviderCommand") + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nforeign-provider nemoclaw-mcp-v1 1 0\n", + "", + ], + stdout: "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nforeign-provider nemoclaw-mcp-v1 1 0\n", + stderr: "", + }) + .mockReturnValueOnce({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + "", + ], + stdout: + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + stderr: "", + }); + const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }; + + expect(() => + assertNoAttachedProviderCredentialCollisions("alpha", [entry], runtimeSelection), + ).toThrow("Credential key 'GITHUB_TOKEN' is already supplied by attached provider"); + expect(run).toHaveBeenCalledTimes(2); + expect( + run.mock.calls.every( + ([, options]) => + options?.runtimeSelection?.gatewayName === runtimeSelection.gatewayName && + options.runtimeSelection.workspace === runtimeSelection.workspace, + ), + ).toBe(true); + }); + + it("pins registered collision inspection to the recorded runtime target (#10514)", () => { + const runtimeSelection = { gatewayName: "nemoclaw-9090", workspace: "default" }; + const run = vi.spyOn(providerCommand, "runOpenshellProviderCommand").mockReturnValue({ + pid: 1234, + status: 0, + signal: null, + output: [ + null, + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + "", + ], + stdout: + "Id: 99999999-8888-4777-8666-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: GITHUB_TOKEN\n", + stderr: "", + }); + const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-08-19T00:00:00.000Z", + }; + + expect(() => + assertNoRegisteredProviderCredentialCollisions([entry], { + listExtraProviders: () => ["foreign-provider"], + runtimeSelection, + }), + ).toThrow("Credential key 'GITHUB_TOKEN' is already supplied by registered provider"); + expect(run).toHaveBeenCalledWith( + ["provider", "get", "foreign-provider"], + expect.objectContaining({ runtimeSelection }), + ); + }); + it.each([ { value: undefined, observation: "absent" }, { value: "openshell:resolve:env:GITHUB_TOKEN", observation: "canonical" }, @@ -318,7 +646,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toBe("v11"); const proofCommand = exec.mock.calls[0]?.[1] ?? ""; expect(proofCommand).toContain("\n"); @@ -327,6 +655,7 @@ alpha-mcp-slack generic 1 0 expect(proofCommand).not.toContain("base64 -d"); expect(exec).toHaveBeenCalledWith("alpha", proofCommand, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); exec.mockReturnValue({ status: 0, stdout: "raw-secret", stderr: "" }); @@ -341,7 +670,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/Could not observe the current OpenShell credential revision/); }); @@ -365,6 +694,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ); @@ -395,7 +725,7 @@ alpha-mcp-slack generic 1 0 .mockReturnValueOnce({ status: 0, stdout: "v11", stderr: "" }) .mockReturnValue({ status: 0, stdout: "v12", stderr: "" }); - expect(waitForAttachedMcpCredential("alpha", entry)).toBe("v12"); + expect(waitForAttachedMcpCredential("alpha", entry, runtimeSelection)).toBe("v12"); expect(exec).toHaveBeenCalledTimes(3); }); @@ -419,7 +749,7 @@ alpha-mcp-slack generic 1 0 .mockReturnValue({ status: 0, stdout: "v7480654703696766813", stderr: "" }); expect( - waitForAttachedMcpCredential("alpha", entry, { + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { previousRevision: "v15566468742889590075", }), ).toBe("v7480654703696766813"); @@ -446,7 +776,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/last bounded observation: canonical/); }); @@ -470,7 +800,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/last bounded observation: absent/); expect(exec).toHaveBeenCalledOnce(); }); @@ -493,9 +823,11 @@ alpha-mcp-slack generic 1 0 .mockReturnValue({ status: 0, stdout: "v12", stderr: "" }); const refreshAfterObservedAbsence = vi.fn(); - expect(waitForAttachedMcpCredential("alpha", entry, { refreshAfterObservedAbsence })).toBe( - "v12", - ); + expect( + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { + refreshAfterObservedAbsence, + }), + ).toBe("v12"); expect(refreshAfterObservedAbsence).toHaveBeenCalledOnce(); expect(exec).toHaveBeenCalledTimes(3); }); @@ -524,6 +856,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ), ).toThrow(/post-absence provider refresh attempted: yes/u); @@ -556,6 +889,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ); } catch (error) { @@ -591,6 +925,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ), ).toThrow("provider refresh failed"); @@ -620,6 +955,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { previousRevision: "v11", refreshAfterObservedAbsence }, ), ).toThrow(/last bounded observation: v11; post-absence provider refresh attempted: yes/u); @@ -643,7 +979,7 @@ alpha-mcp-slack generic 1 0 providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", - }), + }, runtimeSelection), ).toThrow(/did not confirm credential 'GITHUB_TOKEN' was revoked/); const proofCommand = exec.mock.calls[0]?.[1] ?? ""; @@ -651,6 +987,7 @@ alpha-mcp-slack generic 1 0 expect(proofCommand).not.toContain("base64 -d"); expect(exec).toHaveBeenCalledWith("alpha", proofCommand, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); }); @@ -672,17 +1009,22 @@ alpha-mcp-slack generic 1 0 stderr: "", }); - expect(waitForAttachedMcpCredential("alpha", entry, { previousRevision: "v11" })).toBe("v12"); + expect( + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { + previousRevision: "v11", + }), + ).toBe("v12"); expect(exec).toHaveBeenCalledTimes(2); vi.stubEnv("NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS", "1"); exec.mockClear(); exec.mockReturnValue({ status: 0, stdout: "v11", stderr: "" }); vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(1_000); - expect(() => waitForAttachedMcpCredential("alpha", entry, { previousRevision: "v11" })).toThrow( - /did not synchronize the expected credential revision/, - ); + expect(() => + waitForAttachedMcpCredential("alpha", entry, runtimeSelection, { + previousRevision: "v11", + }), + ).toThrow(/did not synchronize the expected credential revision/); expect(exec).toHaveBeenCalledTimes(1); }); - }); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.ts b/src/lib/actions/sandbox/mcp-bridge-provider.ts index 42b991c6b10..609faf20675 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.ts @@ -5,12 +5,14 @@ export type { McpProviderAttachment, McpProviderAttachmentInspection, McpProviderInspection, + McpProviderInspectionRuntimeSelection, } from "./mcp-bridge-provider-inspection"; export { assertMcpProviderRecoverable, assertNoAttachedProviderCredentialCollisions, assertNoProviderCredentialCollisions, assertNoRegisteredProviderCredentialCollisions, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, inspectMcpProviderAttachments, MCP_BRIDGE_PROVIDER_TYPE, diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts index a9e15211cb8..477a5a12672 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -12,6 +12,8 @@ import { } from "./mcp-bridge-destroy-preflight"; import { assertNoProviderCredentialCollisions, + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, } from "./mcp-bridge-provider"; import { @@ -27,6 +29,7 @@ import { assertAuthenticatedBridgeEntry, validateSandboxName } from "./mcp-bridg type ReadOnlyValidationSnapshot = { providerByServer: Map; + runtimeSelection: McpProviderInspectionRuntimeSelection; targetsByServer: Map; }; @@ -43,6 +46,7 @@ export interface ExecUnavailableMcpRebuildPreparation { scrubbedAdapterEntries: McpBridgeEntry[]; revalidateBeforeDelete: () => Promise; assertDeleteEdgeUnchanged: () => void; + runtimeSelection: McpProviderInspectionRuntimeSelection; } function assertUniqueMcpOwnership(entries: readonly McpBridgeEntry[]): void { @@ -127,23 +131,43 @@ function targetFingerprint(target: McpBridgeTargetValidation | undefined): strin async function inspectReadOnlyRecoveryState( sandboxName: string, entries: readonly McpBridgeEntry[], + expectedRuntimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { const resolvedTargets = await preflightMcpEntryTargets(entries); - // This may start or recover the sandbox's recorded host gateway and select + const currentRuntimeSelection = getMcpProviderInspectionRuntimeSelection( + getSandboxOrThrow(sandboxName), + ); + if ( + expectedRuntimeSelection && + (currentRuntimeSelection.gatewayName !== expectedRuntimeSelection.gatewayName || + currentRuntimeSelection.workspace !== expectedRuntimeSelection.workspace || + currentRuntimeSelection.localTlsDir !== expectedRuntimeSelection.localTlsDir) + ) { + throw new McpBridgeError( + `Sandbox '${sandboxName}' changed its MCP gateway authority before host-side rebuild recovery could inspect providers. Recorded OpenShell target gateway '${expectedRuntimeSelection.gatewayName}' no longer matches observed target gateway '${currentRuntimeSelection.gatewayName}' or its workspace and TLS authority. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expectedRuntimeSelection.gatewayName}', confirm it is healthy, then retry.`, + ); + } + const providerRuntimeSelection = expectedRuntimeSelection ?? currentRuntimeSelection; + // This may start or recover only the frozen recorded host gateway and select // it in CLI context. It does not mutate MCP lifecycle state or sandbox // contents; the provider and target checks below remain inspection-only. - if (entries.length > 0) await ensureSandboxGatewaySelected(sandboxName); + if (entries.length > 0) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const providerByServer = new Map(); const targetsByServer = new Map(); for (const entry of entries) { const target = resolvedTargets.get(entry.server); - const provider = inspectExactMcpDestroyProvider(entry, { allowMissing: false }); + const provider = inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); providerByServer.set(entry.server, providerFingerprint(provider)); targetsByServer.set(entry.server, targetFingerprint(target)); } - assertNoProviderCredentialCollisions(sandboxName, entries); - return { providerByServer, targetsByServer }; + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); + return { providerByServer, runtimeSelection: providerRuntimeSelection, targetsByServer }; } function assertValidationSnapshotCurrent( @@ -156,9 +180,15 @@ function assertValidationSnapshotCurrent( current.providerByServer.get(entry.server) !== expected.providerByServer.get(entry.server) || current.targetsByServer.get(entry.server) !== expected.targetsByServer.get(entry.server), ); - if (drifted) { + const targetChanged = + current.runtimeSelection.gatewayName !== expected.runtimeSelection.gatewayName || + current.runtimeSelection.workspace !== expected.runtimeSelection.workspace || + current.runtimeSelection.localTlsDir !== expected.runtimeSelection.localTlsDir; + if (drifted || targetChanged) { throw new McpBridgeError( - `MCP server '${drifted.server}' changed after host-side rebuild preflight. Refusing to delete the still-live sandbox; retry after its target and provider state are stable.`, + drifted + ? `MCP server '${drifted.server}' changed after host-side rebuild preflight on recorded OpenShell gateway '${expected.runtimeSelection.gatewayName}'. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expected.runtimeSelection.gatewayName}', confirm it is healthy and the MCP target and provider identity match the sandbox registry, then retry.` + : `Sandbox MCP gateway authority changed after host-side rebuild preflight. Recorded OpenShell target gateway '${expected.runtimeSelection.gatewayName}' no longer matches observed target gateway '${current.runtimeSelection.gatewayName}' or its workspace and TLS authority. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expected.runtimeSelection.gatewayName}', confirm it is healthy, then retry.`, ); } } @@ -182,9 +212,10 @@ function assertDeleteEdgeUnchanged( `Sandbox '${sandboxName}' changed its recorded agent or MCP adapter after host-side rebuild preflight. Refusing to delete it.`, ); } - if (resolveSandboxGatewayName(sandbox) !== expectedGatewayName) { + const observedGatewayName = resolveSandboxGatewayName(sandbox); + if (observedGatewayName !== expectedGatewayName) { throw new McpBridgeError( - `Sandbox '${sandboxName}' changed its recorded gateway after host-side rebuild preflight. Refusing to delete it.`, + `Sandbox '${sandboxName}' changed its recorded gateway after host-side rebuild preflight from '${expectedGatewayName}' to '${observedGatewayName}'. NemoClaw did not delete the original sandbox. Restore recorded gateway '${expectedGatewayName}', confirm it is healthy, then retry.`, ); } } @@ -204,7 +235,11 @@ async function revalidateBeforeDelete( expectedAgentName, expectedAdapter, ); - const currentValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); + const currentValidation = await inspectReadOnlyRecoveryState( + sandboxName, + expectedEntries, + expectedValidation.runtimeSelection, + ); assertValidationSnapshotCurrent(expectedEntries, expectedValidation, currentValidation); } @@ -218,14 +253,20 @@ async function revalidateBeforeDelete( */ export async function prepareMcpBridgesForExecUnavailableRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { const { entries, gatewayName, agentName, adapter } = snapshotCompleteEntries(sandboxName); const expectedEntries = entries.map(cloneMcpBridgeEntry); - const expectedValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); + const expectedValidation = await inspectReadOnlyRecoveryState( + sandboxName, + expectedEntries, + runtimeSelection, + ); return { entries: entries.map(cloneMcpBridgeEntry), detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: expectedValidation.runtimeSelection, revalidateBeforeDelete: () => revalidateBeforeDelete( sandboxName, diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index 8350560224a..67fdc872130 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -30,6 +30,8 @@ import { assertNoProviderCredentialCollisions, assertNoRegisteredProviderCredentialCollisions, detachProvider, + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; @@ -54,6 +56,8 @@ export interface McpRebuildPreparation { revalidateBeforeDelete?: () => Promise; /** Final synchronous registry-only proof immediately before delete. */ assertDeleteEdgeUnchanged?: () => void; + /** One authority-derived OpenShell target frozen for this rebuild attempt. */ + runtimeSelection?: McpProviderInspectionRuntimeSelection; } function policyDocumentsMatch(left: string, right: string): boolean { @@ -78,10 +82,12 @@ function policyWithoutManagedMcpEntries( function assertMcpTeardownPolicyUnchanged( sandboxName: string, expectedTeardownPolicy: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { const currentPolicy = policies.captureRecordedSandboxBasePolicy( sandboxName, "verify the live policy before MCP teardown", + runtimeSelection, ); if (!currentPolicy || !policyDocumentsMatch(currentPolicy, expectedTeardownPolicy)) { throw new McpBridgeError( @@ -94,12 +100,23 @@ export { prepareMcpBridgesForExecUnavailableRebuild } from "./mcp-bridge-rebuild async function getCompleteMcpRebuildEntries( sandboxName: string, - options: { sandboxAbsent?: boolean } = {}, -): Promise { + options: { + runtimeSelection?: McpProviderInspectionRuntimeSelection; + sandboxAbsent?: boolean; + } = {}, +): Promise<{ + entries: McpBridgeEntry[]; + runtimeSelection: McpProviderInspectionRuntimeSelection; +}> { validateSandboxName(sandboxName); const currentSandbox = getSandboxOrThrow(sandboxName); assertMcpDestroyNotPending(currentSandbox); - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, options); + const runtimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(currentSandbox); + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + ...options, + runtimeSelection, + }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const incompleteAdd = entries.find((entry) => entry.addState); if (incompleteAdd) { @@ -107,7 +124,7 @@ async function getCompleteMcpRebuildEntries( `MCP server '${incompleteAdd.server}' has an incomplete add transaction (${incompleteAdd.addState}). Re-run the original mcp add command or remove it with --force before rebuilding the sandbox.`, ); } - return entries; + return { entries, runtimeSelection }; } /** @@ -118,47 +135,61 @@ async function getCompleteMcpRebuildEntries( */ export async function prepareMcpBridgesForAbsentSandboxRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - const entries = await getCompleteMcpRebuildEntries(sandboxName, { sandboxAbsent: true }); + const { entries, runtimeSelection: providerRuntimeSelection } = + await getCompleteMcpRebuildEntries(sandboxName, { sandboxAbsent: true, runtimeSelection }); if (entries.length === 0) { return { entries: [], detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: providerRuntimeSelection, }; } await preflightMcpEntryTargets(entries); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); for (const entry of entries) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } - for (const entry of entries) assertMcpProviderRecoverable(entry); - assertNoRegisteredProviderCredentialCollisions(entries); + for (const entry of entries) assertMcpProviderRecoverable(entry, providerRuntimeSelection); + assertNoRegisteredProviderCredentialCollisions(entries, { + runtimeSelection: providerRuntimeSelection, + }); return { entries, detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: providerRuntimeSelection, }; } export async function prepareMcpBridgesForRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { const sandbox = getSandboxOrThrow(sandboxName); - const entries = await getCompleteMcpRebuildEntries(sandboxName); + const { entries, runtimeSelection: providerRuntimeSelection } = + await getCompleteMcpRebuildEntries(sandboxName, { runtimeSelection }); if (entries.length === 0) { return { entries: [], detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection: providerRuntimeSelection, }; } await preflightMcpEntryTargets(entries); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); for (const entry of entries) assertGeneratedPolicyMutationSafe(sandboxName, entry); - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); - for (const entry of entries) assertMcpProviderRecoverable(entry); - assertNoProviderCredentialCollisions(sandboxName, entries); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); + for (const entry of entries) assertMcpProviderRecoverable(entry, providerRuntimeSelection); + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); // This is the bounded replacement handoff, not a durable NemoClaw policy // record. Capture OpenShell immediately before the internal teardown // mutations so the replacement receives the complete operator-owned @@ -167,6 +198,7 @@ export async function prepareMcpBridgesForRebuild( const policyHandoff = policies.captureRecordedSandboxBasePolicy( sandboxName, "capture the live policy before MCP teardown", + providerRuntimeSelection, ); if (!policyHandoff) { throw new McpBridgeError( @@ -187,32 +219,45 @@ export async function prepareMcpBridgesForRebuild( // `/sandbox` may be a retained PVC. Scrub before delete so a replacement // Hermes/agent cannot boot with a stale placeholder while its provider // is intentionally detached during recreate. - scrubbedAdapters.push(scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry)); + scrubbedAdapters.push( + scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry, providerRuntimeSelection), + ); } for (const entry of entries) { // The same-name replacement journal fingerprints this source row before // MCP teardown removes the live entry from the source sandbox. Rebuild's // OpenShell policy handoff already captured the complete live document. - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); removedPolicies.push(entry); } for (const entry of entries) { // Keep the provider and its host-only credentials for the replacement // sandbox, but detach it before OpenShell deletes the old attachment. - inspectExactMcpDestroyProvider(entry, { allowMissing: false }); - const detachOutcome = detachProvider(sandboxName, entry); + inspectExactMcpDestroyProvider(entry, { + allowMissing: false, + runtimeSelection: providerRuntimeSelection, + }); + const detachOutcome = detachProvider(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); if (detachOutcome === "unknown") { throw new McpBridgeError( `Could not prove provider detach for MCP server '${entry.server}'.`, ); } - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); // A binding already absent on retry was still detached by this rebuild // transaction (possibly before a prior process died), so it must be // reattached if sandbox deletion later aborts. detached.push(entry); } - assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); + assertMcpTeardownPolicyUnchanged( + sandboxName, + expectedTeardownPolicy, + providerRuntimeSelection, + ); } catch (error) { const rollbackFailures: string[] = []; let runtimeRestored = false; @@ -220,6 +265,7 @@ export async function prepareMcpBridgesForRebuild( try { await restoreExistingMcpBridgeRuntime(sandboxName, removedPolicies, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (rollbackError) { @@ -229,7 +275,14 @@ export async function prepareMcpBridgesForRebuild( } } if (!runtimeRestored) { - rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); + rollbackFailures.push( + ...rollbackScrubbedMcpAdapters( + sandboxName, + sandbox, + scrubbedAdapters, + providerRuntimeSelection, + ), + ); } const detail = error instanceof Error ? error.message : String(error); throw new McpBridgeError( @@ -243,8 +296,13 @@ export async function prepareMcpBridgesForRebuild( detachedProviderEntries: detached, scrubbedAdapterEntries: scrubbedAdapters, policyHandoff, + runtimeSelection: providerRuntimeSelection, revalidateBeforeDelete: async () => { - assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); + assertMcpTeardownPolicyUnchanged( + sandboxName, + expectedTeardownPolicy, + providerRuntimeSelection, + ); }, }; } @@ -253,14 +311,19 @@ export async function reattachMcpProvidersAfterRebuildAbort( sandboxName: string, entries: readonly McpBridgeEntry[], scrubbedAdapterEntries: readonly McpScrubbedAdapterEntry[] = [], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { if (entries.length === 0 && scrubbedAdapterEntries.length === 0) return; - await ensureSandboxGatewaySelected(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, [ - ...entries, - ...scrubbedAdapterEntries, - ]); + const providerRuntimeSelection = + runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + [...entries, ...scrubbedAdapterEntries], + providerRuntimeSelection, + ); const failures: string[] = []; let runtimeRestored = false; @@ -268,6 +331,7 @@ export async function reattachMcpProvidersAfterRebuildAbort( try { await restoreExistingMcpBridgeRuntime(sandboxName, entries, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (error) { @@ -275,7 +339,14 @@ export async function reattachMcpProvidersAfterRebuildAbort( } } if (!runtimeRestored) { - failures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapterEntries)); + failures.push( + ...rollbackScrubbedMcpAdapters( + sandboxName, + sandbox, + scrubbedAdapterEntries, + providerRuntimeSelection, + ), + ); } if (failures.length > 0) { throw new McpBridgeError(failures.join("; ")); @@ -285,6 +356,7 @@ export async function reattachMcpProvidersAfterRebuildAbort( export async function restoreMcpBridgesAfterRebuild( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); @@ -297,5 +369,8 @@ export async function restoreMcpBridgesAfterRebuild( // Sandbox creation already received the complete pre-rebuild OpenShell // policy. Restore providers and adapters without regenerating or overwriting // policy entries that an operator may have edited independently. - await restoreExistingMcpBridgeRuntime(sandboxName, entries, { applyPolicy: false }); + await restoreExistingMcpBridgeRuntime(sandboxName, entries, { + applyPolicy: false, + ...(runtimeSelection ? { runtimeSelection } : {}), + }); } diff --git a/src/lib/actions/sandbox/mcp-bridge-recovery.ts b/src/lib/actions/sandbox/mcp-bridge-recovery.ts index a9e85582dc0..927a35e51c6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-recovery.ts +++ b/src/lib/actions/sandbox/mcp-bridge-recovery.ts @@ -6,6 +6,7 @@ import { inspectHermesMcpRuntimeIntent, sanitizeHermesMcpReconciliationDetail, } from "./mcp-bridge-hermes-reconciliation"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; export type McpReconciliationRefusalRecoveryResult = { checked: true; @@ -23,8 +24,12 @@ type InspectHermesMcpRuntimeIntent = (sandboxName: string) => HermesMcpReconcili export function inspectHermesMcpReconciliationRefusal( sandboxName: string, inspect: InspectHermesMcpRuntimeIntent = inspectHermesMcpRuntimeIntent, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): { detail: string } | null { - const reconciliation = inspect(sandboxName); + const reconciliation = + inspect === inspectHermesMcpRuntimeIntent + ? inspectHermesMcpRuntimeIntent(sandboxName, { runtimeSelection }) + : inspect(sandboxName); if (reconciliation.ok) return null; return { detail: sanitizeHermesMcpReconciliationDetail(reconciliation.detail) }; } @@ -33,8 +38,9 @@ export function processRecoveryMcpReconciliationRefusal( sandboxName: string, wasRunning: boolean, inspect: InspectHermesMcpRuntimeIntent = inspectHermesMcpRuntimeIntent, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): McpReconciliationRefusalRecoveryResult | null { - const refusal = inspectHermesMcpReconciliationRefusal(sandboxName, inspect); + const refusal = inspectHermesMcpReconciliationRefusal(sandboxName, inspect, runtimeSelection); if (!refusal) return null; return { checked: true, diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index bdc89d980be..533e58b9de5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -16,7 +16,9 @@ import { deleteProvider, detachMissingProviderReference, detachProvider, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, + type McpProviderInspectionRuntimeSelection, providerMatchesManagedCredential, providerShapeDetail, waitForDetachedMcpCredential, @@ -55,7 +57,11 @@ function requiresProviderDetachBeforeAdapterCleanup(entry: McpBridgeEntry): bool function assertExactMcpRemoveProvider( entry: McpBridgeEntry, - options: { allowMissing: boolean; force?: boolean }, + options: { + allowMissing: boolean; + force?: boolean; + runtimeSelection: McpProviderInspectionRuntimeSelection; + }, ): void { assertPersistedAuthenticatedBridgeEntry(entry); if (!entry.providerId) { @@ -63,7 +69,7 @@ function assertExactMcpRemoveProvider( `MCP server '${entry.server}' has no stable OpenShell provider ID. Refusing destructive cleanup of same-name provider '${entry.providerName}'. Remove the legacy bridge with --force only after independently cleaning that provider.`, ); } - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, options.runtimeSelection); if (inspection.exists === null) { throw new McpBridgeError( inspection.error ?? `Could not inspect OpenShell provider '${entry.providerName}'.`, @@ -194,6 +200,7 @@ async function removeMcpBridgeUnlocked( console.log(` Cancelled incomplete MCP add for '${server}' on sandbox '${sandboxName}'.`); return "cancelledPreparedAdd"; } + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); // Cleanup follows the adapter persisted with the bridge. Requiring the // sandbox's current agent to still advertise MCP support would strand old // resources after an agent/capability migration. @@ -203,14 +210,14 @@ async function removeMcpBridgeUnlocked( const detachBeforeAdapterCleanup = entry.providerName ? requiresProviderDetachBeforeAdapterCleanup(entry) : false; - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); assertGeneratedPolicyMutationSafe(sandboxName, entry); const failures: string[] = []; let providerOwnershipProved = !entry.providerName; let providerWasMissing = false; if (entry.providerName) { if (!entry.providerId) { - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (inspection.exists === false) { // With no live provider there is no global object to adopt or destroy. // This lets an operator independently remove a legacy/orphan provider, @@ -226,7 +233,7 @@ async function removeMcpBridgeUnlocked( failures.push(detail); } } else { - const inspection = inspectMcpProvider(entry.providerName); + const inspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (inspection.exists === false) { providerOwnershipProved = true; providerWasMissing = true; @@ -262,7 +269,7 @@ async function removeMcpBridgeUnlocked( detachBeforeAdapterCleanup ) { try { - detachMissingProviderReference(sandboxName, entry); + detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); missingProviderReferenceDetached = true; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -278,7 +285,10 @@ async function removeMcpBridgeUnlocked( ? missingProviderReferenceDetached ? "detached" : "unknown" - : detachProvider(sandboxName, entry, { allowLegacyGeneric: true }); + : detachProvider(sandboxName, entry, { + allowLegacyGeneric: true, + runtimeSelection: providerRuntimeSelection, + }); providerDetachedBeforeAdapterCleanup = detachOutcome !== "unknown"; if (!providerDetachedBeforeAdapterCleanup) { throw new McpBridgeError( @@ -304,11 +314,16 @@ async function removeMcpBridgeUnlocked( // this probe precedes every provider/policy/adapter side effect. Hermes // retains its helper/lifecycle validation; Deep Agents intentionally // skips only the marker that an older image cannot expose. - assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter); + assertAgentMcpTeardownRuntimeCapability( + sandboxName, + adapter, + providerRuntimeSelection, + ); const adapterRemoval = unregisterAgentAdapter( sandboxName, (entry.adapter as AgentMcpAdapter | undefined) ?? adapter, entry, + providerRuntimeSelection, { force: options.force === true, envValues: adapterEnvValues, @@ -327,6 +342,7 @@ async function removeMcpBridgeUnlocked( (candidate) => candidate.server !== server, ), managedServerNames: sandbox.mcp?.managedServerNames, + runtimeSelection: providerRuntimeSelection, }); } } catch (error) { @@ -339,7 +355,9 @@ async function removeMcpBridgeUnlocked( let policyCleanupProved = false; if (adapterCleanupProved) { try { - removeGeneratedPolicy(sandboxName, entry); + removeGeneratedPolicy(sandboxName, entry, { + runtimeSelection: providerRuntimeSelection, + }); policyCleanupProved = true; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -362,11 +380,14 @@ async function removeMcpBridgeUnlocked( if (providerWasMissing) { detachOutcome = missingProviderReferenceDetached ? "detached" - : detachMissingProviderReference(sandboxName, entry); + : detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); } else { detachOutcome = providerDetachedBeforeAdapterCleanup ? "detached" - : detachProvider(sandboxName, entry, { allowLegacyGeneric: true }); + : detachProvider(sandboxName, entry, { + allowLegacyGeneric: true, + runtimeSelection: providerRuntimeSelection, + }); } if (detachOutcome !== "unknown") { // A missing provider has no credential left to revoke. Its stock CLI @@ -374,7 +395,7 @@ async function removeMcpBridgeUnlocked( // skipping a fresh-exec probe lets cleanup proceed even if another // unrelated provider reference is also dangling. if (!providerWasMissing && !providerDetachedBeforeAdapterCleanup) { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); } reservationCleanupProved = true; } @@ -403,10 +424,12 @@ async function removeMcpBridgeUnlocked( assertExactMcpRemoveProvider(entry, { allowMissing: false, force: options.force, + runtimeSelection: providerRuntimeSelection, }); deleteProvider(entry, { allowLegacyGeneric: true, allowMissing: options.force === true || entry.addState === "preflighted", + runtimeSelection: providerRuntimeSelection, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); diff --git a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts index ef401e8b80e..324aac09758 100644 --- a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.test.ts @@ -46,6 +46,11 @@ const readyProbe = { providerAttached: true, providerCredentialReady: true, } as const; +const runtimeSelection = { + gatewayName: "nemoclaw-8091", + localTlsDir: "/recorded/gateway/tls", + workspace: "default", +} as const; function probeStdout( parts: { @@ -291,7 +296,13 @@ describe("MCP credential-resolution probe execution gates", () => { ] as const)( "fails closed before sandbox traffic unless policy and provider readiness are all true [case %#] (#6379)", (readiness, expectedDetail) => { - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readiness); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readiness, + runtimeSelection, + ); expect(probe).toMatchObject({ ok: null }); expect(probe.detail).toContain(expectedDetail); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); @@ -299,7 +310,13 @@ describe("MCP credential-resolution probe execution gates", () => { ); it("skips without contacting the sandbox when the adapter is not declared (#6379)", () => { - const probe = probeCredentialResolution("alpha", baseEntry, undefined, readyProbe); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + undefined, + readyProbe, + runtimeSelection, + ); expect(probe).toEqual({ ok: null, detail: "MCP adapter is not declared" }); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); }); @@ -310,6 +327,7 @@ describe("MCP credential-resolution probe execution gates", () => { { ...baseEntry, addState: "preflighted" }, "mcporter", readyProbe, + runtimeSelection, ); expect(probe).toEqual({ ok: null, detail: "add transaction incomplete" }); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); @@ -321,6 +339,7 @@ describe("MCP credential-resolution probe execution gates", () => { { ...baseEntry, url: "http://api.githubcopilot.com/mcp/" }, "mcporter", readyProbe, + runtimeSelection, ); expect(probe).toEqual({ ok: null, detail: "no credential binding or safe endpoint to probe" }); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); @@ -341,13 +360,20 @@ describe("MCP credential-resolution probe execution gates", () => { stderr: "", }; }); - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readyProbe); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readyProbe, + runtimeSelection, + ); expect(probe).toEqual({ ok: true, httpStatus: 200, controlHttpStatus: 401 }); expect(mocks.executeSandboxCommand).toHaveBeenCalledTimes(1); const [, command] = mocks.executeSandboxCommand.mock.calls[0]; expect(command).toContain("openshell:resolve:env:v11_GITHUB_TOKEN"); expect(command).not.toContain("authorization: Bearer openshell:resolve:env:GITHUB_TOKEN"); expect(command).toContain(MCP_PROBE_CONTROL_BEARER); + expect(mocks.executeSandboxCommand.mock.calls[0]?.[2]).toEqual({ runtimeSelection }); }); it("reuses a status observation instead of starting a second revision check (#10079)", () => { @@ -366,7 +392,14 @@ describe("MCP credential-resolution probe execution gates", () => { }; }); - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readyProbe, "v12"); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readyProbe, + runtimeSelection, + "v12", + ); expect(probe).toEqual({ ok: true, httpStatus: 200, controlHttpStatus: 401 }); expect(mocks.observeMcpCredentialRevision).not.toHaveBeenCalled(); @@ -378,7 +411,13 @@ describe("MCP credential-resolution probe execution gates", () => { it("does not probe with an identityless canonical placeholder (#10079)", () => { mocks.observeMcpCredentialRevision.mockReturnValue("canonical"); - const probe = probeCredentialResolution("alpha", baseEntry, "mcporter", readyProbe); + const probe = probeCredentialResolution( + "alpha", + baseEntry, + "mcporter", + readyProbe, + runtimeSelection, + ); expect(probe).toEqual({ ok: null, diff --git a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts index 401aaf466bb..dbc58f100e8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts +++ b/src/lib/actions/sandbox/mcp-bridge-resolution-probe.ts @@ -54,6 +54,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import { authorizationValue } from "./mcp-bridge-adapter-status"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; import { observeMcpCredentialRevision } from "./mcp-bridge-provider"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision, McpCredentialRevisionObservation, @@ -388,6 +389,7 @@ export function probeCredentialResolution( entry: McpBridgeEntry, adapter: AgentMcpAdapter | undefined, readiness: CredentialResolutionProbeReadiness, + runtimeSelection: McpProviderInspectionRuntimeSelection, observedCredentialRevision?: McpCredentialRevisionObservation, ): CredentialResolutionProbe { if (!adapter) return { ok: null, detail: "MCP adapter is not declared" }; @@ -406,7 +408,7 @@ export function probeCredentialResolution( let credentialRevision = observedCredentialRevision; if (credentialRevision === undefined) { try { - credentialRevision = observeMcpCredentialRevision(sandboxName, entry); + credentialRevision = observeMcpCredentialRevision(sandboxName, entry, runtimeSelection); } catch { return { ok: null, @@ -429,6 +431,6 @@ export function probeCredentialResolution( } const probeCommand = buildCredentialResolutionProbeCommand(entry, adapter, credentialRevision); if (!probeCommand) return { ok: null, detail: "no credential binding or safe endpoint to probe" }; - const result = executeSandboxCommand(sandboxName, probeCommand.command); + const result = executeSandboxCommand(sandboxName, probeCommand.command, { runtimeSelection }); return classifyCredentialResolutionProbe(result, entry, probeCommand.resultMarker); } diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index daea6bd9016..6cef17855b5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -17,9 +17,11 @@ import { attachProvider, detachMissingProviderReference, ensureMcpBridgeProviderProfile, + getMcpProviderInspectionRuntimeSelection, refreshMcpProviderEnvironment, type McpCredentialRevisionObservation, type McpProviderInspection, + type McpProviderInspectionRuntimeSelection, observeMcpCredentialRevision, preflightMcpEntryTargets, upsertMcpProvider, @@ -79,6 +81,7 @@ function resolvedTargetPins( async function assertRestartCredentialsAvailable( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): Promise { for (const entry of entries) { const exported = resolveCredentialEnv(entry.env.map((name) => ({ name }))); @@ -87,6 +90,7 @@ async function assertRestartCredentialsAvailable( try { const [status] = await statusMcpBridge(sandboxName, entry.server, { probeCredentialResolution: true, + runtimeSelection, }); const probe = status?.provider.credentialResolution; if (probe?.ok === true) continue; @@ -120,7 +124,12 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P const bridges = bridgeState(sandbox); const targets = server ? [[server, bridges[server]] as const] : Object.entries(bridges); if (targets.length === 0) { - if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + if (adapter === "hermes-config") { + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } console.log(` No MCP servers for sandbox '${sandboxName}'.`); return; } @@ -138,18 +147,26 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P const targetEntries = targets .map(([, entry]) => entry) .filter((entry): entry is McpBridgeEntry => !!entry); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); const resolvedByServer = await preflightMcpEntryTargets(targetEntries); assertMcpCredentialBoundaryRuntimeVersion(); - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); // A hostless restart may reuse an attached stored credential only after the // existing wire probe verifies it. Check every target before policy or // provider mutation so a multi-server restart cannot half-apply (#10750). - await assertRestartCredentialsAvailable(sandboxName, targetEntries); + await assertRestartCredentialsAvailable( + sandboxName, + targetEntries, + providerRuntimeSelection, + ); // Validate every generated policy name before inspecting or updating any provider. for (const entry of targetEntries) assertGeneratedPolicyMutationSafe(sandboxName, entry); const providerInspectionByServer = new Map(); for (const entry of targetEntries) { - providerInspectionByServer.set(entry.server, assertMcpProviderRecoverable(entry)); + providerInspectionByServer.set( + entry.server, + assertMcpProviderRecoverable(entry, providerRuntimeSelection), + ); } const missingProviderEntries = targetEntries.filter( (entry) => providerInspectionByServer.get(entry.server)?.exists === false, @@ -160,15 +177,20 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P // already proven absent; no live credential is removed before the runtime // capability probe, and the durable bridge manifest is retained on failure. for (const entry of missingProviderEntries) { - detachMissingProviderReference(sandboxName, entry); + detachMissingProviderReference(sandboxName, entry, providerRuntimeSelection); } - assertMcpAdapterMutationRuntimeCapabilities(sandboxName, sandbox, targetEntries); + assertMcpAdapterMutationRuntimeCapabilities( + sandboxName, + sandbox, + targetEntries, + providerRuntimeSelection, + ); for (const entry of missingProviderEntries) { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); } // Inspect registered providers once before the first mutation. Per-entry // checks below inspect only attached providers at each mutation edge. - assertNoProviderCredentialCollisions(sandboxName, targetEntries); + assertNoProviderCredentialCollisions(sandboxName, targetEntries, providerRuntimeSelection); for (const [name, storedEntry] of targets) { // Validated as a complete authenticated entry before gateway side effects. if (!storedEntry) continue; @@ -177,18 +199,26 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P const adapterEnvValues = resolveCredentialEnv(envRefs); const target = resolvedTargetPins(resolvedByServer, entry); let previousCredentialRevision: McpCredentialRevisionObservation | undefined; - assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); // Revalidate the actual running supervisor before rotating or recreating // credentials. The temporary policy cannot bind the provider until an // endpointless profile is attached. - ensureMcpBridgeProviderProfile(); - applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false }); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); + applyGeneratedPolicy(sandboxName, entry, target, { + bindCredential: false, + runtimeSelection: providerRuntimeSelection, + }); const providerResult = upsertMcpProvider(entry.providerName ?? "", envRefs, { allowExisting: true, expectedProviderId: entry.providerId, + runtimeSelection: providerRuntimeSelection, prepareMutation: (action) => { if (action === "update") { - previousCredentialRevision = observeMcpCredentialRevision(sandboxName, entry); + previousCredentialRevision = observeMcpCredentialRevision( + sandboxName, + entry, + providerRuntimeSelection, + ); } }, }); @@ -206,25 +236,33 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P writeBridgeEntry(sandboxName, refreshedEntry); entry = refreshedEntry; } - assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); if (providerResult.action === "updated" && previousCredentialRevision === undefined) { throw new McpBridgeError( `Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`, ); } - attachProvider(sandboxName, entry); - applyGeneratedPolicy(sandboxName, entry, target); - refreshMcpProviderEnvironment(entry); - const entryAdapter = (entry.adapter as AgentMcpAdapter | undefined) ?? adapter; - const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { - ...(providerResult.action === "updated" - ? { previousRevision: previousCredentialRevision } - : {}), + attachProvider(sandboxName, entry, providerRuntimeSelection); + applyGeneratedPolicy(sandboxName, entry, target, { + runtimeSelection: providerRuntimeSelection, }); + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); + const entryAdapter = (entry.adapter as AgentMcpAdapter | undefined) ?? adapter; + const credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { + ...(providerResult.action === "updated" + ? { previousRevision: previousCredentialRevision } + : {}), + }, + ); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, entryAdapter, entry, + providerRuntimeSelection, adapterEnvValues, credentialRevision, { replaceExisting: true }, @@ -236,7 +274,11 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P }); console.log(` Refreshed MCP server '${name}'.`); } - if (adapter === "hermes-config") assertHermesMcpRuntimeIntent(sandboxName); + if (adapter === "hermes-config") { + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } } export async function restoreExistingMcpBridgeRuntime( @@ -245,6 +287,7 @@ export async function restoreExistingMcpBridgeRuntime( options: { lifecyclePhase?: "active-mutation" | "teardown-rollback"; applyPolicy?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } = {}, ): Promise { if (entries.length === 0) return; @@ -253,22 +296,34 @@ export async function restoreExistingMcpBridgeRuntime( if (options.lifecyclePhase !== "teardown-rollback") { assertMcpCredentialBoundaryRuntimeVersion(); } - await ensureSandboxGatewaySelected(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); assertMcpDestroyNotPending(sandbox); if (options.lifecyclePhase === "teardown-rollback") { // A failed delete/rebuild must be able to restore a backward-compatible // Deep Agents entry on the same old image it just scrubbed. New/rebuilt // images use the default path and must prove the current marker before any // policy, provider, attachment, or adapter mutation. - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); } else { - assertMcpAdapterMutationRuntimeCapabilities(sandboxName, sandbox, entries); + assertMcpAdapterMutationRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); } const defaultAdapter = getBridgeAdapter(getSandboxAgent(sandbox)); for (const entry of entries) { assertGeneratedPolicyMutationSafe(sandboxName, entry); - const provider = assertMcpProviderRecoverable(entry); + const provider = assertMcpProviderRecoverable(entry, providerRuntimeSelection); if (provider.exists !== true) { throw new McpBridgeError( `OpenShell provider '${entry.providerName}' is missing. Runtime restoration refuses to create or rotate credentials; run explicit MCP restart after exporting '${entry.env[0]}'.`, @@ -279,26 +334,34 @@ export async function restoreExistingMcpBridgeRuntime( // pre-existing collision on a later entry cannot follow an earlier restore // mutation. Per-entry attached-provider checks detect new collisions at each // restore mutation edge. - assertNoProviderCredentialCollisions(sandboxName, entries); + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); for (const entry of entries) { - assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); - ensureMcpBridgeProviderProfile(); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); if (options.applyPolicy !== false) { applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { bindCredential: false, + runtimeSelection: providerRuntimeSelection, }); } - attachProvider(sandboxName, entry); + attachProvider(sandboxName, entry, providerRuntimeSelection); if (options.applyPolicy !== false) { - applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry)); + applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { + runtimeSelection: providerRuntimeSelection, + }); } const adapter = (entry.adapter as AgentMcpAdapter | undefined) ?? defaultAdapter; - refreshMcpProviderEnvironment(entry); - const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry); + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); + const credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + ); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, adapter, entry, + providerRuntimeSelection, {}, credentialRevision, { @@ -312,6 +375,9 @@ export async function restoreExistingMcpBridgeRuntime( defaultAdapter === "hermes-config" || entries.some((entry) => entry.adapter === "hermes-config") ) { - assertHermesMcpRuntimeIntent(sandboxName, { entries }); + assertHermesMcpRuntimeIntent(sandboxName, { + entries, + runtimeSelection: providerRuntimeSelection, + }); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts b/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts index 769f1b84fbe..e5700811b92 100644 --- a/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts +++ b/src/lib/actions/sandbox/mcp-bridge-runtime-capabilities.ts @@ -8,6 +8,7 @@ import { assertAgentMcpTeardownRuntimeCapability, } from "./mcp-bridge-adapters"; import { isAgentMcpAdapter } from "./mcp-bridge-contracts"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import { getBridgeAdapter, getSandboxAgent } from "./mcp-bridge-state"; function adaptersForEntries( @@ -25,18 +26,26 @@ export function assertMcpAdapterMutationRuntimeCapabilities( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { for (const adapter of adaptersForEntries(sandbox, entries)) { - assertAgentMcpMutationRuntimeCapability(sandboxName, adapter); + assertAgentMcpMutationRuntimeCapability(sandboxName, adapter, runtimeSelection); } } +/** + * Prove host-visible config mutability without requiring a capability marker + * from the image being torn down. Deep Agents entries created by an older + * NemoClaw release remain safe to scrub because their exact persisted adapter + * definition is still ownership-checked by unregisterAgentAdapter. + */ export function assertMcpAdapterTeardownRuntimeCapabilities( sandboxName: string, sandbox: SandboxEntry, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { for (const adapter of adaptersForEntries(sandbox, entries)) { - assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter); + assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter, runtimeSelection); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-state.ts b/src/lib/actions/sandbox/mcp-bridge-state.ts index 291c2efa813..4695ae5c7e8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-state.ts +++ b/src/lib/actions/sandbox/mcp-bridge-state.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { type AgentDefinition, type AgentMcpAdapter, loadAgent } from "../../agent/defs"; -import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; +import { + recoverNamedGatewayRuntime, + replaceOpenShellRuntimeSelectionEnv, +} from "../../gateway-runtime-action"; import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { getSandboxTargetGatewayName } from "./gateway-target"; @@ -234,10 +238,14 @@ export function removeBridgeEntry(sandboxName: string, server: string): void { setBridgeState(sandboxName, bridges); } -export async function ensureSandboxGatewaySelected(sandboxName: string): Promise { +export async function ensureSandboxGatewaySelected( + sandboxName: string, + runtimeSelection: OpenShellRuntimeSelection, +): Promise { const gatewayName = getSandboxTargetGatewayName(sandboxName); const recovery = await recoverNamedGatewayRuntime({ gatewayName, + runtimeSelection, }); if (!recovery.recovered || recovery.after.state !== "healthy_named") { throw new McpBridgeError( @@ -248,5 +256,5 @@ export async function ensureSandboxGatewaySelected(sandboxName: string): Promise // the sandbox's recorded gateway. The globally selected gateway is mutable // shared metadata and another NemoClaw process may select a sibling between // this health check and the provider/policy mutation. - process.env.OPENSHELL_GATEWAY = gatewayName; + replaceOpenShellRuntimeSelectionEnv(process.env, runtimeSelection); } diff --git a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts index 1757a5dc0f4..577333fc0e5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts @@ -8,6 +8,8 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; + const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] .filter(Boolean) @@ -25,7 +27,102 @@ afterEach(() => { tempHomes.clear(); }); -describe("cross-agent MCP status boundaries", () => { +describe("cross-agent MCP status boundaries", testTimeoutOptions(45_000), () => { + it("pins provider reads to the recorded runtime (#10514)", () => { + const home = createTempHome("nemoclaw-mcp-status-provider-target-"); + const script = String.raw` +process.env.HOME = ${JSON.stringify(home)}; +process.env.OPENSHELL_GATEWAY = "ambient-gateway"; +process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://other.example.test"; +process.env.OPENSHELL_GATEWAY_INSECURE = "true"; +process.env.OPENSHELL_LOCAL_TLS_DIR = "/tmp/ambient-client-tls"; +process.env.OPENSHELL_TOKEN = "ambient-token"; +process.env.OPENSHELL_WORKSPACE = "ambient-workspace"; +const registry = require("./src/lib/state/registry.js"); +const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); +const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +const providerEnvironments = []; +providerCommands.setProviderCommandRuntimeHooksForTest({ runOpenshell: (args, options) => { + providerEnvironments.push(options.env); + if (args[0] === "provider" && args[1] === "get") { + return { + status: 0, + stdout: "Id: 11111111-2222-4333-8444-555555555555\nType: nemoclaw-mcp-v1\nResource version: 4\nCredential keys: GITHUB_TOKEN\n", + stderr: "", + }; + } + if (args.slice(0, 3).join(" ") === "sandbox provider list") { + return { + status: 0, + stdout: "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-github nemoclaw-mcp-v1 1 0\n", + stderr: "", + }; + } + throw new Error("Unexpected OpenShell call: " + args.join(" ")); +} }); +gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ + recovered: true, + attempted: false, + before: { state: "healthy_named" }, + after: { state: "healthy_named" }, +}); +processRecovery.executeSandboxExecCommand = () => ({ status: 0, stdout: "v11", stderr: "" }); +processRecovery.executeSandboxCommand = () => ({ status: 0, stdout: "registered", stderr: "" }); +registry.registerSandbox({ + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, + mcp: { bridges: { github: { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + allowedIps: ["8.8.8.8"], + providerName: "alpha-mcp-github", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-06-01T00:00:00.000Z", + } } }, +}); +require("./src/lib/actions/sandbox/mcp-bridge-status.js").statusMcpBridge("alpha", "github").then( + () => process.stdout.write(JSON.stringify({ + processEnvironment: Object.fromEntries( + Object.entries(process.env).filter(([name]) => name.startsWith("OPENSHELL_")), + ), + providerEnvironments, + })), + (error) => process.stderr.write(error.stack || error.message, () => process.exit(1)), +); +`; + const result = spawnSync(process.execPath, ["-e", script], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, HOME: home, NODE_OPTIONS: sourceNodeOptions }, + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + const payload = JSON.parse(result.stdout) as { + processEnvironment: Record; + providerEnvironments: Array>; + }; + expect(payload.processEnvironment).toEqual({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_WORKSPACE: "default", + }); + expect(payload.providerEnvironments.length).toBeGreaterThan(0); + expect( + payload.providerEnvironments.every( + (environment) => + environment.OPENSHELL_GATEWAY === "nemoclaw-9090" && + environment.OPENSHELL_WORKSPACE === "default" && + !Object.hasOwn(environment, "OPENSHELL_GATEWAY_ENDPOINT"), + ), + ).toBe(true); + }); + it("reports unsupported persisted boundaries without starting an unsafe sandbox child", () => { const home = createTempHome("nemoclaw-mcp-status-risk-"); const script = String.raw` @@ -66,6 +163,8 @@ processRecovery.executeSandboxCommand = () => { registry.registerSandbox({ name: "alpha", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { fake: { server: "fake", agent: "openclaw", @@ -132,8 +231,17 @@ const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); const script = ` process.env.HOME = ${JSON.stringify(home)}; const registry = require("./src/lib/state/registry.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => { + throw new Error("runtime selection resolved for empty MCP status"); +}; const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes" }); +registry.registerSandbox({ + name: "hermes-sandbox", + agent: "hermes", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, +}); bridge.dispatchMcpBridgeCommand("hermes-sandbox", ["status", "--json"]).then( () => process.exit(0), (error) => { diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index 9f96af194bf..802c005da5d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -19,12 +19,14 @@ import { import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; import { getPolicyPresence, getRegisteredGeneratedPolicy } from "./mcp-bridge-policy"; import { + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, observeMcpCredentialRevision, providerAttached, providerMatchesCredential, providerShapeDetail, } from "./mcp-bridge-provider"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision, McpCredentialRevisionObservation, @@ -93,6 +95,7 @@ function getAdapterRegistration( sandboxName: string, adapter: AgentMcpAdapter | undefined, entry: McpBridgeEntry | undefined, + runtimeSelection: McpProviderInspectionRuntimeSelection, hermesReconciliation?: HermesMcpReconciliationResult, credentialRevision?: McpAttachedCredentialRevision, credentialObservationDetail?: string, @@ -123,7 +126,7 @@ function getAdapterRegistration( : adapter === "hermes-config" ? buildHermesMcpStatusCommand(entry, credentialRevision) : buildDeepAgentsMcpStatusCommand(entry, credentialRevision); - const result = executeSandboxCommand(sandboxName, command); + const result = executeSandboxCommand(sandboxName, command, { runtimeSelection }); if (!result) return credentialInspectionFailure ?? { registered: null, detail: "sandbox unreachable" }; const unsafeProjection = @@ -165,6 +168,8 @@ export interface McpBridgeStatusOptions { * layer restricts this live operation to an explicitly named server. */ discoverTools?: boolean; + /** Reuse the operation-scoped OpenShell target when status closes another lifecycle action. */ + runtimeSelection?: McpProviderInspectionRuntimeSelection; } function attachedCredentialRevision( @@ -202,9 +207,6 @@ export async function statusMcpBridge( const sandbox = getSandboxOrThrow(sandboxName); const agent = getSandboxAgent(sandbox); const bridges = bridgeState(sandbox); - if (Object.keys(bridges).length > 0) { - await ensureSandboxGatewaySelected(sandboxName); - } const selectedEntry = server !== undefined && Object.hasOwn(bridges, server) ? bridges[server] : undefined; const entries: Array<[string, McpBridgeEntry | undefined]> = @@ -244,12 +246,23 @@ export async function statusMcpBridge( }, ]; } + const hasHermesManagedIntent = + agent.name === "hermes" && (sandbox.mcp?.managedServerNames?.length ?? 0) > 0; + if (entries.length === 0 && !hasHermesManagedIntent) return []; + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + if (Object.keys(bridges).length > 0) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const credentialObservations = new Map(); for (const [name, entry] of entries) { if (!entry || storedCredentialWarning(entry) !== undefined) continue; try { - credentialObservations.set(name, observeMcpCredentialRevision(sandboxName, entry)); + credentialObservations.set( + name, + observeMcpCredentialRevision(sandboxName, entry, providerRuntimeSelection), + ); } catch { credentialObservations.set(name, null); } @@ -277,7 +290,10 @@ export async function statusMcpBridge( state: "error" as const, detail: hermesCredentialObservationDetail, } - : inspectHermesMcpRuntimeIntent(sandboxName, { credentialRevisions }) + : inspectHermesMcpRuntimeIntent(sandboxName, { + credentialRevisions, + runtimeSelection: providerRuntimeSelection, + }) : undefined; if (entries.length === 0 && hermesReconciliation && !hermesReconciliation.ok) { throw new McpBridgeError( @@ -303,7 +319,7 @@ export async function statusMcpBridge( return entries.map(([name, entry]) => { const support = entry ? getPersistedBridgeSupport(entry) : getSupportSummary(agent); const registeredPolicy = getRegisteredGeneratedPolicy(sandboxName, entry); - const policyPresence = getPolicyPresence(sandboxName, entry); + const policyPresence = getPolicyPresence(sandboxName, entry, providerRuntimeSelection); const hasCredentialBinding = !!entry && Array.isArray(entry.env) && @@ -316,7 +332,7 @@ export async function statusMcpBridge( ) : []; const expectedCredential = entry?.env.length === 1 ? entry.env[0] : undefined; - const providerInspection = inspectMcpProvider(entry?.providerName); + const providerInspection = inspectMcpProvider(entry?.providerName, providerRuntimeSelection); const providerCredentialReady = providerMatchesCredential( providerInspection, expectedCredential, @@ -327,7 +343,11 @@ export async function statusMcpBridge( expectedCredential, entry?.providerId, ); - const attached = providerAttached(sandboxName, entry?.providerName); + const attached = providerAttached( + sandboxName, + entry?.providerName, + providerRuntimeSelection, + ); const warnings: string[] = []; let credentialWarning: string | undefined; if (entry) { @@ -360,6 +380,7 @@ export async function statusMcpBridge( sandboxName, support.adapter, entry, + providerRuntimeSelection, hermesReconciliation, credentialRevision, unsafeCredentialMayBeAttached ? UNSUPPORTED_ATTACHED_CREDENTIAL_DETAIL : observationDetail, @@ -384,6 +405,7 @@ export async function statusMcpBridge( entry, support.adapter, readiness, + providerRuntimeSelection, credentialRevision, ) : undefined; @@ -401,7 +423,13 @@ export async function statusMcpBridge( truncated: false, detail: `tool discovery skipped: ${UNSUPPORTED_ATTACHED_CREDENTIAL_DETAIL}`, } - : discoverMcpTools(sandboxName, entry, support.adapter, readiness) + : discoverMcpTools( + sandboxName, + entry, + support.adapter, + readiness, + providerRuntimeSelection, + ) : undefined; return { server: name, diff --git a/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts b/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts index d3369860200..6dd78ba149e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts +++ b/src/lib/actions/sandbox/mcp-bridge-tool-discovery.ts @@ -6,6 +6,7 @@ import { shellQuote } from "../../core/shell-quote"; import type { McpBridgeEntry } from "../../state/registry"; import type { McpBridgeStatus } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { CredentialResolutionProbeReadiness } from "./mcp-bridge-resolution-readiness"; import { MCP_RUNTIME_SANITIZED_ENV_VARS, @@ -226,6 +227,7 @@ export function discoverMcpTools( entry: McpBridgeEntry, adapter: AgentMcpAdapter | undefined, readiness: McpToolDiscoveryReadiness, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): NonNullable { if (!adapter) return failure("tool discovery skipped: MCP adapter is not declared"); if (entry.addState) return failure("tool discovery skipped: add transaction is incomplete"); @@ -236,7 +238,7 @@ export function discoverMcpTools( return failure("tool discovery skipped: no valid managed endpoint is available"); } return classifyMcpToolDiscoveryResult( - executeSandboxCommand(sandboxName, discoveryCommand.command), + executeSandboxCommand(sandboxName, discoveryCommand.command, { runtimeSelection }), entry, discoveryCommand.resultMarker, ); diff --git a/src/lib/actions/sandbox/mcp-bridge.ts b/src/lib/actions/sandbox/mcp-bridge.ts index 6b81455f5e7..ebea3223f6e 100644 --- a/src/lib/actions/sandbox/mcp-bridge.ts +++ b/src/lib/actions/sandbox/mcp-bridge.ts @@ -3,6 +3,7 @@ import type { McpBridgeEntry } from "../../state/registry"; import type { McpScrubbedAdapterEntry } from "./mcp-bridge-adapter-teardown"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider"; import { addMcpBridge as addMcpBridgeLifecycle } from "./mcp-bridge-add-restart"; import { type McpBridgeAddOptions, @@ -89,7 +90,7 @@ export { statusMcpBridge }; export async function addMcpBridge( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise { return addMcpBridgeLifecycle(sandboxName, options); } @@ -107,14 +108,20 @@ export async function removeMcpBridge( export async function prepareMcpBridgesForAbsentSandboxDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; + } = {}, ): Promise { return prepareMcpBridgesForAbsentSandboxDestroyLifecycle(sandboxName, options); } export async function prepareMcpBridgesForDestroy( sandboxName: string, - options: { force?: boolean } = {}, + options: { + force?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; + } = {}, ): Promise { return prepareMcpBridgesForDestroyLifecycle(sandboxName, options); } @@ -136,33 +143,38 @@ export async function finalizeMcpBridgesAfterSandboxDelete( export async function prepareMcpBridgesForAbsentSandboxRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - return prepareMcpBridgesForAbsentSandboxRebuildLifecycle(sandboxName); + return prepareMcpBridgesForAbsentSandboxRebuildLifecycle(sandboxName, runtimeSelection); } export async function prepareMcpBridgesForRebuild( sandboxName: string, + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - return prepareMcpBridgesForRebuildLifecycle(sandboxName); + return prepareMcpBridgesForRebuildLifecycle(sandboxName, runtimeSelection); } export async function reattachMcpProvidersAfterRebuildAbort( sandboxName: string, entries: readonly McpBridgeEntry[], scrubbedAdapterEntries: readonly McpScrubbedAdapterEntry[] = [], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { return reattachMcpProvidersAfterRebuildAbortLifecycle( sandboxName, entries, scrubbedAdapterEntries, + runtimeSelection, ); } export async function restoreMcpBridgesAfterRebuild( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { - return restoreMcpBridgesAfterRebuildLifecycle(sandboxName, entries); + return restoreMcpBridgesAfterRebuildLifecycle(sandboxName, entries, runtimeSelection); } function parseJsonFlag(args: string[]): { json: boolean; rest: string[] } { @@ -193,12 +205,17 @@ function parseToolsFlag(args: string[]): { tools: boolean; rest: string[] } { * nonzero exit here would break scripted adds mid-remediation; `mcp status * ` remains the authoritative recheck. */ -async function reportAddCredentialResolution(sandboxName: string, server: string): Promise { +async function reportAddCredentialResolution( + sandboxName: string, + server: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): Promise { let probe: McpBridgeStatus["provider"]["credentialResolution"]; let credentialEnvName: string | undefined; try { const [status] = await statusMcpBridge(sandboxName, server, { probeCredentialResolution: true, + runtimeSelection, }); probe = status?.provider.credentialResolution; credentialEnvName = status?.env.names[0]; @@ -314,9 +331,11 @@ export async function dispatchMcpBridgeCommand( 2, ); const options = parseMcpAddArgs(addRest); - await addMcpBridge(sandboxName, options); + const runtimeSelection = await addMcpBridge(sandboxName, options); console.log(` MCP server '${options.server}' added to sandbox '${sandboxName}'.`); - if (probe !== false) await reportAddCredentialResolution(sandboxName, options.server); + if (probe !== false) { + await reportAddCredentialResolution(sandboxName, options.server, runtimeSelection); + } return; } case "list": { diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index 62c0cad7d45..4a539e3c981 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -4,14 +4,17 @@ import type { StdioOptions } from "node:child_process"; import { PROVIDER_NAME_MAX_LENGTH, PROVIDER_NAME_VALID_PATTERN } from "../../name-validation"; -import { buildSubprocessEnv } from "../../subprocess-env"; +import { buildOpenShellCommandEnv } from "./command-argv"; +import type { OpenShellRuntimeSelection } from "./runtime-selection"; import { OPENSHELL_OPERATION_TIMEOUT_MS, runOpenshell } from "./runtime"; +export type { OpenShellRuntimeSelection } from "./runtime-selection"; export { OPENSHELL_OPERATION_TIMEOUT_MS }; export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; stdio?: StdioOptions; suppressOutput?: boolean; timeout?: number; @@ -44,14 +47,15 @@ export function parseCliOpenShellProviderNames(output: unknown): string[] | null } export function runOpenshellProviderCommand(args: string[], opts?: ProviderCommandOptions) { + const { runtimeSelection, ...runtimeOptions } = opts ?? {}; const explicitEnv = Object.fromEntries( - Object.entries(opts?.env ?? {}).filter( + Object.entries(runtimeOptions.env ?? {}).filter( (entry): entry is [string, string] => entry[1] !== undefined, ), ); const providerOpts = { - ...opts, - env: buildSubprocessEnv(explicitEnv), + ...runtimeOptions, + env: buildOpenShellCommandEnv(runtimeSelection, explicitEnv), replaceEnv: true, }; const commandRunner = runtimeHooks.runOpenshell ?? runOpenshell; diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index e8533136efd..e5ab486b6a9 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -23,6 +23,7 @@ import { } from "../adapters/openshell/sandbox-policy-cli"; import { PolicyObservationError } from "../adapters/openshell/policy-state"; export { isPolicyObservationError } from "../adapters/openshell/policy-state"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; import { @@ -741,8 +742,13 @@ export function inspectPolicyMutationContext( * Read the round-trippable base policy through the sandbox's recorded gateway. * Destructive lifecycle callers use this instead of the ambient CLI gateway. */ -export function captureRecordedSandboxBasePolicy(sandboxName: string, operation: string): string { - return inspectLivePolicyBoundary(sandboxName, operation).basePolicyDocument; +export function captureRecordedSandboxBasePolicy( + sandboxName: string, + operation: string, + runtimeSelection?: OpenShellRuntimeSelection, +): string { + return inspectLivePolicyBoundary(sandboxName, operation, runtimeSelection?.gatewayName) + .basePolicyDocument; } function preparePolicyMutationContext( @@ -1731,7 +1737,11 @@ function removePresetFromPolicy( function removePreset( sandboxName: string, presetName: string, - options: { nonFatal?: boolean; presetContent?: string } = {}, + options: { + nonFatal?: boolean; + presetContent?: string; + runtimeSelection?: OpenShellRuntimeSelection; + } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -1750,7 +1760,11 @@ function removePreset( } const operation = `remove policy preset '${presetName}'`; - const context = inspectLivePolicyForMutation(sandboxName, operation); + const context = inspectLivePolicyForMutation( + sandboxName, + operation, + options.runtimeSelection?.gatewayName, + ); if (!context) return false; const currentPolicy = currentPolicyFromMutationContext(context); @@ -2148,6 +2162,7 @@ function applyPresetContent( suppressDisclosure?: boolean; disclosedPresetState?: PresetPolicyState | null; includeMessagingCredentialBindings?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated @@ -2230,7 +2245,11 @@ function applyPresetContent( const operation = `apply policy preset '${presetName}'`; let context: PolicyMutationContext; try { - context = preparePolicyMutationContext(sandboxName, operation); + context = preparePolicyMutationContext( + sandboxName, + operation, + options.runtimeSelection?.gatewayName, + ); } catch (error) { return reportPolicyObservationFailure(error); } @@ -2745,9 +2764,11 @@ function getPresetContentGatewayState( sandboxName: string, presetContent: string, policyKey?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): "match" | "absent" | "drift" | null { return inspectPresetContentGatewayState({ - readPolicy: () => readCurrentSandboxPolicy(sandboxName) ?? "", + readPolicy: () => + readCurrentSandboxPolicy(sandboxName, runtimeSelection?.gatewayName) ?? "", parseCurrentPolicy: parseCurrentPolicyOrEmpty, extractPresetEntries, presetContent, diff --git a/src/lib/policy/policy-live-state.test.ts b/src/lib/policy/policy-live-state.test.ts index 60329fb58ab..5ce9bcf80c5 100644 --- a/src/lib/policy/policy-live-state.test.ts +++ b/src/lib/policy/policy-live-state.test.ts @@ -44,6 +44,7 @@ vi.mock("../state/registry", async (importOriginal) => ({ import { applyPresetContent, applyPresets, + captureRecordedSandboxBasePolicy, confirmAppliedPolicySetSubmission, excludeBaselineEntry, inspectPolicyMutationContext, @@ -106,6 +107,29 @@ describe("live OpenShell policy mutations", () => { ); }); + it("pins recorded policy reads to the supplied operation target (#10514)", () => { + const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" } as const; + + expect( + captureRecordedSandboxBasePolicy( + sandboxName, + "capture a lifecycle policy", + runtimeSelection, + ), + ).toBe(livePolicy); + expect(mocks.inspectSandboxPolicy).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + sandboxName, + }); + expect(mocks.readSandboxPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "named", gatewayName: "nemoclaw" }, + sandboxName, + scope: "base", + }), + ); + }); + it("confirms an ambiguous submission when authoritative readback matches", () => { const context = inspectPolicyMutationContext(sandboxName, "prepare policy confirmation"); diff --git a/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts b/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts index 34e7f76718d..d7aaf116c9c 100644 --- a/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts +++ b/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mockManagedEndpointlessProviderProfileRun } from "../../helpers/onboard-script-mocks.cjs"; @@ -27,7 +27,8 @@ vi.mock("../../../src/lib/adapters/openshell/provider-command", () => ({ runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, })); -vi.mock("../../../src/lib/gateway-runtime-action", () => ({ +vi.mock("../../../src/lib/gateway-runtime-action", async (importOriginal) => ({ + ...(await importOriginal()), recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, })); @@ -48,11 +49,19 @@ vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.106"); const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_GATEWAY_MANAGEMENT = process.env.NEMOCLAW_GATEWAY_MANAGEMENT; const ORIGINAL_OPENSHELL_BIN = process.env.NEMOCLAW_OPENSHELL_BIN; const ORIGINAL_OPENSHELL_GATEWAY = process.env.OPENSHELL_GATEWAY; const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-legacy-")); +const GATEWAY_MANAGEMENT = path.join(TMP_HOME, "gateway-management.json"); + +fs.writeFileSync( + GATEWAY_MANAGEMENT, + JSON.stringify({ version: 1, mode: "nemoclaw-managed", requiredCapabilities: [] }), +); process.env.HOME = TMP_HOME; +process.env.NEMOCLAW_GATEWAY_MANAGEMENT = GATEWAY_MANAGEMENT; process.env.NEMOCLAW_OPENSHELL_BIN = MATCHING_OPENSHELL; const registry = await import("../../../src/lib/state/registry"); @@ -96,13 +105,19 @@ function restoreEnvironmentVariable(name: string, value: string | undefined): vo afterAll(() => { restoreEnvironmentVariable("HOME", ORIGINAL_HOME); + restoreEnvironmentVariable("NEMOCLAW_GATEWAY_MANAGEMENT", ORIGINAL_GATEWAY_MANAGEMENT); restoreEnvironmentVariable("NEMOCLAW_OPENSHELL_BIN", ORIGINAL_OPENSHELL_BIN); restoreEnvironmentVariable("OPENSHELL_GATEWAY", ORIGINAL_OPENSHELL_GATEWAY); fs.rmSync(TMP_HOME, { recursive: true, force: true }); }); +afterEach(() => { + restoreEnvironmentVariable("NEMOCLAW_GATEWAY_MANAGEMENT", ORIGINAL_GATEWAY_MANAGEMENT); +}); + beforeEach(() => { fs.rmSync(path.dirname(registry.REGISTRY_FILE), { recursive: true, force: true }); + process.env.NEMOCLAW_GATEWAY_MANAGEMENT = GATEWAY_MANAGEMENT; restoreEnvironmentVariable("OPENSHELL_GATEWAY", ORIGINAL_OPENSHELL_GATEWAY); providerExists = true; diff --git a/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts b/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts index 2a7a4013c1d..5e04b0f6539 100644 --- a/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts +++ b/test/agents/deepagents/deepagents-mcp-runtime-capability.test.ts @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ executeGatewaySupervisorAction: vi.fn(), executeSandboxCommand: vi.fn(), + getSandbox: vi.fn(), })); vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ @@ -13,24 +14,46 @@ vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ executeSandboxCommand: mocks.executeSandboxCommand, })); +vi.mock("../../../src/lib/state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: mocks.getSandbox, +})); + import { assertAgentMcpMutationRuntimeCapability } from "../../../src/lib/actions/sandbox/mcp-bridge-adapters"; +beforeEach(() => { + mocks.getSandbox.mockReset().mockReturnValue({ + agent: "langchain-deepagents-code", + gatewayName: "nemoclaw-8091", + name: "deepagents-box", + }); +}); + type ProbeResult = { status: number; stdout: string; stderr: string } | null; function runDeepAgentsProbe(result: ProbeResult) { mocks.executeSandboxCommand.mockReset().mockReturnValue(result); + const runtimeSelection = { + gatewayName: "nemoclaw-8091", + workspace: "default", + } as const; let message = ""; try { - assertAgentMcpMutationRuntimeCapability("deepagents-box", "deepagents-config"); + assertAgentMcpMutationRuntimeCapability( + "deepagents-box", + "deepagents-config", + runtimeSelection, + ); } catch (error) { message = error instanceof Error ? error.message : String(error); } return { - calls: mocks.executeSandboxCommand.mock.calls.map(([sandboxName, command]) => ({ + calls: mocks.executeSandboxCommand.mock.calls.map(([sandboxName, command, options]) => ({ sandboxName, command, + runtimeSelection: options?.runtimeSelection, })), message, }; @@ -49,6 +72,10 @@ describe("Deep Agents managed MCP runtime capability", () => { { sandboxName: "deepagents-box", command: "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability", + runtimeSelection: { + gatewayName: "nemoclaw-8091", + workspace: "default", + }, }, ], message: "", diff --git a/test/agents/hermes/hermes-mcp-startup-probe.test.ts b/test/agents/hermes/hermes-mcp-startup-probe.test.ts index e633a60ef2e..d82c727c045 100644 --- a/test/agents/hermes/hermes-mcp-startup-probe.test.ts +++ b/test/agents/hermes/hermes-mcp-startup-probe.test.ts @@ -28,18 +28,26 @@ vi.mock("../../../src/lib/core/wait", () => ({ import { assertAgentMcpMutationRuntimeCapability } from "../../../src/lib/actions/sandbox/mcp-bridge-adapters"; type ProbeResult = { status: number; stdout: string; stderr: string }; -type SupervisorResult = ProbeResult | null; -function runHermesProbe(results: ProbeResult[], supervisorResults: SupervisorResult[] = []) { +function runHermesProbe(results: ProbeResult[]) { + const runtimeSelection = { + gatewayName: "nemoclaw-8091", + workspace: "default", + } as const; let calls = 0; - let recoveryCalls = 0; const recoveryActions: Array<{ action: string; timeout: number }> = []; - mocks.runOpenshellProviderCommand.mockImplementation(() => results[calls++]); + mocks.runOpenshellProviderCommand.mockImplementation((_args, options) => { + expect(options?.runtimeSelection).toEqual({ + gatewayName: "nemoclaw-8091", + workspace: "default", + }); + return results[calls++]; + }); mocks.executeGatewaySupervisorAction.mockImplementation( (_sandbox: string, action: string, timeout: number) => { recoveryActions.push({ action, timeout }); - return supervisorResults[recoveryCalls++] ?? null; + return null; }, ); mocks.waitUntil.mockImplementation( @@ -59,7 +67,7 @@ function runHermesProbe(results: ProbeResult[], supervisorResults: SupervisorRes ); let message = ""; try { - assertAgentMcpMutationRuntimeCapability("hermes-box", "hermes-config"); + assertAgentMcpMutationRuntimeCapability("hermes-box", "hermes-config", runtimeSelection); } catch (error) { message = error instanceof Error ? error.message : String(error); } @@ -81,12 +89,6 @@ const ready: ProbeResult = { stdout: '{"ok":true}\n', stderr: "", }; -const recovered: SupervisorResult = { - status: 0, - stdout: `v1 ${"a".repeat(64)} complete ok 0 4242\nGATEWAY_PID=4242`, - stderr: "", -}; - describe("Hermes managed MCP startup probe", () => { it("retries only the exact transient gateway-starting result", () => { expect(runHermesProbe([starting, ready])).toEqual({ @@ -104,80 +106,13 @@ describe("Hermes managed MCP startup probe", () => { }); }); - it("uses one host-authenticated recovery after repeated exact not-ready probes", () => { - expect(runHermesProbe([starting, starting, starting, ready], [recovered])).toEqual({ - calls: 4, - recoveryActions: [{ action: "recover", timeout: 210_000 }], - message: "", - }); - }); - - it("keeps the fresh helper wait when privileged recovery is unavailable", () => { - expect(runHermesProbe([starting, starting, starting, ready])).toEqual({ - calls: 4, - recoveryActions: [{ action: "recover", timeout: 210_000 }], - message: "", - }); - }); - - it("does not treat controller success as transaction-helper readiness", () => { - const result = runHermesProbe([starting, starting, starting, starting, starting], [recovered]); - - expect(result.calls).toBe(5); - expect(result.recoveryActions).toEqual([{ action: "recover", timeout: 210_000 }]); - expect(result.message).toContain("after managed gateway recovery"); - }); - - it.each([ - "GATEWAY_CONFIG_HASH_MISMATCH", - "SUPERVISOR_REBUILD_REQUIRED", - "SUPERVISOR_UNSAFE_CONTROL_DIR", - "SUPERVISOR_INVALID_STATUS", - "GATEWAY_HEALTH_TIMEOUT", - "SUPERVISOR_TIMEOUT", - "SUPERVISOR_BUSY", - ])( - "fails typed managed-recovery integrity refusal %s without another sandbox probe", - (marker) => { - const result = runHermesProbe( - [starting, starting, starting, ready], - [{ status: 1, stdout: "", stderr: marker }], - ); - - expect(result.calls).toBe(3); - expect(result.recoveryActions).toEqual([{ action: "recover", timeout: 210_000 }]); - expect(result.message).toContain("managed gateway recovery failed before MCP mutation"); - expect(result.message).toContain(marker); - }, - ); - - it.each([ - { - label: "non-numeric PID", - result: { status: 0, stdout: "GATEWAY_PID=garbage", stderr: "" }, - }, - { - label: "failure output beside a completion", - result: { ...recovered!, stderr: "SUPERVISOR_UNSAFE_CONTROL_DIR" }, - }, - { - label: "failure status beside a completion", - result: { ...recovered!, status: 1 }, - }, - { - label: "partial completion protocol", - result: { - status: 1, - stdout: `v1 ${"a".repeat(64)} complete ok 0 4242`, - stderr: "", - }, - }, - ])("rejects invalid controller response: $label", ({ result: invalidResult }) => { - const result = runHermesProbe([starting, starting, starting, ready], [invalidResult]); + it("fails closed on the selected target without host-local supervisor recovery", () => { + const result = runHermesProbe([starting, starting, starting, ready]); expect(result.calls).toBe(3); - expect(result.recoveryActions).toEqual([{ action: "recover", timeout: 210_000 }]); - expect(result.message).toContain("managed gateway recovery failed before MCP mutation"); + expect(result.recoveryActions).toEqual([]); + expect(result.message).toContain("recorded OpenShell target 'nemoclaw-8091'"); + expect(result.message).toContain("NemoClaw did not attempt host-local supervisor recovery"); }); it("fails immediately on trust and topology errors", () => { @@ -216,8 +151,7 @@ describe("Hermes managed MCP startup probe", () => { const result = runHermesProbe([starting, starting, starting]); expect(result.calls).toBe(3); - expect(result.recoveryActions).toEqual([{ action: "recover", timeout: 210_000 }]); - expect(result.message).toContain("after managed gateway recovery"); - expect(result.message).toContain("no controller result"); + expect(result.recoveryActions).toEqual([]); + expect(result.message).toContain("recorded OpenShell target 'nemoclaw-8091'"); }); }); diff --git a/test/mcp/mcp-adapter-teardown-rollback.test.ts b/test/mcp/mcp-adapter-teardown-rollback.test.ts index 8dc5303424a..12b3b605554 100644 --- a/test/mcp/mcp-adapter-teardown-rollback.test.ts +++ b/test/mcp/mcp-adapter-teardown-rollback.test.ts @@ -43,6 +43,7 @@ const entry: McpBridgeEntry = { addedAt: "2026-06-27T00:00:00.000Z", }; const sandbox: SandboxEntry = { name: "alpha" }; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" } as const; describe("MCP adapter teardown rollback", () => { beforeEach(() => { @@ -54,15 +55,19 @@ describe("MCP adapter teardown rollback", () => { const opaqueRevision = "v4067750153477477215"; testState.observeCredentialRevision.mockReturnValue(opaqueRevision); - const failures = rollbackScrubbedMcpAdapters("alpha", sandbox, [ - { ...entry, credentialRevision: "v1" }, - ]); + const failures = rollbackScrubbedMcpAdapters( + "alpha", + sandbox, + [{ ...entry, credentialRevision: "v1" }], + runtimeSelection, + ); expect(failures).toEqual([]); expect(testState.registerAdapter).toHaveBeenCalledWith( "alpha", "mcporter", expect.objectContaining({ server: "github" }), + runtimeSelection, {}, opaqueRevision, { replaceExisting: true, teardownRollback: true }, @@ -74,9 +79,12 @@ describe("MCP adapter teardown rollback", () => { (observation) => { testState.observeCredentialRevision.mockReturnValue(observation); - const failures = rollbackScrubbedMcpAdapters("alpha", sandbox, [ - { ...entry, credentialRevision: "v4067750153477477215" }, - ]); + const failures = rollbackScrubbedMcpAdapters( + "alpha", + sandbox, + [{ ...entry, credentialRevision: "v4067750153477477215" }], + runtimeSelection, + ); expect(failures).toEqual([ "Could not restore the managed adapter entry for MCP server 'github' without its observed credential revision.", diff --git a/test/mcp/mcp-add-crash-consistency.test.ts b/test/mcp/mcp-add-crash-consistency.test.ts index f31cfb26bdb..b0393723c95 100644 --- a/test/mcp/mcp-add-crash-consistency.test.ts +++ b/test/mcp/mcp-add-crash-consistency.test.ts @@ -35,11 +35,13 @@ function buildAddProcessScript( crashAfter: CrashBoundary, includeSecret = true, initializeSandbox = true, + forbidRuntimeSelection = false, ): string { return String.raw` process.env.HOME = ${JSON.stringify(home)}; const includeSecret = ${JSON.stringify(includeSecret)}; const initializeSandbox = ${JSON.stringify(initializeSandbox)}; +const forbidRuntimeSelection = ${JSON.stringify(forbidRuntimeSelection)}; includeSecret ? (process.env.FAKE_MCP_SECRET = "host-only-secret") : delete process.env.FAKE_MCP_SECRET; const fs = require("node:fs"); const path = require("node:path"); @@ -86,6 +88,7 @@ let credentialObservationAfterRepublishCountThisProcess = 0; const registry = require("./src/lib/state/registry.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const runner = require("./src/lib/runner.js"); @@ -103,6 +106,13 @@ const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const ownershipLocks = require("./src/lib/state/mcp-lifecycle-lock/credential-ownership.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => { + if (forbidRuntimeSelection) { + throw new Error("runtime selection resolved before local MCP add validation"); + } + return { gatewayName: "nemoclaw", workspace: "default" }; +}; + if (crashAfter === "credential-command-race") { const withMcpCredentialOwnershipLock = ownershipLocks.withMcpCredentialOwnershipLock; ownershipLocks.withMcpCredentialOwnershipLock = (operation) => { @@ -134,7 +144,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { if (crashAfter === "late-race" && providerGetCount === 3) mark("provider"); return marked("provider") ? { status: 0, stdout: "Id: " + (marked("foreign-provider") ? foreignProviderId : providerId) + "\nType: nemoclaw-mcp-v1\nResource version: " + providerVersion() + "\nCredential keys: FAKE_MCP_SECRET\n", stderr: "" } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "provider" && (args[1] === "create" || args[1] === "update")) { if (credentialProjectionScenario) { @@ -421,8 +431,19 @@ function initializeSandboxRegistry(home: string): void { ).toBe(0); } -function runAddProcess(home: string, crashAfter: CrashBoundary, includeSecret = true) { - const script = buildAddProcessScript(home, crashAfter, includeSecret); +function runAddProcess( + home: string, + crashAfter: CrashBoundary, + includeSecret = true, + forbidRuntimeSelection = false, +) { + const script = buildAddProcessScript( + home, + crashAfter, + includeSecret, + true, + forbidRuntimeSelection, + ); return spawnSync(process.execPath, ["-e", script], { cwd: process.cwd(), encoding: "utf8", @@ -529,11 +550,17 @@ const providerId = "11111111-2222-4333-8444-555555555555"; let observedProviderName = null; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); + gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, attempted: false, @@ -551,7 +578,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { observedProviderName = args[2]; return marked("provider") ? { status: 0, stdout: "Id: " + providerId + "\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: FAKE_MCP_SECRET\n", stderr: "" } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { observedProviderName = args[4]; @@ -581,7 +608,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { } if (args[0] === "provider" && args[1] === "delete") { if (!marked("provider")) { - return { status: 1, stdout: "", stderr: "NotFound: provider" }; + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } fs.rmSync(marker("provider"), { force: true }); if (crashAfterProviderDelete) process.exit(87); @@ -629,11 +656,17 @@ function runStatusProcess(home: string) { const script = String.raw` process.env.HOME = ${JSON.stringify(home)}; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); + gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, attempted: false, @@ -830,7 +863,7 @@ describe("MCP add crash consistency", () => { it("rejects a missing host credential before creating durable MCP state", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-add-missing-secret-")); try { - const result = runAddProcess(home, "", false); + const result = runAddProcess(home, "", false, true); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(2); expect(result.stderr).toContain("Host environment variable 'FAKE_MCP_SECRET' is required"); diff --git a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts index e7e6876fc26..22698ec1eaf 100644 --- a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts +++ b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts @@ -44,7 +44,14 @@ function runNodeScript( home: string, script: string, ): { status: number | null; stdout: string; stderr: string } { - const result = spawnSync(process.execPath, ["-e", script], { + const runtimeSelectionSetup = ` +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); +`; + const result = spawnSync(process.execPath, ["-e", `${runtimeSelectionSetup}\n${script}`], { cwd: process.cwd(), encoding: "utf8", env: { ...process.env, HOME: home, NODE_OPTIONS: sourceNodeOptions }, @@ -241,7 +248,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { stdout: "Id: " + expectedId + "\nType: nemoclaw-mcp-v1\nResource version: 4\nCredential keys: EXPECTED_TOKEN\n", stderr: "", } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { events.push(attached ? "provider:list:attached" : "provider:list:detached"); @@ -545,7 +552,6 @@ const state = require("./src/lib/actions/sandbox/mcp-bridge-state.js"); const adapters = require("./src/lib/actions/sandbox/mcp-bridge-adapters.js"); const policy = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); state.ensureSandboxGatewaySelected = async () => {}; -adapters.assertAgentMcpConfigMutationAllowed = () => {}; adapters.assertAgentMcpTeardownRuntimeCapability = () => {}; adapters.unregisterAgentAdapter = () => { throw new Error("adapter cleanup failed (injected)"); @@ -596,7 +602,6 @@ const state = require("./src/lib/actions/sandbox/mcp-bridge-state.js"); const adapters = require("./src/lib/actions/sandbox/mcp-bridge-adapters.js"); const policy = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); state.ensureSandboxGatewaySelected = async () => {}; -adapters.assertAgentMcpConfigMutationAllowed = () => {}; adapters.assertAgentMcpTeardownRuntimeCapability = () => {}; adapters.unregisterAgentAdapter = () => "removed"; policy.assertGeneratedPolicyMutationSafe = () => {}; diff --git a/test/mcp/mcp-provider-detach-retry.test.ts b/test/mcp/mcp-provider-detach-retry.test.ts index c96df17b054..4584c1e9e5e 100644 --- a/test/mcp/mcp-provider-detach-retry.test.ts +++ b/test/mcp/mcp-provider-detach-retry.test.ts @@ -16,7 +16,14 @@ const foreignId = "99999999-8888-4777-8666-555555555555"; let attached = true; let liveId = expectedId; let detachCalls = 0; -providerCommands.runOpenshellProviderCommand = (args) => { +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; +providerCommands.runOpenshellProviderCommand = (args, options) => { + if ( + options?.runtimeSelection?.gatewayName !== runtimeSelection.gatewayName || + options?.runtimeSelection?.workspace !== runtimeSelection.workspace + ) { + throw new Error("provider command did not retain the recorded runtime selection"); + } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return attached ? { status: 0, stdout: "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-mcp-fake nemoclaw-mcp-v1 1 0\n", stderr: "" } @@ -62,7 +69,7 @@ const entry = { let outcome = null; let message = null; try { - outcome = providerActions.detachProvider("alpha", entry); + outcome = providerActions.detachProvider("alpha", entry, { runtimeSelection }); } catch (error) { message = error.message; } diff --git a/test/mcp/mcp-provider-ownership.test.ts b/test/mcp/mcp-provider-ownership.test.ts index bd169423460..95142f493a8 100644 --- a/test/mcp/mcp-provider-ownership.test.ts +++ b/test/mcp/mcp-provider-ownership.test.ts @@ -19,12 +19,17 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; const foreignId = "99999999-8888-4777-8666-555555555555"; let liveId = expectedId; let attached = true; let policyState = "match"; const calls = []; +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => { throw new Error("persisted adapter must be used"); }; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, @@ -117,11 +122,16 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const expectedId = "11111111-2222-4333-8444-555555555555"; let providerExists = true; let attached = true; let policyState = "match"; const calls = []; +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => { throw new Error("persisted adapter must be used"); }; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, @@ -139,7 +149,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { stdout: "Id: " + expectedId + "\nType: nemoclaw-mcp-v1\nResource version: 4\nCredential keys: LD_PRELOAD\n", stderr: "", } - : { status: 1, stdout: "", stderr: "NotFound: provider" }; + : { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return { @@ -275,7 +285,12 @@ const agentDefs = require("./src/lib/agent/defs.js"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => ({ name: "openclaw", displayName: "OpenClaw", @@ -359,10 +374,11 @@ process.env.HOME = ${JSON.stringify(home)}; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; const attached = new Set(["alpha-mcp-fake", "alpha-mcp-second"]); +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); if (args[0] === "provider" && args[1] === "get") { - return { status: 1, stdout: "", stderr: "NotFound: provider" }; + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } if (args[0] === "sandbox" && args[1] === "provider" && args[2] === "list") { return attached.size > 0 @@ -391,17 +407,17 @@ const entry = { adapter: "mcporter", addedAt: "2026-06-01T00:00:00.000Z", }; -const before = providerActions.inspectMcpProviderAttachments("alpha"); -const firstOutcome = providerActions.detachMissingProviderReference("alpha", entry); -const afterFirst = providerActions.inspectMcpProviderAttachments("alpha"); +const before = providerActions.inspectMcpProviderAttachments("alpha", runtimeSelection); +const firstOutcome = providerActions.detachMissingProviderReference("alpha", entry, runtimeSelection); +const afterFirst = providerActions.inspectMcpProviderAttachments("alpha", runtimeSelection); const secondOutcome = providerActions.detachMissingProviderReference("alpha", { ...entry, server: "second", providerName: "alpha-mcp-second", providerId: "22222222-3333-4444-8555-666666666666", policyName: "mcp-bridge-second", -}); -const after = providerActions.inspectMcpProviderAttachments("alpha"); +}, runtimeSelection); +const after = providerActions.inspectMcpProviderAttachments("alpha", runtimeSelection); process.stdout.write(JSON.stringify({ before, firstOutcome, afterFirst, secondOutcome, after, calls })); `; const result = spawnSync(process.execPath, ["-e", script], { @@ -448,6 +464,7 @@ process.env.EXPECTED_TOKEN = "host-only-secret"; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; let resourceVersion = 4; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); if (args[0] === "provider" && args[1] === "get") { @@ -476,6 +493,7 @@ try { { allowExisting: true, expectedProviderId: "11111111-2222-4333-8444-555555555555", + runtimeSelection, }, ); } catch (error) { @@ -513,10 +531,11 @@ process.env.HOME = ${JSON.stringify(home)}; process.env.EXPECTED_TOKEN = "host-only-secret"; const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const calls = []; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args) => { calls.push(args.join(" ")); if (args[0] === "provider" && args[1] === "get") { - return { status: 1, stdout: "", stderr: "NotFound: provider" }; + return { status: 1, stdout: "", stderr: "provider '" + args[2] + "' not found" }; } throw new Error("unexpected call: " + args.join(" ")); }; @@ -530,6 +549,7 @@ try { allowExisting: true, expectedProviderId: "11111111-2222-4333-8444-555555555555", requireExisting: true, + runtimeSelection, }, ); } catch (error) { @@ -562,7 +582,12 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); +const providerInspection = require("./src/lib/actions/sandbox/mcp-bridge-provider-inspection.js"); const calls = []; +providerInspection.getMcpProviderInspectionRuntimeSelection = () => ({ + gatewayName: "nemoclaw", + workspace: "default", +}); agentDefs.loadAgent = () => { throw new Error("persisted adapter must be used"); }; gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, From bcb228c62a3736edb02906b2a33bed7103eb0dc4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 3 Sep 2026 15:38:17 -0700 Subject: [PATCH 5/8] fix(openshell): fail closed for selected exec Signed-off-by: Prekshi Vyas --- .../sandbox/process-recovery-temp-ssh.test.ts | 39 ++++++++++++++++--- src/lib/actions/sandbox/process-recovery.ts | 7 +++- .../docker-driver-gateway-env-service.test.ts | 6 +-- ...river-gateway-service-version-gate.test.ts | 2 +- src/lib/onboard/gateway/late-binding.test.ts | 18 ++++----- 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts index a928f998502..fad669039b4 100644 --- a/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts +++ b/src/lib/actions/sandbox/process-recovery-temp-ssh.test.ts @@ -8,7 +8,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const captureSandboxSshConfig = vi.hoisted(() => vi.fn()); -const dockerSpawnSync = vi.hoisted(() => vi.fn()); +const executePrivilegedSandboxCommand = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -25,9 +25,9 @@ vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({ runOpenshell: vi.fn(), })); -vi.mock("../../adapters/docker/exec", async (importOriginal) => ({ - ...(await importOriginal()), - dockerSpawnSync, +vi.mock("../../sandbox/privileged-exec", async (importOriginal) => ({ + ...(await importOriginal()), + executePrivilegedSandboxCommand, })); vi.mock("../../runner", () => ({ @@ -137,7 +137,6 @@ describe("executeSandboxCommand temp SSH config", () => { expect( executeSandboxExecCommand("alpha", "printf revision-1", undefined, { - allowLocalDockerFallback: false, runtimeSelection: { gatewayName: "nemoclaw-8091", localTlsDir: "/authority/tls", @@ -159,7 +158,35 @@ describe("executeSandboxCommand temp SSH config", () => { 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(); + expect(executePrivilegedSandboxCommand).not.toHaveBeenCalled(); + }); + + it("fails closed instead of using a same-name local sandbox for selected exec (#10514)", () => { + vi.mocked(spawnSync).mockReturnValue({ + status: 1, + stdout: "selected gateway unavailable\n", + stderr: "", + pid: 1234, + output: [], + signal: null, + }); + executePrivilegedSandboxCommand.mockReturnValue({ + status: 0, + stdout: "__NEMOCLAW_SANDBOX_EXEC_STARTED__\nlocal-same-name\n", + stderr: "", + }); + + expect( + executeSandboxExecCommand("alpha", "printf selected", undefined, { + allowLocalDockerFallback: true, + runtimeSelection: { + gatewayName: "recorded-gateway", + localTlsDir: "/authority/tls", + workspace: "default", + }, + }), + ).toBeNull(); + expect(executePrivilegedSandboxCommand).not.toHaveBeenCalled(); }); it("uses the exact legacy alias while backing up a pre-upgrade sandbox", () => { diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 9021bd98d8f..4e607cdb7da 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -248,7 +248,12 @@ export function executeSandboxExecCommand( timeout, { ...transportOptions, - ...(runtimeSelection ? { gatewayName: runtimeSelection.gatewayName } : {}), + ...(runtimeSelection + ? { + allowLocalDockerFallback: false, + gatewayName: runtimeSelection.gatewayName, + } + : {}), ...(runtimeEnv ? { runtimeEnv } : {}), }, ); diff --git a/src/lib/onboard/docker-driver-gateway-env-service.test.ts b/src/lib/onboard/docker-driver-gateway-env-service.test.ts index 52a09485303..81a74dfb29f 100644 --- a/src/lib/onboard/docker-driver-gateway-env-service.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env-service.test.ts @@ -63,7 +63,7 @@ describe("package-managed Docker-driver gateway env service", () => { }), ).resolves.toBe(true); - expect(observedEnv).toBe(selectedEnv); + expect(observedEnv).toEqual(selectedEnv); expect(observedEnv).toEqual( expect.objectContaining({ OPENSHELL_GATEWAY: "nemoclaw", @@ -262,9 +262,7 @@ describe("package-managed Docker-driver gateway env service", () => { ).toThrow(/not supported for the OpenShell Docker-driver gateway/); }); - it.each( - ["signing_key_path", "public_key_path", "kid_path", "gateway_id", "ttl_secs"], - )( + it.each(["signing_key_path", "public_key_path", "kid_path", "gateway_id", "ttl_secs"])( "rejects incomplete gateway JWT config before writing env or starting the service [%s] (#6903)", (key) => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-")); diff --git a/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts b/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts index da81bacfdc8..486c87a50cd 100644 --- a/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts +++ b/src/lib/onboard/docker-driver-gateway-service-version-gate.test.ts @@ -95,7 +95,7 @@ describe("package-managed gateway version gate (#8094)", () => { }); expect(verdict.supported).toBe(true); - expect(observedEnv).toBe(selectedEnv); + expect(observedEnv).toEqual(selectedEnv); expect(observedEnv?.OPENSHELL_GATEWAY).toBe("nemoclaw"); expect(observedEnv?.OPENSHELL_WORKSPACE).toBe("default"); expect(observedEnv?.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); diff --git a/src/lib/onboard/gateway/late-binding.test.ts b/src/lib/onboard/gateway/late-binding.test.ts index 3703714694b..ca99db54b22 100644 --- a/src/lib/onboard/gateway/late-binding.test.ts +++ b/src/lib/onboard/gateway/late-binding.test.ts @@ -66,8 +66,8 @@ describe("gateway lifecycle late binding", () => { const runCaptureOpenshell = vi.fn( (_args: string[], _options?: Record) => "Connected", ); - const runOpenshell = vi.fn( - (_args: string[], _options?: Record) => runResult(), + const runOpenshell = vi.fn((_args: string[], _options?: Record) => + runResult(), ); const runQuietOpenshell = vi.fn(() => runResult()); const registration = createGatewayRegistration({ @@ -106,9 +106,9 @@ describe("gateway lifecycle late binding", () => { }), replaceEnv: true, }); - expect(runCaptureOpenshell.mock.calls[0]?.[1]?.env).toBe(selectedOptions?.env); - expect(runCaptureOpenshell.mock.calls[1]?.[1]?.env).toBe(selectedOptions?.env); - expect(runCaptureOpenshell.mock.calls[2]?.[1]?.env).toBe(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[0]?.[1]?.env).toEqual(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[1]?.[1]?.env).toEqual(selectedOptions?.env); + expect(runCaptureOpenshell.mock.calls[2]?.[1]?.env).toEqual(selectedOptions?.env); expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); expect(selectedOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_TLS"); @@ -194,9 +194,7 @@ describe("gateway lifecycle late binding", () => { ).toBeNull(); return { OPENSHELL_SERVER_PORT: String(port) }; }); - const runCaptureOpenshell = vi.fn( - (_args: string[], _options?: Record) => "", - ); + const runCaptureOpenshell = vi.fn((_args: string[], _options?: Record) => ""); const runtimeIdentitySpy = vi .spyOn(dockerDriverGatewayLaunch, "buildDockerDriverGatewayRuntimeIdentity") .mockImplementation((options) => ({ @@ -292,7 +290,7 @@ describe("gateway lifecycle late binding", () => { ); const runtimeIdentityOptions = runtimeIdentitySpy.mock.calls[0]?.[0]; const managedOptions = managedStart.mock.calls[0]?.[0]; - expect(runtimeIdentityOptions?.env).toBe(managedOptions?.env); + expect(runtimeIdentityOptions?.env).toEqual(managedOptions?.env); expect(runtimeIdentityOptions?.env).toEqual( expect.objectContaining({ OPENSHELL_GATEWAY: "resumed", @@ -317,7 +315,7 @@ describe("gateway lifecycle late binding", () => { }), replaceEnv: true, }); - expect(statusOptions?.env).toBe(versionOptions?.env); + expect(statusOptions?.env).toEqual(versionOptions?.env); expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); expect(versionOptions?.env).not.toHaveProperty("OPENSHELL_DISABLE_TLS"); From 1cb9f7728ceb4f4e2faa91b281247dd2be5156f1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 3 Sep 2026 16:40:34 -0700 Subject: [PATCH 6/8] fix(mcp): pin snapshot projection repair target Signed-off-by: Prekshi Vyas --- ...ge-adapter-deepagents-registration.test.ts | 49 +++++++++++++------ ...-bridge-adapter-deepagents-registration.ts | 7 ++- .../snapshot-restore-lifecycle.test.ts | 18 +++++-- src/lib/actions/sandbox/snapshot.ts | 7 ++- .../actions/sandbox/snapshot/dependencies.ts | 1 + 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts index 157d5c88ec0..e02351065e6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts @@ -17,6 +17,7 @@ vi.mock("./process-recovery", () => ({ })); const executeSandboxCommandMock = vi.mocked(executeSandboxCommand); +const runtimeSelection = { gatewayName: "nemoclaw-8081", workspace: "review-workspace" }; function jiraEntry(): McpBridgeEntry { return { @@ -350,9 +351,15 @@ describe("Deep Agents MCP config adapter registration", () => { .mockReturnValueOnce({ status: 0, stdout: "registered\n", stderr: "" }) .mockReturnValueOnce({ status: 0, stdout: "registered\n", stderr: "" }); - restoreDeepAgentsManagedMcpProjection("alpha", entries); + restoreDeepAgentsManagedMcpProjection("alpha", entries, runtimeSelection); expect(executeSandboxCommandMock).toHaveBeenCalledTimes(5); + expect( + executeSandboxCommandMock.mock.calls.every((call) => { + const options = call[2]; + return typeof options === "object" && options?.runtimeSelection === runtimeSelection; + }), + ).toBe(true); const commands = executeSandboxCommandMock.mock.calls.map(([, command]) => command); expect(commands[1]).toBe("/usr/local/bin/deepagents-code --nemoclaw-mcp-capability"); expect(commands[2]).toContain("github"); @@ -372,7 +379,7 @@ describe("Deep Agents MCP config adapter registration", () => { }) .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); - restoreDeepAgentsManagedMcpProjection("alpha", []); + restoreDeepAgentsManagedMcpProjection("alpha", [], runtimeSelection); expect(executeSandboxCommandMock).toHaveBeenCalledTimes(3); expect(executeSandboxCommandMock.mock.calls[2]?.[1]).toContain('\\"expectedServers\\":{}'); @@ -385,7 +392,7 @@ describe("Deep Agents MCP config adapter registration", () => { stderr: "", }); - expect(() => restoreDeepAgentsManagedMcpProjection("alpha", [])).toThrow( + expect(() => restoreDeepAgentsManagedMcpProjection("alpha", [], runtimeSelection)).toThrow( "Could not identify the managed Deep Agents MCP runtime.", ); expect(executeSandboxCommandMock).toHaveBeenCalledOnce(); @@ -398,7 +405,7 @@ describe("Deep Agents MCP config adapter registration", () => { stderr: "", }); - restoreDeepAgentsManagedMcpProjection("alpha", [baseEntry]); + restoreDeepAgentsManagedMcpProjection("alpha", [baseEntry], runtimeSelection); expect(executeSandboxCommandMock).toHaveBeenCalledOnce(); }); @@ -412,7 +419,9 @@ describe("Deep Agents MCP config adapter registration", () => { stderr: "", }); - expect(() => restoreDeepAgentsManagedMcpProjection("alpha", [baseEntry])).toThrow( + expect(() => + restoreDeepAgentsManagedMcpProjection("alpha", [baseEntry], runtimeSelection), + ).toThrow( "does not contain managed MCP capability v2", ); expect(executeSandboxCommandMock).toHaveBeenCalledTimes(2); @@ -448,9 +457,13 @@ describe("Deep Agents MCP config adapter registration", () => { }) .mockReturnValueOnce({ status: 2, stdout: "", stderr: "projection is unsafe" }); - expect(() => restoreDeepAgentsManagedMcpProjection("alpha", [baseEntry, jiraEntry()])).toThrow( - "projection is unsafe", - ); + expect(() => + restoreDeepAgentsManagedMcpProjection( + "alpha", + [baseEntry, jiraEntry()], + runtimeSelection, + ), + ).toThrow("projection is unsafe"); expect(executeSandboxCommandMock).toHaveBeenCalledTimes(3); }); @@ -466,9 +479,13 @@ describe("Deep Agents MCP config adapter registration", () => { .mockReturnValueOnce({ status: 0, stdout: "registered\n", stderr: "" }) .mockReturnValueOnce({ status: 0, stdout: "mismatch\n", stderr: "" }); - expect(() => restoreDeepAgentsManagedMcpProjection("alpha", [baseEntry, jiraEntry()])).toThrow( - "config verification failed after adding 'jira': mismatch", - ); + expect(() => + restoreDeepAgentsManagedMcpProjection( + "alpha", + [baseEntry, jiraEntry()], + runtimeSelection, + ), + ).toThrow("config verification failed after adding 'jira': mismatch"); }); it.each([ @@ -489,9 +506,13 @@ describe("Deep Agents MCP config adapter registration", () => { entry: { ...baseEntry, agent: "openclaw" }, }, ])("rejects $label before running a sandbox command (#10756)", ({ entry }) => { - expect(() => restoreDeepAgentsManagedMcpProjection("alpha", [entry as McpBridgeEntry])).toThrow( - "requires Deep Agents registry entries", - ); + expect(() => + restoreDeepAgentsManagedMcpProjection( + "alpha", + [entry as McpBridgeEntry], + runtimeSelection, + ), + ).toThrow("requires Deep Agents registry entries"); expect(executeSandboxCommandMock).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index a19207aca44..6201f017920 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -278,6 +278,7 @@ export function registerDeepAgentsAdapter( export function restoreDeepAgentsManagedMcpProjection( sandboxName: string, entries: readonly McpBridgeEntry[], + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { const managedEntries = [...entries].sort((left, right) => left.server.localeCompare(right.server), @@ -299,6 +300,7 @@ export function restoreDeepAgentsManagedMcpProjection( commandEntry, buildDeepAgentsMcpRuntimeKindCommand(), "Could not identify the managed Deep Agents MCP runtime.", + runtimeSelection, ) .trim() .split(/\r?\n/u) @@ -307,7 +309,7 @@ export function restoreDeepAgentsManagedMcpProjection( if (runtimeKind !== "v2") { throw new McpBridgeError("Could not identify the managed Deep Agents MCP runtime."); } - assertDeepAgentsMcpMutationRuntimeCapability(sandboxName); + assertDeepAgentsMcpMutationRuntimeCapability(sandboxName, runtimeSelection); runDeepAgentsAdapterCommand( sandboxName, commandEntry, @@ -315,8 +317,9 @@ export function restoreDeepAgentsManagedMcpProjection( resetManagedProjection: true, }), "Deep Agents Code managed MCP projection repair failed.", + runtimeSelection, ); for (const managedEntry of managedEntries) { - verifyDeepAgentsAdapterRegistration(sandboxName, managedEntry); + verifyDeepAgentsAdapterRegistration(sandboxName, managedEntry, runtimeSelection); } } diff --git a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts index da6847a10c6..68a3955613e 100644 --- a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts @@ -111,7 +111,11 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { f.getSandboxMock.mockReturnValue({ name: "alpha", agent: "langchain-deepagents-code" }); await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(f.restoreDeepAgentsManagedMcpProjectionMock).toHaveBeenCalledWith("alpha", []); + expect(f.restoreDeepAgentsManagedMcpProjectionMock).toHaveBeenCalledWith( + "alpha", + [], + expect.objectContaining({ gatewayName: "nemoclaw", workspace: "default" }), + ); expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); f.restoreDeepAgentsManagedMcpProjectionMock.mockClear(); @@ -202,10 +206,14 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(f.restoreDeepAgentsManagedMcpProjectionMock).toHaveBeenCalledWith("alpha", [ - expect.objectContaining({ server: "github" }), - expect.objectContaining({ server: "jira" }), - ]); + expect(f.restoreDeepAgentsManagedMcpProjectionMock).toHaveBeenCalledWith( + "alpha", + [ + expect.objectContaining({ server: "github" }), + expect.objectContaining({ server: "jira" }), + ], + expect.objectContaining({ gatewayName: "nemoclaw", workspace: "default" }), + ); expect(f.lifecycleMock.events).toEqual(["restore-mcp-projection", "restore-snapshot-state"]); }); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index a1babbb706a..9db833f17f8 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -93,6 +93,7 @@ import { createSnapshotCloneLifecycle, confirmSandboxRuntimeRestore, fingerprintSandboxLiveIdentity, + getMcpProviderInspectionRuntimeSelection, isSandboxPolicyCredentialFree, type PreparedHostLocalInferenceAuthority, type PreparedSandboxRuntimeRestore, @@ -1556,7 +1557,11 @@ async function runSnapshotRestoreUnlocked( } if (repairsManagedDeepAgentsProjection) { try { - restoreDeepAgentsManagedMcpProjection(targetSandbox, managedDeepAgentsEntries); + restoreDeepAgentsManagedMcpProjection( + targetSandbox, + managedDeepAgentsEntries, + getMcpProviderInspectionRuntimeSelection(snapshotTarget), + ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); const recoveryCommand = deepAgentsManagedProjectionRecoveryCommand(targetSandbox); diff --git a/src/lib/actions/sandbox/snapshot/dependencies.ts b/src/lib/actions/sandbox/snapshot/dependencies.ts index 25409f334ac..30cbb102ed3 100644 --- a/src/lib/actions/sandbox/snapshot/dependencies.ts +++ b/src/lib/actions/sandbox/snapshot/dependencies.ts @@ -23,6 +23,7 @@ export type { export { backupSandboxStateWithManagedAuthority } from "./backup-authority"; export { createSnapshotCloneLifecycle, fingerprintSandboxLiveIdentity } from "./clone-lifecycle"; export { restoreDeepAgentsManagedMcpProjection } from "../mcp-bridge-adapter-deepagents-registration"; +export { getMcpProviderInspectionRuntimeSelection } from "../mcp-bridge-provider-inspection"; export type { ManagedCloneProviderBinding, ManagedCloneProviderCleanupResult, From 95312e6a98271f4deecbd4b05296b48fefa32ad5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 3 Sep 2026 18:03:41 -0700 Subject: [PATCH 7/8] refactor(destroy): pin recorded runtime target (#10816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Outcome Keeps an MCP-bearing sandbox destroy operation on one recorded OpenShell runtime target from confirmation through final cleanup. A hostile or stale ambient selector cannot redirect the session check, policy work, provider cleanup, sandbox deletion, or gateway cleanup decision. ## Reason Destroy combines several read, mutation, rollback, and cleanup phases. Freezing only the MCP provider call is insufficient if a later phase can inspect or delete a same-named sandbox through a different gateway or workspace. ### Related issues Refs #10514 Refs #9833 ## Changes - Freeze the persisted MCP runtime selection before destroy confirmation and reuse it throughout preflight, Shields, provider, identity, delete, and cleanup phases. - Bind sandbox list, identity, wipe, deletion, final-live-sandbox, and gateway-cleanup commands to that exact target. - Preserve forced OpenClaw adapter-scrub behavior and abort recovery while carrying the same runtime selection. - Require the recorded selection at MCP configuration-mutation guards now owned by the complete destroy transaction. - Add hostile-ambient and same-selection regression coverage across success, refusal, and recovery paths. ## Verification - `npm run typecheck:cli` — passed. - Focused destroy CLI suites — 147 tests passed. - `npm run checks:repository` — passed. - Normal pre-push TypeScript hook — passed. - `git diff --check` and a credential review of the diff — passed; no secrets, API keys, or credentials are included. ## Review notes Stack 3 of 4 extracted from #10621. Depends on #10815; review this PR against `codex/10621-mcp-target`. --- Signed-off-by: Apurv Kumaria ## Summary by CodeRabbit - **Bug Fixes** - Sandbox destruction now consistently uses the selected OpenShell gateway, workspace, and TLS configuration across session checks, cleanup, and deletion. - Prevents conflicting environment settings or gateway changes from affecting destructive operations. - Preserves sandbox ownership state when cleanup cannot safely proceed. - Reports the resolved runtime selection after successful destruction. - **Tests** - Added regression coverage for runtime selection, gateway cleanup, environment overrides, and pending sandbox deletion scenarios. --------- Signed-off-by: Apurv Kumaria Signed-off-by: Prekshi Vyas Co-authored-by: Prekshi Vyas --- .../sandbox/destroy-confirmation.test.ts | 19 +++ .../actions/sandbox/destroy-confirmation.ts | 14 +- src/lib/actions/sandbox/destroy-execution.ts | 93 +++++++++++-- .../destroy-flow-runtime-selection.test.ts | 130 ++++++++++++++++++ src/lib/actions/sandbox/destroy-flow.test.ts | 20 ++- .../sandbox/destroy-gateway-cleanup.test.ts | 24 ++++ .../sandbox/destroy-gateway-cleanup.ts | 2 + .../destroy-host-local-inference.test.ts | 39 +++++- src/lib/actions/sandbox/destroy-preflight.ts | 71 +++++++++- src/lib/actions/sandbox/destroy.ts | 36 ++++- .../openshell/sandbox-identity-cli.test.ts | 43 +++++- .../openshell/sandbox-identity-cli.ts | 23 +++- .../adapters/openshell/sanitized-capture.ts | 2 + src/lib/state/sandbox-session.test.ts | 55 +++++++- src/lib/state/sandbox-session.ts | 26 +++- test/helpers/destroy-flow-test-assertions.ts | 24 +++- test/helpers/destroy-flow-test-harness.ts | 16 +++ 17 files changed, 591 insertions(+), 46 deletions(-) create mode 100644 src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts diff --git a/src/lib/actions/sandbox/destroy-confirmation.test.ts b/src/lib/actions/sandbox/destroy-confirmation.test.ts index 2d601d83c80..08344310ace 100644 --- a/src/lib/actions/sandbox/destroy-confirmation.test.ts +++ b/src/lib/actions/sandbox/destroy-confirmation.test.ts @@ -33,6 +33,25 @@ describe("destroy confirmation", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("uses the recorded OpenShell target for active-session detection (#10514)", async () => { + const createSessionDeps = vi.spyOn(sandboxSession, "createSystemDeps"); + stubActiveSessions([]); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", + localTlsDir: "/authority/tls", + }; + + await expect( + confirmSandboxDestroy("test-sb", { yes: true }, runtimeSelection), + ).resolves.toBe(true); + + expect(createSessionDeps).toHaveBeenCalledWith("/usr/bin/openshell", { + runtimeSelection, + }); + }); + it("warns about active sessions when --force skips the prompt (#9855)", async () => { stubActiveSessions([4242, 4243]); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/src/lib/actions/sandbox/destroy-confirmation.ts b/src/lib/actions/sandbox/destroy-confirmation.ts index 50c625f4503..6c05fb4c6a8 100644 --- a/src/lib/actions/sandbox/destroy-confirmation.ts +++ b/src/lib/actions/sandbox/destroy-confirmation.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { resolveOpenshell } from "../../adapters/openshell/resolve"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { R, YW } from "../../cli/terminal-style"; import { prompt as askPrompt } from "../../credentials/store"; import type { DestroySandboxOptions } from "../../domain/lifecycle/options"; @@ -12,11 +13,17 @@ import { type SandboxSession, } from "../../state/sandbox-session"; -function findActiveSandboxSessions(sandboxName: string): SandboxSession[] { +function findActiveSandboxSessions( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): SandboxSession[] { const opsBin = resolveOpenshell(); if (!opsBin) return []; try { - const result = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBin)); + const result = getActiveSandboxSessions( + sandboxName, + createSessionDeps(opsBin, runtimeSelection ? { runtimeSelection } : {}), + ); return result.detected ? result.sessions : []; } catch { return []; @@ -47,8 +54,9 @@ export function assertSandboxDestroyCommandAvailable(sandboxName: string): void export async function confirmSandboxDestroy( sandboxName: string, options: DestroySandboxOptions, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise { - const activeSessions = findActiveSandboxSessions(sandboxName); + const activeSessions = findActiveSandboxSessions(sandboxName, runtimeSelection); // #9855: --yes/--force waives the confirmation prompt, not the notice that // this destroy is about to break somebody else's live SSH session. Without // this the operator sees no warning and the connected terminal just gets a diff --git a/src/lib/actions/sandbox/destroy-execution.ts b/src/lib/actions/sandbox/destroy-execution.ts index 054ecb19e5b..891d6c82a40 100644 --- a/src/lib/actions/sandbox/destroy-execution.ts +++ b/src/lib/actions/sandbox/destroy-execution.ts @@ -3,6 +3,8 @@ import { isDeepStrictEqual } from "node:util"; +import { buildSelectedOpenShellSubprocessEnv } from "../../adapters/openshell/command-argv"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/sandbox-identity-cli"; import { R, YW } from "../../cli/terminal-style"; @@ -66,6 +68,7 @@ type SandboxDestroyExecutionInput = { getSandbox?: (sandboxName: string) => SandboxEntry | null; listSandboxes?: () => { sandboxes: SandboxEntry[] }; runOpenshell: DestroyRunOpenshell; + mcpRuntimeSelection?: McpDestroyPreparation["runtimeSelection"]; sandbox: SandboxEntry | null; sandboxConfirmedAbsent: boolean; sandboxName: string; @@ -94,6 +97,7 @@ export type SandboxDestroyExecutionResult = deleteResult: ReturnType; detachOutcome: DetachSandboxProvidersResult; forcedLocalCleanup: boolean; + runtimeSelection?: OpenShellRuntimeSelection; /** Common lifecycle conclusively retired this row's explicit llama.cpp claim. */ commonLlamaCppAuthorityRetired?: true; } @@ -111,13 +115,16 @@ export type SandboxDestroyExecutionResult = deleteConfirmed?: boolean; }; -function emptyMcpDestroyPreparation(): McpDestroyPreparation { +function emptyMcpDestroyPreparation( + runtimeSelection?: McpDestroyPreparation["runtimeSelection"], +): McpDestroyPreparation { return { entries: [], detachedProviderEntries: [], scrubbedAdapterEntries: [], destroyAlreadyPrepared: false, destroyAlreadyPending: false, + ...(runtimeSelection ? { runtimeSelection } : {}), }; } @@ -126,13 +133,20 @@ async function prepareMcpDestroy( sandbox: SandboxEntry | null, sandboxConfirmedAbsent: boolean, force: boolean, + runtimeSelection?: McpDestroyPreparation["runtimeSelection"], ): Promise { if (Object.keys(sandbox?.mcp?.bridges ?? {}).length === 0) { - return emptyMcpDestroyPreparation(); + return emptyMcpDestroyPreparation(runtimeSelection); } const preparation = sandboxConfirmedAbsent - ? await prepareMcpBridgesForAbsentSandboxDestroy(sandboxName, { force }) - : await prepareMcpBridgesForDestroy(sandboxName, { force }); + ? await prepareMcpBridgesForAbsentSandboxDestroy(sandboxName, { + force, + ...(runtimeSelection ? { runtimeSelection } : {}), + }) + : await prepareMcpBridgesForDestroy(sandboxName, { + force, + ...(runtimeSelection ? { runtimeSelection } : {}), + }); if (sandboxConfirmedAbsent && preparation.entries.length > 0) { console.warn( ` ${YW}⚠${R} Sandbox '${sandboxName}' is already absent, so its retained-volume MCP adapter entry cannot be scrubbed in place. Exact OpenShell providers will be deleted so any stale credential placeholder cannot authenticate; same-name onboarding may need to replace stale MCP adapter config.`, @@ -145,9 +159,13 @@ function wipeLiveSandbox( sandboxName: string, sandboxRuntimeConfirmedAbsent: boolean, deps: NonNullable = {}, + selectedRunOpenshell?: DestroyRunOpenshell, ): void { if (sandboxRuntimeConfirmedAbsent) return; - (deps.wipeSandboxState ?? wipeSandboxState)(sandboxName); + (deps.wipeSandboxState ?? wipeSandboxState)( + sandboxName, + selectedRunOpenshell ? { runOpenshell: selectedRunOpenshell } : {}, + ); } async function restoreMcpAfterDeleteAbort( @@ -186,6 +204,7 @@ export async function executeSandboxDestroy({ getSandbox, listSandboxes, runOpenshell, + mcpRuntimeSelection, sandbox, sandboxConfirmedAbsent, sandboxName, @@ -199,6 +218,7 @@ export async function executeSandboxDestroy({ deps = {}, }: SandboxDestroyExecutionInput): Promise { return withMcpLifecycleLock(sandboxName, async () => { + let destroyRuntimeSelection = mcpRuntimeSelection; type IdentityContinuity = | { status: "match" } | { status: "changed"; subject?: string } @@ -252,6 +272,7 @@ export async function executeSandboxDestroy({ const liveFingerprint = inspectIdentity({ sandboxName, gatewayName: pendingCreateIdentity.gatewayName, + ...(destroyRuntimeSelection ? { runtimeSelection: destroyRuntimeSelection } : {}), }); if ( liveFingerprint !== pendingCreateIdentity.sandboxIdentityFingerprint || @@ -393,7 +414,13 @@ export async function executeSandboxDestroy({ } let mcpPreparation: McpDestroyPreparation; try { - mcpPreparation = await prepareMcpDestroy(sandboxName, sandbox, sandboxConfirmedAbsent, force); + mcpPreparation = await prepareMcpDestroy( + sandboxName, + sandbox, + sandboxConfirmedAbsent, + force, + mcpRuntimeSelection, + ); } catch (error) { if (error instanceof McpBridgeError) { return { @@ -407,6 +434,28 @@ export async function executeSandboxDestroy({ } throw error; } + if ( + mcpRuntimeSelection && + !isDeepStrictEqual(mcpPreparation.runtimeSelection, mcpRuntimeSelection) + ) { + return { + ok: false as const, + deleteOutput: "MCP destroy target changed after preflight.", + exitCode: 1, + gatewayUnreachable: false, + hostLocalInferenceOwnershipRequiresGateway: false, + mcpOwnershipRequiresGateway: false, + }; + } + destroyRuntimeSelection = mcpPreparation.runtimeSelection; + const selectedRunOpenshell: DestroyRunOpenshell = destroyRuntimeSelection + ? (args, options = {}) => + runOpenshell(args, { + ...options, + env: buildSelectedOpenShellSubprocessEnv(destroyRuntimeSelection!), + replaceEnv: true, + }) + : runOpenshell; // Prepared-only/incomplete adds have no external resources and are safely // discarded during preparation. Remaining entries are the durable exact // provider ownership manifest and must survive an unconfirmed delete. @@ -461,7 +510,7 @@ export async function executeSandboxDestroy({ expectedContainerIdentities?.length === 0 || (expectedContainerIdentities === undefined && sandboxConfirmedAbsent); try { - wipeLiveSandbox(sandboxName, sandboxRuntimeConfirmedAbsent, deps); + wipeLiveSandbox(sandboxName, sandboxRuntimeConfirmedAbsent, deps, selectedRunOpenshell); } catch (error) { const mcpRecoveryFailure = await restoreMcpForAbort(); const workspaceTimedOut = error instanceof SandboxWorkspaceCleanupTimeoutError; @@ -479,7 +528,10 @@ export async function executeSandboxDestroy({ }; } const detachProviders = (): DetachSandboxProvidersResult => - runSandboxProviderPreDeleteCleanup(sandboxName, { runOpenshell, redact }); + runSandboxProviderPreDeleteCleanup(sandboxName, { + runOpenshell: selectedRunOpenshell, + redact, + }); const preProviderContinuity = inspectIdentityContinuity(); if (preProviderContinuity.status !== "match") { const mcpRecoveryFailure = await restoreMcpForAbort(); @@ -512,15 +564,33 @@ export async function executeSandboxDestroy({ ` Managed inference cleanup and workspace wipe may already have run; inspect those resources before retrying.${detachedDetail}`, ); } - const deleteArgs = pendingCreateIdentity - ? ["sandbox", "delete", "-g", pendingCreateIdentity.gatewayName, sandboxName] + if ( + pendingCreateIdentity && + destroyRuntimeSelection && + pendingCreateIdentity.gatewayName !== destroyRuntimeSelection.gatewayName + ) { + const mcpRecoveryFailure = await restoreMcpForAbort(); + return { + ok: false as const, + deleteOutput: "Sandbox delete target changed during destroy preparation.", + exitCode: 1, + gatewayUnreachable: false, + hostLocalInferenceOwnershipRequiresGateway: false, + mcpOwnershipRequiresGateway: false, + mcpRecoveryFailure, + }; + } + const deleteGatewayName = + pendingCreateIdentity?.gatewayName ?? destroyRuntimeSelection?.gatewayName; + const deleteArgs = deleteGatewayName + ? ["sandbox", "delete", "-g", deleteGatewayName, sandboxName] : ["sandbox", "delete", sandboxName]; // A successful preflight absence is already the required OpenShell // lifecycle proof. Do not issue a later mutable-name delete that could // target a same-name replacement created after that observation. const deleteResult: ReturnType = sandboxConfirmedAbsent ? { status: 0, stdout: "", stderr: "" } - : runOpenshell(deleteArgs, { + : selectedRunOpenshell(deleteArgs, { ignoreError: true, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"], @@ -679,6 +749,7 @@ export async function executeSandboxDestroy({ deleteResult, alreadyGone, forcedLocalCleanup, + ...(destroyRuntimeSelection ? { runtimeSelection: destroyRuntimeSelection } : {}), ...(commonLlamaCppAuthorityRetired ? { commonLlamaCppAuthorityRetired: true as const } : {}), }; }); diff --git a/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts b/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts new file mode 100644 index 00000000000..5f75ed24099 --- /dev/null +++ b/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts @@ -0,0 +1,130 @@ +// 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 { expectMcpFinalizeAfterDelete } from "../../../../test/helpers/destroy-flow-test-assertions"; +import { + createDestroyHarness, + resetDestroyModuleCache, +} from "../../../../test/helpers/destroy-flow-test-harness"; + +describe("destroySandbox OpenShell runtime selection", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + resetDestroyModuleCache(); + }); + + it("pins MCP detach, delete, finalize, and cleanup to the selected target", async () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + const runtimeSelection = { + gatewayName: "nemoclaw-19080", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const harness = createDestroyHarness({ + mcpServers: ["github", "slack"], + mcpRuntimeSelection: runtimeSelection, + }); + + await harness.destroySandbox("alpha", { yes: true, cleanupGateway: true }); + + expectMcpFinalizeAfterDelete(harness); + expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { + force: false, + runtimeSelection, + }); + const preflightRunner = harness.selectGatewaySpy.mock.calls[0]?.[2] as + | ((args: string[], opts?: Record) => unknown) + | undefined; + expect(preflightRunner).toBeTypeOf("function"); + preflightRunner?.(["gateway", "info", "nemoclaw-19080"]); + expect(harness.runOpenshellSpy).toHaveBeenCalledWith( + ["gateway", "info", "nemoclaw-19080"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + const preflightSandboxListOptions = harness.runOpenshellSpy.mock.calls.find( + ([args]) => args[0] === "sandbox" && args[1] === "list", + )?.[1] as { env?: Record; replaceEnv?: boolean } | undefined; + expect(preflightSandboxListOptions).toMatchObject({ + replaceEnv: true, + env: { + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }, + }); + expect(preflightSandboxListOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(harness.runOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "delete", "-g", "nemoclaw-19080", "alpha"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + const deleteOptions = harness.runOpenshellSpy.mock.calls.find( + ([args]) => args[0] === "sandbox" && args[1] === "delete" && args.at(-1) === "alpha", + )?.[1] as { env?: Record } | undefined; + expect(deleteOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + + const providerDeleteOptions = harness.runOpenshellSpy.mock.calls.find( + ([args]) => args[0] === "provider" && args[1] === "delete", + )?.[1] as { env?: Record; replaceEnv?: boolean } | undefined; + expect(providerDeleteOptions).toMatchObject({ + replaceEnv: true, + env: { + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }, + }); + expect(providerDeleteOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + + const finalSandboxListOptions = [...harness.captureOpenshellSpy.mock.calls] + .reverse() + .find( + (call) => Array.isArray(call[0]) && call[0][0] === "sandbox" && call[0][1] === "list", + )?.[1] as { env?: Record; replaceEnv?: boolean } | undefined; + expect(finalSandboxListOptions).toMatchObject({ + replaceEnv: true, + env: { + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }, + }); + expect(finalSandboxListOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + + const cleanupRunner = harness.cleanupGatewaySpy.mock.calls[0]?.[1] as + | ((args: string[], opts?: Record) => unknown) + | undefined; + expect(cleanupRunner).toBeTypeOf("function"); + cleanupRunner?.(["gateway", "info", "nemoclaw-19080"]); + expect(harness.runOpenshellSpy).toHaveBeenLastCalledWith( + ["gateway", "info", "nemoclaw-19080"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + }, 30_000); +}); diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 71e9bbc9624..d139ea9f436 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -1152,7 +1152,7 @@ describe("destroySandbox flow", () => { ); }); - it("does not require mutable Hermes config for a prepared-only add", async () => { + it("does not resolve runtime authority for a prepared-only add", async () => { const harness = createDestroyHarness({ agent: "hermes", mcpAddState: "prepared", @@ -1161,7 +1161,10 @@ describe("destroySandbox flow", () => { await expect(harness.destroySandbox("alpha", { yes: true })).resolves.toBeUndefined(); - expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { force: false }); + expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { + force: false, + }); + expect(harness.mcpRuntimeSelectionSpy).not.toHaveBeenCalled(); }); it("does not require mutable Hermes config for absent-sandbox cleanup", async () => { @@ -1175,6 +1178,10 @@ describe("destroySandbox flow", () => { expect(harness.prepareMcpBridgesForAbsentSandboxDestroySpy).toHaveBeenCalledWith("alpha", { force: false, + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), }); }); @@ -1437,14 +1444,15 @@ describe("destroySandbox flow", () => { expect(harness.prepareMcpBridgesForAbsentSandboxDestroySpy).toHaveBeenCalledWith("alpha", { force: false, + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), }); expect(harness.finalizeMcpBridgesAfterSandboxDeleteSpy).toHaveBeenCalledTimes(2); expect(harness.removeSandboxSpy).toHaveBeenCalledWith("alpha"); expect(harness.compareAndSwapSessionSpy).toHaveBeenCalledOnce(); expect(harness.updateSessionSpy).not.toHaveBeenCalled(); - expect(harness.cleanupGatewaySpy).toHaveBeenCalledWith( - "nemoclaw-19080", - harness.runOpenshellSpy, - ); + expect(harness.cleanupGatewaySpy).toHaveBeenCalledWith("nemoclaw-19080", expect.any(Function)); }); }); diff --git a/src/lib/actions/sandbox/destroy-gateway-cleanup.test.ts b/src/lib/actions/sandbox/destroy-gateway-cleanup.test.ts index f026f46f6d1..49df15f2775 100644 --- a/src/lib/actions/sandbox/destroy-gateway-cleanup.test.ts +++ b/src/lib/actions/sandbox/destroy-gateway-cleanup.test.ts @@ -56,6 +56,30 @@ describe("shouldCleanupGatewayAfterConfirmedFinalDestroy", () => { ).toBe(false); }); + it("passes the selected OpenShell capture boundary to the final live-sandbox probe (#10514)", () => { + const captureOpenshell = vi.fn(() => ({ status: 0, output: "" })); + const liveSandboxProbe = vi.fn(() => true); + + expect( + shouldCleanupGatewayAfterConfirmedFinalDestroy( + { + deleteSucceededOrAlreadyGone: true, + removedRegistryEntry: true, + }, + { + captureOpenshell, + listSandboxes: () => ({ sandboxes: [] }), + liveSandboxProbe, + timeoutMs: 1_000, + }, + ), + ).toBe(true); + expect(liveSandboxProbe).toHaveBeenCalledWith({ + captureOpenshell, + timeoutMs: 1_000, + }); + }); + it("preserves the gateway when a live sandbox appears after the empty-registry check", () => { const events: string[] = []; expect( diff --git a/src/lib/actions/sandbox/destroy-gateway-cleanup.ts b/src/lib/actions/sandbox/destroy-gateway-cleanup.ts index c2fff6ee31a..9bdb666b2b1 100644 --- a/src/lib/actions/sandbox/destroy-gateway-cleanup.ts +++ b/src/lib/actions/sandbox/destroy-gateway-cleanup.ts @@ -32,6 +32,7 @@ type FinalDestroyGatewayCleanupInput = { }; type FinalDestroyGatewayCleanupDeps = { + captureOpenshell?: LiveSandboxListProbe; listSandboxes?: SandboxListProvider; liveSandboxProbe?: LiveSandboxProbe; timeoutMs?: number; @@ -109,6 +110,7 @@ export function shouldCleanupGatewayAfterConfirmedFinalDestroy( input.removedRegistryEntry && noRegisteredSandboxes && liveSandboxProbe({ + ...(deps.captureOpenshell ? { captureOpenshell: deps.captureOpenshell } : {}), timeoutMs, }); diff --git a/src/lib/actions/sandbox/destroy-host-local-inference.test.ts b/src/lib/actions/sandbox/destroy-host-local-inference.test.ts index 0791dbead73..d30c2db2084 100644 --- a/src/lib/actions/sandbox/destroy-host-local-inference.test.ts +++ b/src/lib/actions/sandbox/destroy-host-local-inference.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { createInMemoryRuntimeProviderBundle } from "../../../../test/helpers/runtime-provider-bundle"; import { llamaCppHostLocalInferenceReceipt } from "../../../../test/helpers/host-local-inference-receipt"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import type { HostLocalInferenceOperation } from "../../onboard/runtime-provider/host-local-inference"; import { type HostLocalInferenceDestroyResult, @@ -185,7 +186,14 @@ async function runDestroy( sandboxConfirmedAbsent?: boolean; force?: boolean; includeRegistryReaders?: boolean; - inspectSandboxIdentityFingerprint?: () => string; + inspectSandboxIdentityFingerprint?: NonNullable< + NonNullable< + Parameters[0]["deps"] + >["inspectOpenShellSandboxIdentityFingerprint"] + >; + mcpRuntimeSelection?: NonNullable< + Parameters[0]["mcpRuntimeSelection"] + >; lifecycleOptions?: NonNullable< NonNullable< Parameters[0]["deps"] @@ -209,7 +217,10 @@ async function runDestroy( const runOpenshell = vi.fn((args: string[]) => { const command = args.join(" "); runtimeProvider.events.push(command); - current = command === "sandbox delete alpha" ? afterDelete : current; + current = + args[0] === "sandbox" && args[1] === "delete" && args.at(-1) === "alpha" + ? afterDelete + : current; return ( options.deleteResult ?? { status: 0, @@ -226,6 +237,7 @@ async function runDestroy( sandboxConfirmedAbsent: options.sandboxConfirmedAbsent ?? false, sandboxName: "alpha", stopInferenceResources, + ...(options.mcpRuntimeSelection ? { mcpRuntimeSelection: options.mcpRuntimeSelection } : {}), runtimeProviders: { mxc: runtimeProvider.bundle }, deps: { ...(options.lifecycleOptions @@ -293,15 +305,36 @@ describe("sandbox destroy host-local inference transaction", () => { const entry = sandbox("alpha", receipt(), { pendingCreateIdentity: pendingCreateIdentity(), }); - const inspect = vi.fn(() => SANDBOX_FINGERPRINT); + const runtimeSelection = { + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const inspect = vi.fn( + (_options: { + readonly sandboxName: string; + readonly gatewayName: string; + readonly runtimeSelection?: OpenShellRuntimeSelection; + }) => SANDBOX_FINGERPRINT, + ); const { getSandbox, result, runOpenshell } = await runDestroy(runtimeProvider, { entry, inspectSandboxIdentityFingerprint: inspect, + mcpRuntimeSelection: runtimeSelection, }); expect(result).toMatchObject({ ok: true }); expect(inspect.mock.calls.length).toBeGreaterThanOrEqual(5); + expect(inspect.mock.calls.map(([options]) => options)).toEqual( + new Array(inspect.mock.calls.length).fill( + expect.objectContaining({ + sandboxName: "alpha", + gatewayName: "nemoclaw", + runtimeSelection, + }), + ), + ); expect(getSandbox.mock.calls.length).toBeGreaterThanOrEqual(10); expect(runOpenshell).toHaveBeenCalledWith( ["sandbox", "delete", "-g", "nemoclaw", "alpha"], diff --git a/src/lib/actions/sandbox/destroy-preflight.ts b/src/lib/actions/sandbox/destroy-preflight.ts index e6063423c06..644287b2679 100644 --- a/src/lib/actions/sandbox/destroy-preflight.ts +++ b/src/lib/actions/sandbox/destroy-preflight.ts @@ -3,6 +3,8 @@ import os from "node:os"; +import { buildSelectedOpenShellSubprocessEnv } from "../../adapters/openshell/command-argv"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import { withModelRouterPortLifecycleLock } from "../../inference/gateway-route-mutation-lock"; import { DEFAULT_MODEL_ROUTER_PORT, isRoutedInferenceProvider } from "../../onboard/model-router"; @@ -32,11 +34,28 @@ export { teardownSandboxDashboardForward } from "./forward-recovery"; export type SandboxDestroyPreflight = { cleanupGatewayName: string; + runtimeSelection?: OpenShellRuntimeSelection; runOpenshell: DestroyRunOpenshell; + selectedCaptureOpenshell?: typeof import("../../adapters/openshell/runtime").captureOpenshell; + selectedRunOpenshell: DestroyRunOpenshell; sandbox: SandboxEntry | null; sandboxConfirmedAbsent: boolean; }; +export function resolveSandboxDestroyRuntimeSelection( + sandbox: SandboxEntry | null, +): OpenShellRuntimeSelection | undefined { + if ( + !sandbox || + !Object.values(sandbox.mcp?.bridges ?? {}).some((entry) => entry.addState !== "prepared") + ) { + return undefined; + } + return ( + require("./mcp-bridge-provider") as typeof import("./mcp-bridge-provider") + ).getMcpProviderInspectionRuntimeSelection(sandbox); +} + export function stopSandboxInferenceResources( sandboxName: string, sandbox: SandboxEntry | null, @@ -266,13 +285,20 @@ export async function stopModelRouterForDestroyedSandbox( export function prepareSandboxDestroy( sandboxName: string, - { retainedRecoveryGatewayName }: { retainedRecoveryGatewayName?: string } = {}, + { + retainedRecoveryGatewayName, + operationRuntimeSelection, + }: { + retainedRecoveryGatewayName?: string; + operationRuntimeSelection?: OpenShellRuntimeSelection; + } = {}, ): SandboxDestroyPreflight { const sandbox = registry.getSandbox(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); - const { runOpenshell } = require("../../adapters/openshell/runtime") as { - runOpenshell: DestroyRunOpenshell; - }; + const { captureOpenshell, runOpenshell } = require("../../adapters/openshell/runtime") as Pick< + typeof import("../../adapters/openshell/runtime"), + "captureOpenshell" | "runOpenshell" + >; // Capture the sandbox gateway before destructive work, then pin every // following OpenShell subprocess against that same durable authority. A @@ -290,17 +316,48 @@ export function prepareSandboxDestroy( } const cleanupGatewayName = retainedRecoveryGatewayName ?? registeredGatewayName ?? getSandboxTargetGatewayName(); - selectGatewayForSandboxDestroy(sandboxName, cleanupGatewayName, runOpenshell); + const runtimeSelection = + operationRuntimeSelection ?? resolveSandboxDestroyRuntimeSelection(sandbox); + if (runtimeSelection && runtimeSelection.gatewayName !== cleanupGatewayName) { + throw new Error( + `Refusing to destroy sandbox '${sandboxName}': recorded MCP gateway '${runtimeSelection.gatewayName}' does not match destroy gateway '${cleanupGatewayName}'.`, + ); + } + const selectedRunOpenshell: DestroyRunOpenshell = runtimeSelection + ? (args, options = {}) => + runOpenshell(args, { + ...options, + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + }) + : runOpenshell; + const selectedCaptureOpenshell = runtimeSelection + ? (args: string[], options: Record = {}) => + captureOpenshell(args, { + ...options, + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + }) + : undefined; + selectGatewayForSandboxDestroy(sandboxName, cleanupGatewayName, selectedRunOpenshell); process.env.OPENSHELL_GATEWAY = cleanupGatewayName; const sandboxPresence = classifyDestroySandboxPresence( sandboxName, - runOpenshell(["sandbox", "list", "-o", "json"], { + selectedRunOpenshell(["sandbox", "list", "-o", "json"], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"], timeout: OPENSHELL_PROBE_TIMEOUT_MS, }), ); const sandboxConfirmedAbsent = sandboxPresence === "absent"; - return { cleanupGatewayName, runOpenshell, sandbox, sandboxConfirmedAbsent }; + return { + cleanupGatewayName, + runOpenshell, + selectedRunOpenshell, + sandbox, + sandboxConfirmedAbsent, + ...(selectedCaptureOpenshell ? { selectedCaptureOpenshell } : {}), + ...(runtimeSelection ? { runtimeSelection } : {}), + }; } diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index a135f52c470..1732f16a1b0 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -69,6 +69,7 @@ import { } from "./destroy-presence"; import { prepareSandboxDestroy, + resolveSandboxDestroyRuntimeSelection, stopModelRouterForDestroyedSandbox, stopSandboxInferenceResources, teardownSandboxDashboardForward, @@ -616,9 +617,10 @@ async function destroySandboxUnlocked( retireRemovedImmutabilityState = false, ): Promise { const normalized = normalizeDestroySandboxOptions(options); - if (!(await confirmSandboxDestroy(sandboxName, normalized))) return; - const destroySession = onboardSession.loadSession(); const registeredSandbox = registry.getSandbox(sandboxName); + const operationRuntimeSelection = resolveSandboxDestroyRuntimeSelection(registeredSandbox); + if (!(await confirmSandboxDestroy(sandboxName, normalized, operationRuntimeSelection))) return; + const destroySession = onboardSession.loadSession(); const retainedRecoveryRecords = onboardSession.listRetainedSandboxRecoveryRecords(); const retainedRecoveryAuthority = selectRetainedSandboxRecoveryAuthority( sandboxName, @@ -729,9 +731,18 @@ async function destroySandboxUnlocked( destroyPreflight = abortPreparedCleanupOnError(() => prepareSandboxDestroy(sandboxName, { retainedRecoveryGatewayName: retainedRecoveryAuthority?.gatewayName, + operationRuntimeSelection, }), ); - const { cleanupGatewayName, runOpenshell, sandbox, sandboxConfirmedAbsent } = destroyPreflight; + const { + cleanupGatewayName, + runOpenshell, + runtimeSelection: mcpRuntimeSelection, + selectedCaptureOpenshell: cleanupCaptureOpenshell, + selectedRunOpenshell: cleanupRunOpenshell, + sandbox, + sandboxConfirmedAbsent, + } = destroyPreflight; if (retainedRecoveryAuthority && !sandboxConfirmedAbsent) { console.error( ` Refusing to automatically delete retained sandbox '${sandboxName}': OpenShell still reports it present, but its delete command accepts only the mutable sandbox name. NemoClaw cannot bind that deletion to the retained immutable identity. No sandbox resources were removed. Ask an OpenShell administrator to resolve create-attempt label '${retainedRecoveryAuthority.createAttemptNonce}' to the exact sandbox and use an identity-bound removal procedure. After OpenShell confirms the retained sandbox is absent, rerun '${CLI_NAME} ${sandboxName} destroy --yes' to reconcile its verified Docker containers and recovery record.`, @@ -775,6 +786,7 @@ async function destroySandboxUnlocked( getSandbox: registry.getSandbox, listSandboxes: registry.listSandboxes, runOpenshell, + ...(mcpRuntimeSelection ? { mcpRuntimeSelection } : {}), sandbox, sandboxConfirmedAbsent, sandboxName, @@ -862,8 +874,20 @@ async function destroySandboxUnlocked( forcedLocalCleanup, deleteOutput, commonLlamaCppAuthorityRetired, + runtimeSelection: destroyRuntimeSelection, } = destructiveResult; + if ( + destroyRuntimeSelection && + cleanupGatewayName !== destroyRuntimeSelection.gatewayName + ) { + console.error( + ` Sandbox '${sandboxName}' was deleted, but its cleanup target changed from '${destroyRuntimeSelection.gatewayName}' to '${cleanupGatewayName}'.`, + ); + console.error(" Local ownership state was preserved. Restore the recorded gateway binding and retry destroy."); + preparedManagedLlamaCppCleanup?.abort(); + requestSandboxDestroyExit(1); + } /** * SOURCE_OF_TRUTH * Invalid state: the OpenShell gateway is unreachable while a local sandbox @@ -944,6 +968,8 @@ async function destroySandboxUnlocked( }); cleanupSandboxServices(sandboxName, { stopHostServices: shouldStopHostServices, + }, { + runOpenshell: cleanupRunOpenshell, }); }); if (deleteSucceededOrAlreadyGone && commonLlamaCppAuthorityRetired === true) { @@ -1120,11 +1146,11 @@ async function destroySandboxUnlocked( shouldCleanupGatewayAfterConfirmedFinalDestroy({ deleteSucceededOrAlreadyGone, removedRegistryEntry: removed, - }) + }, cleanupCaptureOpenshell ? { captureOpenshell: cleanupCaptureOpenshell } : {}) ) { const shouldCleanupGateway = await resolveCleanupGatewayDecision(normalized); if (shouldCleanupGateway) { - cleanupGatewayAfterLastSandbox(cleanupGatewayName, runOpenshell); + cleanupGatewayAfterLastSandbox(cleanupGatewayName, cleanupRunOpenshell); } else { // `gateway remove ` is the modern OpenShell subcommand on every // platform; the old `gateway destroy -g` was pre-0.0.44 only and current diff --git a/src/lib/adapters/openshell/sandbox-identity-cli.test.ts b/src/lib/adapters/openshell/sandbox-identity-cli.test.ts index df6fab3a0d4..a2e65e460ce 100644 --- a/src/lib/adapters/openshell/sandbox-identity-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-identity-cli.test.ts @@ -3,10 +3,14 @@ import { createHash } from "node:crypto"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createSyncCliOpenShellSandboxIdentityInspector } from "./sandbox-identity-cli"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + function captured(status: number | null, stdout = "", stderr = "", error?: Error) { return { status, @@ -40,6 +44,43 @@ describe("CLI OpenShell sandbox identity inspector", () => { ); }); + it("uses the complete recorded target and discards ambient endpoint credentials", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + const capture = vi.fn((_args: string[], _options: Record) => + captured(0, "ID: sandbox-alpha\n"), + ); + const inspect = createSyncCliOpenShellSandboxIdentityInspector({ capture }); + + const result = inspect({ + sandboxName: "alpha", + gatewayName: "nemoclaw-18080", + runtimeSelection: { + gatewayName: "nemoclaw-18080", + workspace: "default", + localTlsDir: "/authority/tls", + }, + }); + + expect(result.ok).toBe(true); + const options = capture.mock.calls[0]?.[1] as + | { env?: Record; replaceEnv?: boolean } + | undefined; + expect(options).toMatchObject({ + replaceEnv: true, + env: { + OPENSHELL_GATEWAY: "nemoclaw-18080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }, + }); + expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(options?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + }); + it("rejects invalid names before invoking OpenShell", () => { const capture = vi.fn(() => captured(0)); const inspect = createSyncCliOpenShellSandboxIdentityInspector({ capture }); diff --git a/src/lib/adapters/openshell/sandbox-identity-cli.ts b/src/lib/adapters/openshell/sandbox-identity-cli.ts index 80191911df0..a560cbb7d8a 100644 --- a/src/lib/adapters/openshell/sandbox-identity-cli.ts +++ b/src/lib/adapters/openshell/sandbox-identity-cli.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "./command-argv"; import { diagnosticPreview, isValidName, @@ -29,12 +33,15 @@ type CaptureSandboxIdentityCommand = ( readonly includeStreams: true; readonly maxBuffer: number; readonly timeout: number; + readonly env?: Record; + readonly replaceEnv?: true; }, ) => CapturedOpenShellCommandResult; type InspectSandboxIdentityRequest = Readonly<{ sandboxName: string; gatewayName: string; + runtimeSelection?: OpenShellRuntimeSelection; timeoutMs?: number; }>; @@ -70,8 +77,22 @@ export function createSyncCliOpenShellSandboxIdentityInspector(deps: { return (request) => { const gatewayName = validateIdentityName(request.gatewayName, "gateway name"); const sandboxName = validateIdentityName(request.sandboxName, "sandbox name"); - assertNoOpenShellGatewayEndpointOverride(); + if ( + request.runtimeSelection?.gatewayName !== undefined && + request.runtimeSelection.gatewayName !== gatewayName + ) { + throw new PolicyObservationError( + `OpenShell sandbox identity target '${request.runtimeSelection.gatewayName}' does not match gateway '${gatewayName}'.`, + ); + } + if (!request.runtimeSelection) assertNoOpenShellGatewayEndpointOverride(); const captured = deps.capture(["sandbox", "get", "-g", gatewayName, sandboxName], { + ...(request.runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(request.runtimeSelection), + replaceEnv: true as const, + } + : {}), ignoreError: true, includeStderr: true, includeStreams: true, diff --git a/src/lib/adapters/openshell/sanitized-capture.ts b/src/lib/adapters/openshell/sanitized-capture.ts index eba12b2edaa..633ca2bd89a 100644 --- a/src/lib/adapters/openshell/sanitized-capture.ts +++ b/src/lib/adapters/openshell/sanitized-capture.ts @@ -13,6 +13,8 @@ type SanitizedCaptureOptions = Readonly<{ includeStreams: true; maxBuffer: number; timeout: number; + env?: Record; + replaceEnv?: true; }>; /** Capture a bounded OpenShell read with a credential-minimizing environment. */ diff --git a/src/lib/state/sandbox-session.test.ts b/src/lib/state/sandbox-session.test.ts index a1d9539c437..aeb748e1dc6 100644 --- a/src/lib/state/sandbox-session.test.ts +++ b/src/lib/state/sandbox-session.test.ts @@ -1,13 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + createSystemDeps, getActiveSandboxSessions, parseSshProcesses, type SessionDetectionDeps, } from "./sandbox-session"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("parseSshProcesses", () => { it("returns empty array for empty input", () => { expect(parseSshProcesses("", "my-sandbox")).toEqual([]); @@ -132,6 +137,54 @@ describe("parseSshProcesses", () => { }); describe("getActiveSandboxSessions", () => { + it("pins a proxied session lookup to the recorded OpenShell target (#10514)", () => { + const sandboxId = "de7eab7a-002f-41e9-acad-5fd4749e07bb"; + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + const spawn = vi + .fn() + .mockReturnValueOnce({ + status: 0, + stdout: `12345 ssh -o ProxyCommand=/usr/local/bin/openshell ssh-proxy --sandbox-id ${sandboxId} --token t -tt -o RequestTTY=force sandbox`, + stderr: "", + }) + .mockReturnValueOnce({ status: 0, stdout: `Id: ${sandboxId}\n`, stderr: "" }); + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", + localTlsDir: "/authority/tls", + }; + + const result = getActiveSandboxSessions( + "my-sandbox", + createSystemDeps("/usr/bin/openshell", { + runtimeSelection, + spawnSync: spawn as never, + }), + ); + + expect(result.sessions).toHaveLength(1); + expect(spawn).toHaveBeenNthCalledWith( + 2, + "/usr/bin/openshell", + ["sandbox", "get", "-g", "nemoclaw-9090", "my-sandbox"], + expect.any(Object), + ); + const openshellOptions = spawn.mock.calls[1]?.[2] as + | { env?: Record } + | undefined; + expect(openshellOptions?.env).toMatchObject({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }); + expect(openshellOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(openshellOptions?.env).not.toHaveProperty("OPENSHELL_TOKEN"); + }); + it("returns detected=false when no deps available", () => { const deps: SessionDetectionDeps = { getSshProcesses: () => null, diff --git a/src/lib/state/sandbox-session.ts b/src/lib/state/sandbox-session.ts index 61602c3a3a5..0adedc6adec 100644 --- a/src/lib/state/sandbox-session.ts +++ b/src/lib/state/sandbox-session.ts @@ -14,6 +14,8 @@ */ import { spawnSync } from "node:child_process"; +import { buildSelectedOpenShellSubprocessEnv } from "../adapters/openshell/command-argv"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; import { createOpenshellSandboxIdReader } from "../adapters/openshell/sandbox-identity"; import { openshellSandboxSshHost } from "../adapters/openshell/sandbox-ssh-host"; @@ -176,9 +178,9 @@ export function getActiveSandboxSessions( * for matching SSH target hosts. `ps -axo pid,command` works on both platforms * and returns full command lines in pgrep-compatible format (`PID COMMAND`). */ -function querySshProcesses(): string | null { +function querySshProcesses(runCommand: typeof spawnSync = spawnSync): string | null { try { - const result = spawnSync("ps", ["-axo", "pid,command"], { + const result = runCommand("ps", ["-axo", "pid,command"], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000, @@ -199,12 +201,26 @@ function querySshProcesses(): string | null { * Create the default system deps for session detection. * Uses `ps` on the host. */ -export function createSystemDeps(openshellBinary: string): SessionDetectionDeps { +export function createSystemDeps( + openshellBinary: string, + options: { + readonly runtimeSelection?: OpenShellRuntimeSelection; + readonly spawnSync?: typeof spawnSync; + } = {}, +): SessionDetectionDeps { + const runCommand = options.spawnSync ?? spawnSync; + const selectedEnv = options.runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(options.runtimeSelection) + : undefined; return { - getSshProcesses: querySshProcesses, + getSshProcesses: () => querySshProcesses(runCommand), resolveSandboxId: createOpenshellSandboxIdReader(openshellBinary, (binary, args) => { - const result = spawnSync(binary, args, { + const selectedArgs = options.runtimeSelection + ? [args[0]!, args[1]!, "-g", options.runtimeSelection.gatewayName, ...args.slice(2)] + : args; + const result = runCommand(binary, selectedArgs, { encoding: "utf-8", + ...(selectedEnv ? { env: selectedEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 5000, }); diff --git a/test/helpers/destroy-flow-test-assertions.ts b/test/helpers/destroy-flow-test-assertions.ts index f2f603b2357..6bf88620bfe 100644 --- a/test/helpers/destroy-flow-test-assertions.ts +++ b/test/helpers/destroy-flow-test-assertions.ts @@ -88,10 +88,20 @@ export function expectMcpFinalizeAfterDelete(harness: DestroyHarness): void { // The live preparation is force-aware since #10469: `--force` may keep a // retained-volume adapter entry that cannot be scrubbed. These flows are all // plain destroys, so the flag must be threaded through as false. - expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { force: false }); + expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { + force: false, + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), + }); expect(harness.gatewayPinsAtMcpPrepare).toEqual(["nemoclaw-19080"]); const deleteCall = harness.runOpenshellSpy.mock.calls.findIndex( - (call) => Array.isArray(call[0]) && call[0].join(" ") === "sandbox delete alpha", + (call) => + Array.isArray(call[0]) && + call[0][0] === "sandbox" && + call[0][1] === "delete" && + call[0].at(-1) === "alpha", ); expect(deleteCall).toBeGreaterThanOrEqual(0); expect(harness.prepareMcpBridgesForDestroySpy.mock.invocationCallOrder.at(-1)).toBeLessThan( @@ -151,7 +161,11 @@ export function expectMcpFinalizeBridgeErrorReturnsFailure( ): void { expect(harness.finalizeMcpBridgesAfterSandboxDeleteSpy).toHaveBeenCalled(); const deleteCall = harness.runOpenshellSpy.mock.calls.findIndex( - (call) => Array.isArray(call[0]) && call[0].join(" ") === "sandbox delete alpha", + (call) => + Array.isArray(call[0]) && + call[0][0] === "sandbox" && + call[0][1] === "delete" && + call[0].at(-1) === "alpha", ); expect(deleteCall).toBeGreaterThanOrEqual(0); expect( @@ -169,6 +183,10 @@ export function expectAbsentSandboxMcpFinalize(harness: DestroyHarness): void { expect(harness.prepareMcpBridgesForDestroySpy).not.toHaveBeenCalled(); expect(harness.prepareMcpBridgesForAbsentSandboxDestroySpy).toHaveBeenCalledWith("alpha", { force: false, + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), }); expect(harness.gatewayPinsAtMcpPrepare).toEqual(["nemoclaw-19080"]); expect(harness.restoreMcpBridgesAfterDestroyAbortSpy).not.toHaveBeenCalled(); diff --git a/test/helpers/destroy-flow-test-harness.ts b/test/helpers/destroy-flow-test-harness.ts index 44b2029c8d8..4465230710a 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -37,6 +37,7 @@ export type DestroyHarness = { killStaleProxySpy: MockInstance; lifecycleLockEvents: string[]; logSpy: MockInstance; + mcpRuntimeSelectionSpy: MockInstance; prepareMcpBridgesForAbsentSandboxDestroySpy: MockInstance; prepareMcpBridgesForDestroySpy: MockInstance; prepareManagedLlamaCppRuntimeCleanupSpy: MockInstance; @@ -106,6 +107,11 @@ type DestroyHarnessOptions = { mcpAddState?: "prepared"; mcpAdapterScrubSkipped?: true; mcpServers?: string[]; + mcpRuntimeSelection?: { + gatewayName: string; + localTlsDir?: string; + workspace: string; + }; openshellDriver?: string; portableCommandError?: string; portableDestroyAuthority?: boolean; @@ -239,6 +245,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr const destroyPreflight = requireSource("./destroy-preflight.js"); const sandboxSession = requireSource("../../state/sandbox-session.js"); const mcpBridge = requireSource("./mcp-bridge.js"); + const mcpBridgeProvider = requireSource("./mcp-bridge-provider.js"); const dockerRun = requireSource("../../adapters/docker/run.js"); const portableAgentLifecycle = requireSource( "../../onboard/experimental/portable-agent-lifecycle.js", @@ -590,12 +597,20 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr .mockImplementation(() => undefined); const stopAllSpy = vi.spyOn(tunnelServices, "stopAll").mockImplementation(() => undefined); const preparedServers = options.mcpAddState === "prepared" ? [] : (options.mcpServers ?? []); + const resolvedMcpRuntimeSelection = options.mcpRuntimeSelection ?? { + gatewayName: "nemoclaw-19080", + workspace: "default", + }; + const mcpRuntimeSelectionSpy = vi + .spyOn(mcpBridgeProvider, "getMcpProviderInspectionRuntimeSelection") + .mockReturnValue(resolvedMcpRuntimeSelection); const mcpPreparation = { entries: preparedServers.map((server) => ({ server })), detachedProviderEntries: preparedServers.map((server) => ({ server })), scrubbedAdapterEntries: preparedServers.map((server) => ({ server })), destroyAlreadyPrepared: false, destroyAlreadyPending: false, + ...(preparedServers.length ? { runtimeSelection: resolvedMcpRuntimeSelection } : {}), ...(options.mcpAdapterScrubSkipped ? { adapterScrubSkipped: true as const } : {}), }; const gatewayPinsAtMcpPrepare: Array = []; @@ -657,6 +672,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr killStaleProxySpy, lifecycleLockEvents, logSpy, + mcpRuntimeSelectionSpy, prepareMcpBridgesForAbsentSandboxDestroySpy, prepareMcpBridgesForDestroySpy, prepareManagedLlamaCppRuntimeCleanupSpy, From f02613f106f3fc1041c8f6f5c8f303c91761c02d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 3 Sep 2026 18:22:45 -0700 Subject: [PATCH 8/8] refactor(rebuild): preserve recorded runtime target (#10817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Outcome Keeps an MCP-bearing sandbox rebuild on one recorded OpenShell runtime target across preflight, delete, recreate, restore, process recovery, and final relock. Target drift fails closed with the original sandbox preserved and a concrete recovery action. ## Reason Rebuild crosses gateway recovery, policy, Shields, DCode, onboarding, SSH, plugin restore, messaging, and process-recovery boundaries. Each dependent phase must reuse the same target or a transaction can inspect one sandbox and mutate another with the same name. ### Related issues Refs #10514 Refs #9833 ## Changes - Resolve and pin one MCP runtime selection before live rebuild probes, then retain it in the recreate journal and preparation state. - Carry the selection through DCode checks, policy and Shields work, deletion, inner onboarding, SSH and state restore, messaging, process recovery, and final relock. - Consolidate DCode gateway selection on the shared recovery helper while preserving its distinct failure contract. - Replace ambient OpenShell selectors at every affected subprocess boundary and restore the caller's environment on success and failure. - Add target-drift, hostile-selector, journal, restore, recovery, and post-restore regression coverage. ## Verification - `npm run build:cli` — passed. - `npm run typecheck:cli` — passed. - Focused rebuild CLI suites — 413 tests passed and 14 expected skips. - Focused rebuild integration suites — 225 tests passed. - `npm run checks:repository` — passed. - Normal pre-push TypeScript hook — passed. - `git diff --check` and a credential review of the diff — passed; no secrets, API keys, or credentials are included. ## Review notes Stack 4 of 4 extracted from #10621. Depends on #10816; review this PR against `codex/10621-destroy-target`. The broad parallel changed-test run encountered shared portable-host lock contention. The same owning suites passed in serial, which is the evidence reported above. --- Signed-off-by: Apurv Kumaria ## Summary by CodeRabbit * **New Features** * Rebuilds and recovery operations now consistently use the selected OpenShell gateway, workspace, and TLS settings. * Authoritative rebuild resumes preserve and restore their selected runtime configuration across preflight, restore, and post-restore steps. * Sandbox restoration, messaging, MCP, inference, and configuration checks support the selected runtime context. * **Bug Fixes** * Prevented ambient endpoint and token settings from overriding the selected runtime. * Operations now fail safely when the selected gateway is unavailable or mismatched. * Host-local supervisor controls are blocked when they could target the wrong runtime. --------- Signed-off-by: Apurv Kumaria Signed-off-by: Prekshi Vyas Co-authored-by: Prekshi Vyas --- ci/source-architecture-budget.json | 8 +- .../forward-recovery-declared-ports.test.ts | 67 ++++- src/lib/actions/sandbox/forward-recovery.ts | 130 ++++++--- .../actions/sandbox/gateway-restart.test.ts | 23 ++ .../sandbox/inference-invocation-probe.ts | 11 +- .../messaging-host-forward-lifecycle.ts | 7 +- src/lib/actions/sandbox/process-recovery.ts | 253 ++++++++++++++---- .../actions/sandbox/rebuild-backup-phase.ts | 18 +- .../sandbox/rebuild-config-hash.test.ts | 61 ++++- .../actions/sandbox/rebuild-config-hash.ts | 15 +- .../sandbox/rebuild-dcode-orchestrator.ts | 51 +++- .../sandbox/rebuild-dcode-preflight.ts | 117 ++++---- .../sandbox/rebuild-destroy-phase.test.ts | 88 +++++- .../actions/sandbox/rebuild-destroy-phase.ts | 76 +++++- .../sandbox/rebuild-flow-helpers.test.ts | 72 ++++- .../actions/sandbox/rebuild-flow-helpers.ts | 24 +- .../sandbox/rebuild-flow-lifecycle.test.ts | 32 +++ .../sandbox/rebuild-gateway-drift.test.ts | 21 ++ .../actions/sandbox/rebuild-gpu-opt-out.ts | 2 + .../sandbox/rebuild-hermes-post-restore.ts | 16 +- .../actions/sandbox/rebuild-mcp-phase.test.ts | 72 ++++- src/lib/actions/sandbox/rebuild-mcp-phase.ts | 71 ++++- .../sandbox/rebuild-messaging-phase.ts | 34 ++- .../sandbox/rebuild-messaging-removal.test.ts | 47 +++- src/lib/actions/sandbox/rebuild-pipeline.ts | 51 +++- .../rebuild-post-restore-phase.test.ts | 42 +++ .../sandbox/rebuild-post-restore-phase.ts | 42 ++- .../sandbox/rebuild-preflight-guards.ts | 11 +- .../sandbox/rebuild-preflight-phase.ts | 25 +- ...eflight-target-phase-orchestration.test.ts | 38 ++- .../rebuild-preflight-target-phase.test.ts | 66 ++++- .../sandbox/rebuild-preflight-target-phase.ts | 44 ++- .../rebuild-provider-preflight.test.ts | 45 ++++ .../sandbox/rebuild-provider-preflight.ts | 9 + .../sandbox/rebuild-recreate-journal.test.ts | 66 +++++ .../sandbox/rebuild-recreate-journal.ts | 21 +- .../actions/sandbox/rebuild-recreate-phase.ts | 3 + .../sandbox/rebuild-restore-phase.test.ts | 34 +++ .../actions/sandbox/rebuild-restore-phase.ts | 12 +- .../sandbox/rebuild-resume-snapshot.test.ts | 5 + .../sandbox/rebuild-target-runtime.test.ts | 7 + .../sandbox/reconcile-session-models.test.ts | 21 ++ .../sandbox/reconcile-session-models.ts | 34 ++- .../openshell/forward-service.test.ts | 6 + src/lib/adapters/openshell/forward-service.ts | 6 +- .../adapters/openshell/gateway-drift.test.ts | 66 +++++ src/lib/adapters/openshell/gateway-drift.ts | 49 +++- .../authoritative-rebuild-target.test.ts | 43 +++ .../onboard/authoritative-rebuild-target.ts | 71 ++++- src/lib/onboard/entry-options.ts | 27 +- src/lib/onboard/gateway/registration.ts | 3 +- src/lib/onboard/sandbox-recreate-probe.ts | 14 + src/lib/onboard/types.ts | 2 + .../state/openclaw-config-restore-input.ts | 6 +- src/lib/state/openclaw-plugin-restore.ts | 3 + ...sandbox-recreated-openclaw-restore.test.ts | 155 ++++++++--- src/lib/state/sandbox.ts | 38 ++- src/lib/state/state-file-restore.ts | 3 + .../state/user-managed-files-probe.test.ts | 102 +++++++ src/lib/state/user-managed-files-probe.ts | 22 +- test/helpers/rebuild-flow-harness.ts | 2 + test/mcp/mcp-destroy-lifecycle.test.ts | 6 +- 62 files changed, 2193 insertions(+), 323 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index ca8d410ef20..8b64b6d931e 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -7,6 +7,7 @@ "src/lib/actions/sandbox/process-recovery.ts": 26, "src/lib/adapters/docker/index.ts": 42, "src/lib/adapters/openshell/client.ts": 21, + "src/lib/adapters/openshell/command-argv.ts": 23, "src/lib/adapters/openshell/resolve.ts": 25, "src/lib/adapters/openshell/runtime.ts": 54, "src/lib/adapters/openshell/timeouts.ts": 33, @@ -43,8 +44,8 @@ "src/lib/actions/sandbox/gateway-state.ts": 21, "src/lib/actions/sandbox/status-snapshot.ts": 19, "src/lib/actions/sandbox/policy-channel.ts": 30, - "src/lib/actions/sandbox/process-recovery.ts": 19, - "src/lib/actions/sandbox/rebuild-pipeline.ts": 29, + "src/lib/actions/sandbox/process-recovery.ts": 20, + "src/lib/actions/sandbox/rebuild-pipeline.ts": 30, "src/lib/actions/sandbox/snapshot.ts": 37, "src/lib/actions/uninstall/run-plan.ts": 25, "src/lib/inference/local.ts": 22, @@ -53,7 +54,8 @@ "src/lib/onboard.ts": 191, "src/lib/onboard/machine/handlers/sandbox.ts": 21, "src/lib/policy/index.ts": 22, - "src/lib/sandbox/config.ts": 19 + "src/lib/sandbox/config.ts": 19, + "src/lib/state/sandbox.ts": 21 } }, "allowedCycles": [], diff --git a/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts b/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts index f8a69e6c94f..112f2a384c0 100644 --- a/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts +++ b/src/lib/actions/sandbox/forward-recovery-declared-ports.test.ts @@ -18,11 +18,13 @@ vi.mock("../../adapters/openshell/forward-service", async (importOriginal) => ({ launchForwardService: mocks.launchForwardService, })); -vi.mock("../../adapters/openshell/resolve", () => ({ +vi.mock("../../adapters/openshell/resolve", async (importOriginal) => ({ + ...(await importOriginal()), resolveOpenshell: () => "/usr/local/bin/openshell", })); -vi.mock("../../adapters/openshell/runtime", () => ({ +vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({ + ...(await importOriginal()), captureOpenshell: mocks.captureOpenshell, runOpenshell: mocks.runOpenshell, isCommandTimeout: () => false, @@ -68,7 +70,7 @@ beforeEach(() => { mocks.getSessionAgent.mockReturnValue(HERMES_AGENT); }); -describe("ensureDeclaredAgentForwardPortsHealthy", () => { +describe("ensureDeclaredAgentForwardPortsHealthy", { timeout: 30_000 }, () => { it("accepts an already-reachable remote direct service during gateway recovery", async () => { vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); mocks.getSandbox.mockReturnValue({ @@ -111,9 +113,68 @@ describe("ensureDeclaredAgentForwardPortsHealthy", () => { expect(ensureDeclaredAgentForwardPortsHealthy("beta", 18790)).toBe(true); expect(mocks.launchForwardService).toHaveBeenCalledWith( expect.objectContaining({ localPort: 8643, targetPort: 8643 }), + {}, ); }); + it("pins declared forward inspection and recovery to the selected OpenShell target (#10514)", async () => { + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "0"); + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + mocks.isLocalForwardReachable.mockReturnValue(false); + mocks.getSandbox.mockReturnValue({ + agent: "hermes", + dashboardPort: 18790, + hermesApiPort: 8643, + }); + mocks.captureOpenshell.mockReturnValue(forwardList([])); + const runtimeSelection = { + gatewayName: "nemoclaw-19080", + workspace: "review-workspace", + localTlsDir: "/authority/tls", + }; + const selectedOptions = expect.objectContaining({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: runtimeSelection.gatewayName, + OPENSHELL_WORKSPACE: runtimeSelection.workspace, + OPENSHELL_LOCAL_TLS_DIR: runtimeSelection.localTlsDir, + }), + replaceEnv: true, + }); + const { ensureDeclaredAgentForwardPortsHealthy } = await import("./forward-recovery"); + + expect(ensureDeclaredAgentForwardPortsHealthy("beta", 18790, runtimeSelection)).toBe(true); + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["forward", "list", "--gateway", runtimeSelection.gatewayName], + selectedOptions, + ); + expect(mocks.launchForwardService).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayName: runtimeSelection.gatewayName, + localPort: 8643, + sandboxName: "beta", + targetPort: 8643, + workspace: runtimeSelection.workspace, + }), + { + sourceEnvironment: expect.objectContaining({ + OPENSHELL_GATEWAY: runtimeSelection.gatewayName, + OPENSHELL_WORKSPACE: runtimeSelection.workspace, + OPENSHELL_LOCAL_TLS_DIR: runtimeSelection.localTlsDir, + }), + }, + ); + const captureEnv = mocks.captureOpenshell.mock.calls[0]?.[1]?.env; + expect(captureEnv).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(captureEnv).not.toHaveProperty("OPENSHELL_TOKEN"); + const sourceEnvironment = mocks.launchForwardService.mock.calls[0]?.[1]?.sourceEnvironment; + expect(sourceEnvironment).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(sourceEnvironment).not.toHaveProperty("OPENSHELL_TOKEN"); + }); + it("keeps the default API port for a sandbox registered without one (#8543)", async () => { mocks.getSandbox.mockReturnValue({ agent: "hermes", dashboardPort: 18789 }); mocks.captureOpenshell.mockReturnValue(forwardList([])); diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index bf8c939fdb4..f26329ab463 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -2,12 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 import { resolveOpenshell } from "../../adapters/openshell/resolve"; +import { + buildSelectedOpenShellSubprocessEnv, + withSelectedOpenShellCommandOptions, +} from "../../adapters/openshell/command-argv"; import { launchForwardService, type ForwardServiceTarget, } from "../../adapters/openshell/forward-service"; import { isLegacySandboxForwardListed } from "../../adapters/openshell/forward-service-migration"; -import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime"; +import { + captureOpenshell, + type OpenShellRuntimeSelection, + runOpenshell, +} from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import * as agentRuntime from "../../agent/runtime"; import { DASHBOARD_PORT, HERMES_OPENAI_API_PORT } from "../../core/ports"; @@ -129,27 +137,41 @@ type SandboxForwardRecoveryOptions = { afterSuccess?: () => boolean; beforeStart?: () => boolean; isWsl?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; }; function retireLegacyForwardServiceMigration( sandboxName: string, gatewayName: string, ports: readonly number[], + runtimeSelection?: OpenShellRuntimeSelection, ): number { return retireProductionLegacySandboxForwards(sandboxName, gatewayName, ports, { capture: (gatewayName) => - captureOpenshell(["forward", "list", "--gateway", gatewayName], { - ignoreError: true, - includeStreams: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }), + captureOpenshell( + ["forward", "list", "--gateway", gatewayName], + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + includeStreams: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }, + runtimeSelection, + ), + ), isReachable: isLocalForwardReachable, run: (gatewayName, sandboxName, port) => - runOpenshell(["forward", "stop", String(port), sandboxName, "--gateway", gatewayName], { - ignoreError: true, - stdio: "ignore", - timeout: 30_000, - }), + runOpenshell( + ["forward", "stop", String(port), sandboxName, "--gateway", gatewayName], + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + stdio: "ignore", + timeout: 30_000, + }, + runtimeSelection, + ), + ), }); } @@ -159,11 +181,12 @@ function forwardServiceTarget( sandboxName: string, port: number, expectedBind = "127.0.0.1", + workspace = "default", ): ForwardServiceTarget { return { executable, gatewayName, - workspace: "default", + workspace, sandboxName, localHost: expectedBind === "0.0.0.0" ? ("0.0.0.0" as const) : ("127.0.0.1" as const), localPort: port, @@ -309,13 +332,14 @@ export function ensureSandboxPortForward( (!remoteBindRequested || registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true) && (options.beforeStart?.() ?? true), + runtimeSelection: options.runtimeSelection, }); } /** Probe local reachability for a registered sandbox port without claiming process ownership. */ export function isSandboxForwardHealthy( sandboxName: string, - options: { isWsl?: boolean } = {}, + options: { isWsl?: boolean; runtimeSelection?: OpenShellRuntimeSelection } = {}, ): SandboxForwardHealth { const allInterfaceBindRequired = isRemoteDashboardBindRequested(process.env.NEMOCLAW_DASHBOARD_BIND) || @@ -324,6 +348,7 @@ export function isSandboxForwardHealthy( sandboxName, resolveSandboxDashboardPort(sandboxName), allInterfaceBindRequired ? "0.0.0.0" : "127.0.0.1", + options.runtimeSelection, ); } @@ -331,16 +356,23 @@ export function isSandboxPortForwardHealthy( sandboxName: string, port: number, _expectedBind?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): SandboxForwardHealth { const sandbox = registry.getSandbox(sandboxName); if (!sandbox) return false; if (!isLocalForwardReachable(port)) return false; - const gatewayName = resolveSandboxGatewayName(sandbox); - const listed = captureOpenshell(["forward", "list", "--gateway", gatewayName], { - ignoreError: true, - includeStreams: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); + const gatewayName = runtimeSelection?.gatewayName ?? resolveSandboxGatewayName(sandbox); + const listed = captureOpenshell( + ["forward", "list", "--gateway", gatewayName], + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + includeStreams: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }, + runtimeSelection, + ), + ); if ( !listed.error && !listed.signal && @@ -360,6 +392,7 @@ export function ensureSandboxPortForwardForPort( forwardTarget?: string; expectedBind?: string; beforeStart?: () => boolean; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): boolean { const { @@ -367,6 +400,7 @@ export function ensureSandboxPortForwardForPort( forwardTarget = String(port), expectedBind, beforeStart = () => true, + runtimeSelection, } = options; const acceptSuccessfulForward = () => { let accepted = false; @@ -377,14 +411,19 @@ export function ensureSandboxPortForwardForPort( } return accepted; }; - const forwardHealth = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); + const forwardHealth = isSandboxPortForwardHealthy( + sandboxName, + port, + expectedBind, + runtimeSelection, + ); if (forwardHealth === true) return acceptSuccessfulForward(); if (!beforeStart()) return false; try { const sandbox = registry.getSandbox(sandboxName); if (!sandbox) throw new Error(`Sandbox '${sandboxName}' is not registered`); - const gatewayName = resolveSandboxGatewayName(sandbox); - retireLegacyForwardServiceMigration(sandboxName, gatewayName, [port]); + const gatewayName = runtimeSelection?.gatewayName ?? resolveSandboxGatewayName(sandbox); + retireLegacyForwardServiceMigration(sandboxName, gatewayName, [port], runtimeSelection); const executable = resolveOpenshell(); if (!executable) throw new Error("OpenShell is unavailable"); launchForwardService( @@ -394,7 +433,11 @@ export function ensureSandboxPortForwardForPort( sandboxName, port, expectedBind ?? (forwardTarget.startsWith("0.0.0.0:") ? "0.0.0.0" : "127.0.0.1"), + runtimeSelection?.workspace ?? "default", ), + runtimeSelection + ? { sourceEnvironment: buildSelectedOpenShellSubprocessEnv(runtimeSelection) } + : {}, ); return acceptSuccessfulForward(); } catch (error) { @@ -407,10 +450,15 @@ export function ensureSandboxPortForwardForPort( } } -export function ensureHermesDashboardPortForwardIfEnabled(sandboxName: string): boolean | null { +export function ensureHermesDashboardPortForwardIfEnabled( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): boolean | null { return ensureHermesDashboardPortForward(sandboxName, { - isPortForwardHealthy: isSandboxPortForwardHealthy, - ensurePortForward: ensureSandboxPortForwardForPort, + isPortForwardHealthy: (name, port) => + isSandboxPortForwardHealthy(name, port, undefined, runtimeSelection), + ensurePortForward: (name, port) => + ensureSandboxPortForwardForPort(name, port, { runtimeSelection }), }); } @@ -423,19 +471,25 @@ function getSandboxMessagingHostForward( return getActiveMessagingHostForward(plan); } -export function ensureMessagingHostForwardHealthy(sandboxName: string): boolean | null { +export function ensureMessagingHostForwardHealthy( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): boolean | null { const forward = getSandboxMessagingHostForward(sandboxName); if (!forward) return null; - const health = isSandboxPortForwardHealthy(sandboxName, forward.port); + const health = isSandboxPortForwardHealthy(sandboxName, forward.port, undefined, runtimeSelection); if (health === true) return true; - return ensureSandboxPortForwardForPort(sandboxName, forward.port); + return ensureSandboxPortForwardForPort(sandboxName, forward.port, { runtimeSelection }); } export function recoverMessagingHostForward( sandboxName: string, - { quiet }: { quiet: boolean }, + { + quiet, + runtimeSelection, + }: { quiet: boolean; runtimeSelection?: OpenShellRuntimeSelection }, ): boolean | null { - const recovered = ensureMessagingHostForwardHealthy(sandboxName); + const recovered = ensureMessagingHostForwardHealthy(sandboxName, runtimeSelection); if (!quiet && recovered === false) { console.error(" Messaging webhook port forward could not be re-established."); } @@ -485,6 +539,7 @@ function resolveDeclaredAgentForwardPorts( export function ensureDeclaredAgentForwardPortsHealthy( sandboxName: string, primaryPort: number, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean | null { const agent = agentRuntime.getSessionAgent(sandboxName); if (!agent) return null; @@ -499,9 +554,9 @@ export function ensureDeclaredAgentForwardPortsHealthy( if (ports.length === 0) return null; let allHealthy = true; for (const port of ports) { - const health = isSandboxPortForwardHealthy(sandboxName, port); + const health = isSandboxPortForwardHealthy(sandboxName, port, undefined, runtimeSelection); if (health === true) continue; - if (!ensureSandboxPortForwardForPort(sandboxName, port)) { + if (!ensureSandboxPortForwardForPort(sandboxName, port, { runtimeSelection })) { allHealthy = false; } } @@ -569,9 +624,16 @@ export function resolveSandboxLaunchForwardPorts(sandboxName: string): number[] export function recoverDeclaredAgentForwardPorts( sandboxName: string, recoveryPort: number, - { quiet }: { quiet: boolean }, + { + quiet, + runtimeSelection, + }: { quiet: boolean; runtimeSelection?: OpenShellRuntimeSelection }, ): boolean | null { - const recovered = ensureDeclaredAgentForwardPortsHealthy(sandboxName, recoveryPort); + const recovered = ensureDeclaredAgentForwardPortsHealthy( + sandboxName, + recoveryPort, + runtimeSelection, + ); if (!quiet && recovered === false) { console.error(" One or more agent-declared port forwards could not be re-established."); } diff --git a/src/lib/actions/sandbox/gateway-restart.test.ts b/src/lib/actions/sandbox/gateway-restart.test.ts index a0485efa932..f9fe5f0defe 100644 --- a/src/lib/actions/sandbox/gateway-restart.test.ts +++ b/src/lib/actions/sandbox/gateway-restart.test.ts @@ -185,6 +185,29 @@ describe("restartSandboxGateway — host-mediated gateway restart", () => { ); }); + it("fails closed instead of using host-local supervisor control for a selected runtime", () => { + const deps = baseDeps(); + const { requestGatewaySupervisorAction: hostLocalSupervisorAction, ...nonSupervisorDeps } = + deps; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const result = restartSandboxGateway("alpha", { + quiet: true, + runtimeSelection: { gatewayName: "remote-gateway", workspace: "remote-workspace" }, + deps: nonSupervisorDeps, + }); + + expect(result).toMatchObject({ + ok: false, + failureLayer: "privileged control unavailable", + detail: expect.stringContaining("SELECTED_RUNTIME_SUPERVISOR_UNAVAILABLE"), + }); + expect(hostLocalSupervisorAction).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failure layer: privileged control unavailable"), + ); + }); + it("prints a bounded sanitized Hermes gateway-log tail after supervisor failure (#8614)", () => { const restore = silenceConsole(); try { diff --git a/src/lib/actions/sandbox/inference-invocation-probe.ts b/src/lib/actions/sandbox/inference-invocation-probe.ts index 83d45daa04d..28507ab8372 100644 --- a/src/lib/actions/sandbox/inference-invocation-probe.ts +++ b/src/lib/actions/sandbox/inference-invocation-probe.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { getSandboxInferenceConfig } from "../../inference/config"; import { validateInferenceResponseBody } from "../../inference/health"; import { MIN_PROBE_REPLY_TOKENS, resolveMaxTokensField } from "../../inference/max-tokens-field"; @@ -17,6 +18,7 @@ import { DCODE_AGENT_NAME } from "./rebuild-dcode-target"; export type SandboxInferenceInvocationInput = { sandboxName: string; gatewayName?: string; + runtimeSelection?: OpenShellRuntimeSelection; agentName?: string | null; provider: string; model: string; @@ -141,6 +143,7 @@ function executeDcodeSandboxInferenceInvocation( try { const result = runOpenshell(buildDcodeSandboxInferenceInvocationArgs(input), { ignoreError: true, + ...(input.runtimeSelection ? { runtimeSelection: input.runtimeSelection } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, }); @@ -178,9 +181,11 @@ export function probeSandboxInferenceInvocation( result = executeDcodeSandboxInferenceInvocation(input, deps, timeoutMs); } else { const execute = deps.execute ?? executeSandboxExecCommand; - const execOptions: SandboxExecCommandOptions = input.gatewayName - ? { gatewayName: input.gatewayName, allowLocalDockerFallback: false } - : {}; + const execOptions: SandboxExecCommandOptions = { + ...(input.gatewayName ? { gatewayName: input.gatewayName } : {}), + ...(input.runtimeSelection ? { runtimeSelection: input.runtimeSelection } : {}), + allowLocalDockerFallback: false, + }; result = execute( input.sandboxName, buildSandboxInferenceInvocationCommand(input), diff --git a/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts b/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts index 7cf04ab991a..d7ac025319e 100644 --- a/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts +++ b/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { SandboxMessagingPlan } from "../../messaging"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { ensureMessagingHostForwardIfConfigured, resolveMessagingHostForward, @@ -14,15 +15,17 @@ import { export function ensureMessagingHostForwardAfterRebuild( sandboxName: string, plan: SandboxMessagingPlan | null | undefined, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean { const forward = resolveMessagingHostForward(plan); if (!forward) return true; - const health = isSandboxPortForwardHealthy(sandboxName, forward.port); + const health = isSandboxPortForwardHealthy(sandboxName, forward.port, undefined, runtimeSelection); if (health === true) return true; return ensureMessagingHostForwardIfConfigured({ sandboxName, plan, - ensureForward: (name, port) => ensureSandboxPortForwardForPort(name, port), + ensureForward: (name, port) => + ensureSandboxPortForwardForPort(name, port, { runtimeSelection }), note: console.log, }); } diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 4e607cdb7da..868cf33ca03 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { stripAnsi } from "../../adapters/openshell/client"; +import { withSelectedOpenShellCommandOptions } from "../../adapters/openshell/command-argv"; import { buildOpenShellRuntimeSelectionEnv, captureOpenshell, @@ -67,7 +68,7 @@ import { type ManagedGatewayControlCompletion, parseManagedGatewayControlCompletion, printGatewayRestartFailure, - type RestartSandboxGatewayOptions, + type RestartSandboxGatewayOptions as BaseRestartSandboxGatewayOptions, restartSandboxGatewayWithDeps, sandboxAgentName, withUnsupportedHermesPortableGatewayRestartFence, @@ -112,9 +113,12 @@ export type { GatewayRestartFailureLayer, GatewayRestartResult, ManagedGatewayControlCompletion, - RestartSandboxGatewayOptions, } from "./gateway-restart"; +export type RestartSandboxGatewayOptions = BaseRestartSandboxGatewayOptions & { + runtimeSelection?: OpenShellRuntimeSelection; +}; + export { buildSandboxExecMarkedCommand } from "./sandbox-exec-output"; export type { SandboxCommandResult, SandboxExecCommandOptions }; @@ -336,6 +340,20 @@ export function executeGatewaySupervisorAction( return executeGatewaySupervisorActionPinned(sandboxName, action, timeout); } +function refuseHostLocalSupervisorForSelectedRuntime( + _sandboxName: string, + _action: "restart" | "recover" | "probe", + _timeout = 210000, + _expectedContainerId?: string, +): ManagedGatewaySupervisorActionResult { + return { + status: 1, + stdout: "", + stderr: + "PRIVILEGED_CONTROL_UNAVAILABLE\nSELECTED_RUNTIME_SUPERVISOR_UNAVAILABLE: host-local supervisor control is not valid for an explicitly selected OpenShell target", + }; +} + async function executeSandboxExecCommandForStatus( sandboxName: string, command: string, @@ -385,12 +403,22 @@ function parseSandboxGatewayProbe(result: SandboxCommandResult | null): boolean * Fixes #2342 — previously `curl -sf` failed on 401, causing false * "Health Offline" readings. */ -function isSandboxGatewayRunning(sandboxName: string): boolean | null { +function isSandboxGatewayRunning( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): boolean | null { const agent = agentRuntime.getSessionAgent(sandboxName); if (agent && !agentRuntime.hasGatewayRuntime(agent)) return null; const probeUrl = getSandboxHealthProbeUrl(sandboxName); const command = `HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' --max-time 3 ${shellQuote(probeUrl)} 2>/dev/null || echo 000); case "$HTTP_CODE" in 200|401) echo RUNNING ;; *) echo STOPPED ;; esac`; - const execProbe = parseSandboxGatewayProbe(executeSandboxExecCommand(sandboxName, command)); + const execProbe = parseSandboxGatewayProbe( + executeSandboxExecCommand( + sandboxName, + command, + DEFAULT_SANDBOX_EXEC_TIMEOUT_MS, + runtimeSelection ? { runtimeSelection } : {}, + ), + ); if (execProbe !== null) return execProbe; // Built-in OpenClaw and Hermes lifecycle control is host-mediated through @@ -401,7 +429,13 @@ function isSandboxGatewayRunning(sandboxName: string): boolean | null { // their recovery contract is explicitly SSH-owned until manifests can // declare a trusted runtime user/supervisor. if (!agent || agent.name === "openclaw" || agent.name === "hermes") return null; - return parseSandboxGatewayProbe(executeSandboxCommand(sandboxName, command)); + return parseSandboxGatewayProbe( + executeSandboxCommand( + sandboxName, + command, + runtimeSelection ? { runtimeSelection } : DEFAULT_SANDBOX_EXEC_TIMEOUT_MS, + ), + ); } function hasGatewayRecoveryMarker(result: SandboxCommandResult | null): boolean { @@ -739,14 +773,25 @@ function recoverSandboxProcesses( requestPinnedGatewaySupervisorAction = executeGatewaySupervisorActionPinned, relaunchManagedSupervisorSessionImpl = relaunchManagedSupervisorSession, onFailureLayer, + runtimeSelection, }: { quiet?: boolean; requestGatewaySupervisorAction?: typeof executeGatewaySupervisorAction; requestPinnedGatewaySupervisorAction?: RequestPinnedGatewaySupervisorAction; relaunchManagedSupervisorSessionImpl?: typeof relaunchManagedSupervisorSession; onFailureLayer?: (layer: GatewayRestartFailureLayer, detail: string) => void; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): SandboxProcessRecovery | null { + const effectiveGatewaySupervisorAction = + runtimeSelection && requestGatewaySupervisorAction === executeGatewaySupervisorAction + ? refuseHostLocalSupervisorForSelectedRuntime + : requestGatewaySupervisorAction; + const effectivePinnedGatewaySupervisorAction = + runtimeSelection && + requestPinnedGatewaySupervisorAction === executeGatewaySupervisorActionPinned + ? refuseHostLocalSupervisorForSelectedRuntime + : requestPinnedGatewaySupervisorAction; const agent = agentRuntime.getSessionAgent(sandboxName); const dashboardPort = resolveSandboxDashboardPort(sandboxName); let persistedAgent: string | null; @@ -784,7 +829,7 @@ function recoverSandboxProcesses( let execResult: ManagedGatewaySupervisorActionResult | null = null; let busyAttempts = 0; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - execResult = requestGatewaySupervisorAction(sandboxName, "recover"); + execResult = effectiveGatewaySupervisorAction(sandboxName, "recover"); const managedControlCompletion = parseManagedGatewayControlCompletion(execResult); if (managedControlCompletion) return { kind: "managed", managedControlCompletion }; if (hasGatewayRecoveryMarker(execResult)) return { kind: "managed" }; @@ -815,24 +860,46 @@ function recoverSandboxProcesses( const relaunch = relaunchManagedSupervisorSessionImpl(sandboxName, { quiet, deps: { - runOpenshell, + runOpenshell: (args, options) => + runOpenshell( + args, + withSelectedOpenShellCommandOptions(options ?? {}, runtimeSelection), + ), runCaptureOpenshell: (args, options) => - captureOpenshell(args, { - ignoreError: true, - includeStderr: true, - killProcessTreeOnTimeout: options?.killProcessTreeOnTimeout === true, - killSignal: options?.killSignal === "SIGKILL" ? "SIGKILL" : undefined, - timeout: - typeof options?.timeout === "number" ? options.timeout : OPENSHELL_PROBE_TIMEOUT_MS, - }).output, + captureOpenshell( + args, + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + includeStderr: true, + killProcessTreeOnTimeout: options?.killProcessTreeOnTimeout === true, + killSignal: options?.killSignal === "SIGKILL" ? "SIGKILL" : undefined, + timeout: + typeof options?.timeout === "number" + ? options.timeout + : OPENSHELL_PROBE_TIMEOUT_MS, + }, + runtimeSelection, + ), + ).output, confirmMissingSupervisor: (containerId) => isExactlyManagedControlMarker( - requestPinnedGatewaySupervisorAction(sandboxName, "probe", 210000, containerId), + effectivePinnedGatewaySupervisorAction( + sandboxName, + "probe", + 210000, + containerId, + ), "SUPERVISOR_NOT_RUNNING", ), restartRestoredManagedGateway: (containerId) => { const restarted = parseManagedGatewayControlCompletion( - requestPinnedGatewaySupervisorAction(sandboxName, "restart", 210000, containerId), + effectivePinnedGatewaySupervisorAction( + sandboxName, + "restart", + 210000, + containerId, + ), ); if (restarted?.disposition !== "ok") return false; return waitForRecoveredSandboxGateway(sandboxName, { @@ -840,10 +907,11 @@ function recoverSandboxProcesses( initialManagedHealthPassed: true, requireManagedProbe: true, timeoutSeconds: gatewayRecoveryTimeoutSeconds(agent), + runtimeSelection, managedProbeImpl: (name) => confirmRecoveredSandboxGatewayManaged(name, { requestGatewaySupervisorActionImpl: (name, action) => - requestPinnedGatewaySupervisorAction(name, action, 210000, containerId), + effectivePinnedGatewaySupervisorAction(name, action, 210000, containerId), }), }); }, @@ -884,7 +952,13 @@ function recoverSandboxProcesses( // Non-Hermes custom manifests do not yet declare a supported host-side // runtime user. Recover them over SSH so the launch inherits the sandbox // login user instead of creating root-owned agent state under /sandbox. - return recoveredSsh(executeSandboxCommand(sandboxName, agentScript)); + return recoveredSsh( + executeSandboxCommand( + sandboxName, + agentScript, + runtimeSelection ? { runtimeSelection } : DEFAULT_SANDBOX_EXEC_TIMEOUT_MS, + ), + ); } return null; @@ -892,9 +966,12 @@ function recoverSandboxProcesses( export function restartSandboxGateway( sandboxName: string, - { quiet = false, deps = {} }: RestartSandboxGatewayOptions = {}, + { quiet = false, deps = {}, runtimeSelection }: RestartSandboxGatewayOptions = {}, ): GatewayRestartResult { return withUnsupportedHermesPortableGatewayRestartFence(sandboxName, () => { + const defaultSupervisorAction = runtimeSelection + ? refuseHostLocalSupervisorForSelectedRuntime + : executeGatewaySupervisorAction; return withMcpLifecycleLockSync(sandboxName, () => restartSandboxGatewayWithDeps(sandboxName, { quiet, @@ -902,25 +979,44 @@ export function restartSandboxGateway( getSessionAgent: agentRuntime.getSessionAgent, getSandbox: registry.getSandbox, resolveSandboxDashboardPort, - requestGatewaySupervisorAction: executeGatewaySupervisorAction, - executeSandboxExecCommand, + requestGatewaySupervisorAction: defaultSupervisorAction, + executeSandboxExecCommand: (name, command, timeout) => + executeSandboxExecCommand( + name, + command, + timeout, + runtimeSelection ? { runtimeSelection } : {}, + ), waitForRecoveredSandboxGateway: (name, options) => waitForRecoveredSandboxGateway(name, { ...options, initialManagedHealthPassed: true, + runtimeSelection, timeoutSeconds: gatewayRecoveryTimeoutSeconds(agentRuntime.getSessionAgent(name)), managedProbeImpl: (sandboxName) => confirmRecoveredSandboxGatewayManaged(sandboxName, { requestGatewaySupervisorActionImpl: - deps.requestGatewaySupervisorAction ?? executeGatewaySupervisorAction, + deps.requestGatewaySupervisorAction ?? defaultSupervisorAction, }), }), - ensureSandboxPortForward, - ensureHermesDashboardPortForwardIfEnabled, - recoverMessagingHostForward, - recoverDeclaredAgentForwardPorts, + ensureSandboxPortForward: (name) => + ensureSandboxPortForward(name, { runtimeSelection }), + ensureHermesDashboardPortForwardIfEnabled: (name) => + ensureHermesDashboardPortForwardIfEnabled(name, runtimeSelection), + recoverMessagingHostForward: (name, options) => + recoverMessagingHostForward(name, { ...options, runtimeSelection }), + recoverDeclaredAgentForwardPorts: (name, recoveryPort, options) => + recoverDeclaredAgentForwardPorts(name, recoveryPort, { + ...options, + runtimeSelection, + }), printGatewayWedgeDiagnostics, - inspectHermesMcpReconciliationRefusal, + inspectHermesMcpReconciliationRefusal: (name) => + inspectHermesMcpReconciliationRefusal( + name, + undefined, + runtimeSelection, + ), ...deps, }, }), @@ -1034,6 +1130,7 @@ type RecreatedSandboxOpenShellReadyOptions = { nowImpl?: () => number; sleepImpl?: (seconds: number) => void; timeoutSeconds?: number; + runtimeSelection?: OpenShellRuntimeSelection; }; function recreatedSandboxOpenShellReadinessFailureDetail( @@ -1134,12 +1231,18 @@ function waitForRecreatedSandboxOpenShellReadyResult( ready: false, }; } - const result = capture(["sandbox", "exec", "--name", sandboxName, "--", "true"], { - ignoreError: true, - includeStderr: true, - includeStreams: true, - timeout: Math.max(1, Math.min(OPENSHELL_PROBE_TIMEOUT_MS, remainingMs)), - }); + const result = capture( + ["sandbox", "exec", "--name", sandboxName, "--", "true"], + withSelectedOpenShellCommandOptions( + { + ignoreError: true, + includeStderr: true, + includeStreams: true, + timeout: Math.max(1, Math.min(OPENSHELL_PROBE_TIMEOUT_MS, remainingMs)), + }, + options.runtimeSelection, + ), + ); if (result.status === 0 && !result.error) return { ready: true }; const openshellError = normalizeOpenshellStructuredError(String(result.stderr ?? "")); if (openshellError) lastOpenshellError = openshellError; @@ -1304,9 +1407,12 @@ export function waitForRecoveredSandboxGateway( quiet?: boolean; timeoutSeconds?: number; requireManagedProbe?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): boolean { - const probe = options.probeImpl ?? isSandboxGatewayRunning; + const probe = + options.probeImpl ?? + ((name: string) => isSandboxGatewayRunning(name, options.runtimeSelection)); const managedProbe = options.managedProbeImpl ?? (options.probeImpl ? null : confirmRecoveredSandboxGatewayManaged); const sleep = options.sleepImpl ?? sleepSeconds; @@ -1430,6 +1536,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( isWsl: isWslOverride, onRecoveryFailureLayer, probeTiming, + runtimeSelection, }: { quiet?: boolean; requestGatewaySupervisorAction?: typeof executeGatewaySupervisorAction; @@ -1440,10 +1547,20 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( isWsl?: boolean; onRecoveryFailureLayer?: (layer: GatewayRestartFailureLayer | null, detail?: string) => void; probeTiming?: ProcessRecoveryProbeTiming; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ) { const measure = (stage: "processes" | "forward", operation: () => T): T => probeTiming ? probeTiming.measure(stage, operation) : operation(); + const effectiveGatewaySupervisorAction = + runtimeSelection && requestGatewaySupervisorAction === executeGatewaySupervisorAction + ? refuseHostLocalSupervisorForSelectedRuntime + : requestGatewaySupervisorAction; + const effectivePinnedGatewaySupervisorAction = + runtimeSelection && + requestPinnedGatewaySupervisorAction === executeGatewaySupervisorActionPinned + ? refuseHostLocalSupervisorForSelectedRuntime + : requestPinnedGatewaySupervisorAction; const recoveryAgent = agentRuntime.getSessionAgent(sandboxName); const recoveryDisplayName = recoveryAgentDisplayName(sandboxName, recoveryAgent); if (recoveryAgent && !agentRuntime.hasGatewayRuntime(recoveryAgent)) { @@ -1455,7 +1572,9 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( runtime: "terminal" as const, }; } - const running = measure("processes", () => isSandboxGatewayRunningImpl(sandboxName)); + const running = measure("processes", () => + isSandboxGatewayRunningImpl(sandboxName, runtimeSelection), + ); if (running === null) { return { checked: false, wasRunning: null, recovered: false, forwardRecovered: false }; } @@ -1464,7 +1583,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( const enforcement = enforceHermesSecretBoundaryOnRunningGateway( sandboxName, recoveryAgent, - requestGatewaySupervisorAction, + effectiveGatewaySupervisorAction, ); if (enforcement?.refused) { return { @@ -1476,7 +1595,12 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( secretBoundaryReason: enforcement.reason, }; } - const mcpRefusal = processRecoveryMcpReconciliationRefusal(sandboxName, true); + const mcpRefusal = processRecoveryMcpReconciliationRefusal( + sandboxName, + true, + undefined, + runtimeSelection, + ); if (mcpRefusal) return mcpRefusal; } if (running) { @@ -1484,7 +1608,10 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( // owned by another sandbox. Probe and re-establish only when // necessary so the live-and-healthy path stays a no-op. const forwardHealthy = measure("forward", () => - isSandboxForwardHealthy(sandboxName, { isWsl: isWslOverride }), + isSandboxForwardHealthy(sandboxName, { + isWsl: isWslOverride, + runtimeSelection, + }), ); if (forwardHealthy === false) { if (!quiet) { @@ -1493,17 +1620,18 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( console.log(" Re-establishing..."); } const forwardRecovered = measure("forward", () => - ensureSandboxPortForward(sandboxName, { isWsl: isWslOverride }), + ensureSandboxPortForward(sandboxName, { isWsl: isWslOverride, runtimeSelection }), ); const dashboardForwardRecovered = measure("forward", () => - ensureHermesDashboardPortForwardIfEnabled(sandboxName), + ensureHermesDashboardPortForwardIfEnabled(sandboxName, runtimeSelection), ); const messagingForwardRecovered = measure("forward", () => - recoverMessagingHostForward(sandboxName, { quiet }), + recoverMessagingHostForward(sandboxName, { quiet, runtimeSelection }), ); const declaredForwardsRecovered = measure("forward", () => recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet, + runtimeSelection, }), ); const auxiliaryResults = [ @@ -1556,13 +1684,13 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( }; } const dashboardForwardRecovered = measure("forward", () => - ensureHermesDashboardPortForwardIfEnabled(sandboxName), + ensureHermesDashboardPortForwardIfEnabled(sandboxName, runtimeSelection), ); const messagingForwardRecovered = measure("forward", () => - recoverMessagingHostForward(sandboxName, { quiet }), + recoverMessagingHostForward(sandboxName, { quiet, runtimeSelection }), ); const declaredForwardsRecovered = measure("forward", () => - recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet }), + recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet, runtimeSelection }), ); const auxiliaryResults = [ { label: "the Hermes dashboard host forward", recovered: dashboardForwardRecovered }, @@ -1607,9 +1735,10 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( const recovery = measure("processes", () => recoverSandboxProcesses(sandboxName, { quiet, - requestGatewaySupervisorAction, - requestPinnedGatewaySupervisorAction, + requestGatewaySupervisorAction: effectiveGatewaySupervisorAction, + requestPinnedGatewaySupervisorAction: effectivePinnedGatewaySupervisorAction, relaunchManagedSupervisorSessionImpl, + runtimeSelection, onFailureLayer: (layer, detail) => { managedRecoveryFailureLayer = layer; managedRecoveryFailureDetail = detail; @@ -1626,8 +1755,8 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( const relaunch = recovery.kind === "relaunched" ? recovery.relaunch : null; const requestManagedProbe = relaunch ? (name: string, action: "restart" | "recover" | "probe", timeout = 210000) => - requestPinnedGatewaySupervisorAction(name, action, timeout, relaunch.containerId) - : requestGatewaySupervisorAction; + effectivePinnedGatewaySupervisorAction(name, action, timeout, relaunch.containerId) + : effectiveGatewaySupervisorAction; const relaunchedManagedHealth = { failure: null as ReturnType | null, }; @@ -1667,6 +1796,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( quiet, initialManagedHealthPassed: recovery.kind === "managed", requireManagedProbe: recovery.kind === "relaunched", + runtimeSelection, // A legacy keepalive relaunch starts a new OpenClaw container. The // #10153 failure exhausted the ordinary 30-second health budget // during that full recreation. Give only this OpenClaw transition @@ -1706,7 +1836,14 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( const rollbackUnconfirmed = recoveryFailureDetail !== gatewayWaitFailureDetail; if (!quiet) { console.error(" Gateway process started but is not responding."); - printGatewayWedgeDiagnostics(sandboxName, executeSandboxExecCommand); + printGatewayWedgeDiagnostics(sandboxName, (name, command) => + executeSandboxExecCommand( + name, + command, + DEFAULT_SANDBOX_EXEC_TIMEOUT_MS, + runtimeSelection ? { runtimeSelection } : {}, + ), + ); console.error(" Check /tmp/gateway.log inside the sandbox for details."); if (rollbackUnconfirmed) { console.error( @@ -1741,6 +1878,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( beforeProbe: relaunch ? (timeoutMs) => confirmRelaunchedManagedHealth?.(timeoutMs) ?? null : undefined, + runtimeSelection, }; const readiness = waitForRecreatedSandboxOpenShellReadyImpl === waitForRecreatedSandboxOpenShellReady @@ -1791,13 +1929,19 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( ); if (finalizationFailure) return finalizationFailure; } - const mcpRefusal = processRecoveryMcpReconciliationRefusal(sandboxName, false); + const mcpRefusal = processRecoveryMcpReconciliationRefusal( + sandboxName, + false, + undefined, + runtimeSelection, + ); if (mcpRefusal) return mcpRefusal; const forwardRecovered = measure("forward", () => ensureSandboxPortForward(sandboxName, { afterSuccess: confirmRelaunchedManagedHealthForForward ?? undefined, beforeStart: confirmRelaunchedManagedHealthForForward ?? undefined, isWsl: isWslOverride, + runtimeSelection, }), ); if (!forwardRecovered && relaunchedManagedHealth.failure) { @@ -1814,13 +1958,13 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( }; } const dashboardForwardRecovered = measure("forward", () => - ensureHermesDashboardPortForwardIfEnabled(sandboxName), + ensureHermesDashboardPortForwardIfEnabled(sandboxName, runtimeSelection), ); const messagingForwardRecovered = measure("forward", () => - recoverMessagingHostForward(sandboxName, { quiet }), + recoverMessagingHostForward(sandboxName, { quiet, runtimeSelection }), ); const declaredForwardsRecovered = measure("forward", () => - recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet }), + recoverDeclaredAgentForwardPorts(sandboxName, recoveryPort, { quiet, runtimeSelection }), ); const auxiliaryResults = [ { label: "the Hermes dashboard host forward", recovered: dashboardForwardRecovered }, @@ -1890,6 +2034,7 @@ export function checkAndRecoverSandboxProcesses( isWsl?: boolean; onRecoveryFailureLayer?: (layer: GatewayRestartFailureLayer | null, detail?: string) => void; probeTiming?: ProcessRecoveryProbeTiming; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ) { return withMcpLifecycleLockSync(sandboxName, () => diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index 12ad3571f78..88a91717b5a 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -9,6 +9,7 @@ import { isPolicyObservationError, PolicyObservationError, } from "../../adapters/openshell/policy-state"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { formatOpenShellPolicyRecoveryAction } from "../../gateway-start-guidance"; import type { WebSearchConfig } from "../../inference/web-search"; import type { SandboxMessagingPlan } from "../../messaging"; @@ -44,6 +45,7 @@ export interface RebuildBackupPhaseInput { webSearchConfig: WebSearchConfig | null; log: RebuildLog; bail: RebuildBail; + runtimeSelection?: OpenShellRuntimeSelection; } export interface RebuildBackupPhaseResult { @@ -62,12 +64,17 @@ function bailForUnsafeOpenClawPluginProvenance(input: RebuildBackupPhaseInput): return input.bail("Custom-image OpenClaw plugin provenance is unavailable."); } -export function captureRebuildPolicyDocument(sandboxName: string, gatewayName: string): string { +export function captureRebuildPolicyDocument( + sandboxName: string, + gatewayName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): string { let policy: string; try { policy = captureRecordedSandboxBasePolicy( sandboxName, "capture the live policy before sandbox replacement", + runtimeSelection, ); } catch (error) { if (!isPolicyObservationError(error)) throw error; @@ -136,7 +143,11 @@ export function runRebuildBackupPhase( const capturedPolicy = input.staleRecovery || preparedRetainedPolicy ? null - : captureRebuildPolicyDocument(input.sandboxName, input.gatewayName); + : captureRebuildPolicyDocument( + input.sandboxName, + input.gatewayName, + input.runtimeSelection, + ); let backupManifest = preparedRecoveryManifest ?? backupStateForRebuild( @@ -200,7 +211,8 @@ export function runRebuildBackupPhase( }; } const policy = - capturedPolicy ?? captureRebuildPolicyDocument(input.sandboxName, input.gatewayName); + capturedPolicy ?? + captureRebuildPolicyDocument(input.sandboxName, input.gatewayName, input.runtimeSelection); if (backupManifest && !retainedPolicy) { try { backupManifest = writeRebuildPolicyHandoff(backupManifest, policy); diff --git a/src/lib/actions/sandbox/rebuild-config-hash.test.ts b/src/lib/actions/sandbox/rebuild-config-hash.test.ts index 1b24b4ab524..22f8e305d48 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash.test.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash.test.ts @@ -7,13 +7,72 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as processRecovery from "./process-recovery"; +import { + refreshMutableOpenClawConfigHashAfterPostRestoreWrites, + verifyFinalMutableOpenClawConfigHash, +} from "./rebuild-config-hash"; import { buildRefreshMutableOpenClawConfigHashCommand, buildVerifyMutableOpenClawConfigHashCommand, } from "./rebuild-config-hash-command"; +const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", +} as const; + +describe("OpenClaw rebuild config hash target selection", () => { + beforeEach(() => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("refreshes the config hash on the selected target instead of the ambient target (#10514)", () => { + const execute = vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue({ + status: 0, + stdout: "", + stderr: "", + }); + + expect( + refreshMutableOpenClawConfigHashAfterPostRestoreWrites("alpha", vi.fn(), runtimeSelection), + ).toBe(true); + expect(execute).toHaveBeenCalledExactlyOnceWith( + "alpha", + buildRefreshMutableOpenClawConfigHashCommand(), + { runtimeSelection }, + ); + expect(process.env.OPENSHELL_GATEWAY).toBe("hostile-gateway"); + }); + + it("verifies the config hash on the selected target instead of the ambient target (#10514)", () => { + const execute = vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue({ + status: 0, + stdout: "", + stderr: "", + }); + + expect(verifyFinalMutableOpenClawConfigHash("alpha", vi.fn(), runtimeSelection)).toBe(true); + expect(execute).toHaveBeenCalledExactlyOnceWith( + "alpha", + buildVerifyMutableOpenClawConfigHashCommand(), + { runtimeSelection }, + ); + expect(process.env.OPENSHELL_GATEWAY).toBe("hostile-gateway"); + }); +}); + function sha256Hex(filePath: string): string { return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } diff --git a/src/lib/actions/sandbox/rebuild-config-hash.ts b/src/lib/actions/sandbox/rebuild-config-hash.ts index fd2c2ce288b..8b14bfae80f 100644 --- a/src/lib/actions/sandbox/rebuild-config-hash.ts +++ b/src/lib/actions/sandbox/rebuild-config-hash.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime"; import { R, YW } from "../../cli/terminal-style"; import { redact } from "../../security/redact"; import { executeSandboxCommand } from "./process-recovery"; @@ -14,8 +15,13 @@ export { buildRefreshMutableOpenClawConfigHashCommand }; export function refreshMutableOpenClawConfigHashAfterPostRestoreWrites( sandboxName: string, log: (msg: string) => void, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean { - const result = executeSandboxCommand(sandboxName, buildRefreshMutableOpenClawConfigHashCommand()); + const result = runtimeSelection + ? executeSandboxCommand(sandboxName, buildRefreshMutableOpenClawConfigHashCommand(), { + runtimeSelection, + }) + : executeSandboxCommand(sandboxName, buildRefreshMutableOpenClawConfigHashCommand()); if (result && result.status === 0) { log("Mutable OpenClaw config hash refreshed after post-restore config writes"); return true; @@ -31,8 +37,13 @@ export function refreshMutableOpenClawConfigHashAfterPostRestoreWrites( export function verifyFinalMutableOpenClawConfigHash( sandboxName: string, log: (msg: string) => void, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean { - const result = executeSandboxCommand(sandboxName, buildVerifyMutableOpenClawConfigHashCommand()); + const result = runtimeSelection + ? executeSandboxCommand(sandboxName, buildVerifyMutableOpenClawConfigHashCommand(), { + runtimeSelection, + }) + : executeSandboxCommand(sandboxName, buildVerifyMutableOpenClawConfigHashCommand()); if (result && result.status === 0) { log("Final mutable OpenClaw config hash verified after post-restore finalization"); return true; diff --git a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts index 2ee4c632308..6dbc3398991 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../../inference/web-search"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import type { Session } from "../../state/onboard-session"; import type { ToolDisclosure } from "../../tool-disclosure"; @@ -19,7 +20,11 @@ import type { RebuildAgentBaseImageOptions, RebuildSandboxEntry } from "./rebuil import type { RebuildResumeConfig } from "./rebuild-resume-config"; type DcodeRebuildOrchestratorDeps = { - checkGatewaySchema(sandboxName: string, bail: DcodeRebuildPreflightBail): boolean; + checkGatewaySchema( + sandboxName: string, + bail: DcodeRebuildPreflightBail, + runtimeSelection?: OpenShellRuntimeSelection, + ): boolean; preflightCredentials( sandboxName: string, entry: RebuildSandboxEntry, @@ -48,7 +53,7 @@ export type DcodeRebuildOrchestrator = { readonly preparedReplacement: PreparedDcodeReplacement | null; run(action: () => Promise): Promise; runSync(action: () => T): T; - preflightCredentials(): Promise; + preflightCredentials(runtimeSelection?: OpenShellRuntimeSelection): Promise; prepareImage( resumeConfig: RebuildResumeConfig, webSearchConfig: WebSearchConfig | null, @@ -57,6 +62,7 @@ export type DcodeRebuildOrchestrator = { skipLiveRoute: boolean, gatewayPort: number, baseImageOptions?: RebuildAgentBaseImageOptions, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise; revalidateBeforeDelete( resumeConfig: RebuildResumeConfig, @@ -64,6 +70,7 @@ export type DcodeRebuildOrchestrator = { dcodeAutoApprovalMode: DcodeAutoApprovalMode, skipLiveRoute: boolean, gatewayPort: number, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise; checkAtDeleteEdge( resumeConfig: RebuildResumeConfig, @@ -71,6 +78,7 @@ export type DcodeRebuildOrchestrator = { dcodeAutoApprovalMode: DcodeAutoApprovalMode, skipLiveRoute: boolean, gatewayPort: number, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise<{ ok: true } | { ok: false; message: string; code?: number }>; clearManagedCustomDockerfile(session: Session): void; storedDockerfile(sessionMatchesSandbox: boolean, session: Session | null): string | null; @@ -135,15 +143,21 @@ export function createDcodeRebuildOrchestrator( }, run, runSync, - preflightCredentials: () => + preflightCredentials: (runtimeSelection) => run(async () => { if (scope.enabled) { if ( - !(await ensureDcodeRebuildTargetGatewaySelected(sandboxName, entry, log, scope.bail)) + !(await ensureDcodeRebuildTargetGatewaySelected( + sandboxName, + entry, + log, + scope.bail, + runtimeSelection, + )) ) { return false; } - if (!deps.checkGatewaySchema(sandboxName, scope.bail)) return false; + if (!deps.checkGatewaySchema(sandboxName, scope.bail, runtimeSelection)) return false; } return deps.preflightCredentials(sandboxName, entry, log, scope.bail); }), @@ -155,6 +169,7 @@ export function createDcodeRebuildOrchestrator( skipLiveRoute, gatewayPort, baseImageOptions, + runtimeSelection, ) => run(async () => { if (!scope.enabled) { @@ -171,7 +186,9 @@ export function createDcodeRebuildOrchestrator( gatewayPort, log, bail: scope.bail, - checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, scope.bail), + checkGatewaySchema: (selection) => + deps.checkGatewaySchema(sandboxName, scope.bail, selection), + runtimeSelection, }); } const replacement = await prepareDcodeReplacementBeforeMutation({ @@ -186,7 +203,9 @@ export function createDcodeRebuildOrchestrator( baseImageOptions, log, bail: scope.bail, - checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, scope.bail), + checkGatewaySchema: (selection) => + deps.checkGatewaySchema(sandboxName, scope.bail, selection), + runtimeSelection, }); if (!replacement) { scope.cleanup(); @@ -201,6 +220,7 @@ export function createDcodeRebuildOrchestrator( dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, + runtimeSelection, ) => run(async () => { if (!scope.enabled) return true; @@ -215,7 +235,9 @@ export function createDcodeRebuildOrchestrator( gatewayPort, log, bail: scope.bail, - checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, scope.bail), + checkGatewaySchema: (selection) => + deps.checkGatewaySchema(sandboxName, scope.bail, selection), + runtimeSelection, }); } const replacement = scope.preparedReplacement; @@ -230,7 +252,9 @@ export function createDcodeRebuildOrchestrator( gatewayPort, log, bail: scope.bail, - checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, scope.bail), + checkGatewaySchema: (selection) => + deps.checkGatewaySchema(sandboxName, scope.bail, selection), + runtimeSelection, replacement, }); }), @@ -240,6 +264,7 @@ export function createDcodeRebuildOrchestrator( dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, + runtimeSelection, ) => { if (!scope.enabled) return { ok: true }; const replacement = scope.preparedReplacement; @@ -261,7 +286,9 @@ export function createDcodeRebuildOrchestrator( gatewayPort, log, bail: capturedBail, - checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, capturedBail), + checkGatewaySchema: (selection) => + deps.checkGatewaySchema(sandboxName, capturedBail, selection), + runtimeSelection, }) : revalidateDcodeReplacementAtMutationEdge({ sandboxName, @@ -273,7 +300,9 @@ export function createDcodeRebuildOrchestrator( gatewayPort, log, bail: capturedBail, - checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, capturedBail), + checkGatewaySchema: (selection) => + deps.checkGatewaySchema(sandboxName, capturedBail, selection), + runtimeSelection, replacement: replacement!, })); if (!valid) { diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index cd71f7c049a..8f5e5c82f0a 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts @@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util"; import { dockerImageInspectFormat, dockerRmi } from "../../adapters/docker"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import type { TrustedRemoteBaseImageOverride } from "../../agent/base-image"; import { loadAgent } from "../../agent/defs"; import { @@ -13,7 +14,7 @@ import { pinTrustedAgentRemoteBaseImageOverrideForOperation, } from "../../agent/onboard"; import { RD as _RD, R } from "../../cli/terminal-style"; -import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +import { snapshotOpenShellEnv } from "../../gateway-runtime-action"; import * as nim from "../../inference/nim"; import type { WebSearchConfig } from "../../inference/web-search"; import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; @@ -38,7 +39,11 @@ import { type ResolvedDcodeRebuildTarget, resolveDcodeRebuildTarget, } from "./rebuild-dcode-target"; -import type { RebuildAgentBaseImageOptions, RebuildSandboxEntry } from "./rebuild-flow-helpers"; +import { + ensureRebuildTargetGatewaySelected, + type RebuildAgentBaseImageOptions, + type RebuildSandboxEntry, +} from "./rebuild-flow-helpers"; import { disposePreparedDcodeRebuildImage, type PreparedDcodeRebuildImage, @@ -78,7 +83,8 @@ export type DcodeReplacementPreflightInput = { gatewayPort?: number; log(message: string): void; bail: DcodeRebuildPreflightBail; - checkGatewaySchema(): boolean; + checkGatewaySchema(runtimeSelection?: OpenShellRuntimeSelection): boolean; + runtimeSelection?: OpenShellRuntimeSelection; }; export type DcodeReplacementPreparationInput = DcodeReplacementPreflightInput & { @@ -102,9 +108,8 @@ export function createDcodeRebuildPreflightScope( bail: DcodeRebuildPreflightBail, env: NodeJS.ProcessEnv = process.env, ): DcodeRebuildPreflightScope { - const previousOpenshellGateway = env.OPENSHELL_GATEWAY; + const restoreOpenShellEnv = snapshotOpenShellEnv(env); let preparedReplacement: PreparedDcodeReplacement | null = null; - let gatewayRestored = false; let cleaned = false; const cleanup = () => { if (!enabled || cleaned) return; @@ -115,11 +120,7 @@ export function createDcodeRebuildPreflightScope( console.warn(" Warning: temporary DCode rebuild inputs could not be fully removed."); } } finally { - if (!gatewayRestored) { - gatewayRestored = true; - if (previousOpenshellGateway === undefined) delete env.OPENSHELL_GATEWAY; - else env.OPENSHELL_GATEWAY = previousOpenshellGateway; - } + restoreOpenShellEnv(); cleaned = disposed; } }; @@ -172,33 +173,14 @@ export async function ensureDcodeRebuildTargetGatewaySelected( entry: RebuildSandboxEntry, log: (message: string) => void, bail: DcodeRebuildPreflightBail, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise { - let gatewayName: string; try { - gatewayName = resolveSandboxGatewayName(entry); + resolveSandboxGatewayName(entry); } catch (error) { fail(error instanceof Error ? error.message : String(error), bail); } - - const recovery = await recoverNamedGatewayRuntime({ - gatewayName, - recoverableStates: ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"], - }); - const beforeState = recovery.before?.state ?? "unknown"; - const afterState = recovery.after?.state ?? "unknown"; - if (!recovery.recovered || afterState !== "healthy_named") { - console.error(""); - console.error( - ` ${_RD}Rebuild preflight failed:${R} could not select the target gateway '${gatewayName}'.`, - ); - console.error(` Gateway state before: ${beforeState}; after: ${afterState}.`); - console.error(" Sandbox is untouched — no data was lost."); - bail(`Could not select healthy gateway '${gatewayName}' for sandbox '${sandboxName}'`); - return false; - } - process.env.OPENSHELL_GATEWAY = gatewayName; - log(`Pinned rebuild subprocesses to target gateway '${gatewayName}'`); - return true; + return ensureRebuildTargetGatewaySelected(sandboxName, entry, log, bail, runtimeSelection); } function resolveTarget( @@ -218,11 +200,13 @@ function requireInferenceRoute( sandboxName: string, target: ResolvedDcodeRebuildTarget, bail: DcodeRebuildPreflightBail, + runtimeSelection?: OpenShellRuntimeSelection, ): void { const result = probeSandboxInferenceInvocation({ sandboxName, agentName: target.agent, ...target, + ...(runtimeSelection ? { runtimeSelection } : {}), }); if (!result.ok) { fail( @@ -513,6 +497,7 @@ export async function prepareDcodeReplacementBeforeMutation( gatewayPort, log, bail, + runtimeSelection, } = input; let buildContext: PreparedDcodeRebuildImage | null = null; let pinnedBase: PinnedDcodeBaseImage | null = null; @@ -527,7 +512,7 @@ export async function prepareDcodeReplacementBeforeMutation( const session = loadMatchingDcodeSession(sandboxName); const target = resolveTarget(entry, resumeConfig, bail, gatewayPort); - if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail); + if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail, runtimeSelection); pinnedBase = resolvePinnedDcodeBaseImage(bail, input.baseImageOptions); const sandboxGpuConfig = getRecordedGpuConfig(sandboxName, entry, session); @@ -552,11 +537,19 @@ export async function prepareDcodeReplacementBeforeMutation( if (!imageResult.ok) fail(imageResult.detail, bail); buildContext = imageResult.prepared; - if (!(await ensureDcodeRebuildTargetGatewaySelected(sandboxName, entry, log, bail))) { + if ( + !(await ensureDcodeRebuildTargetGatewaySelected( + sandboxName, + entry, + log, + bail, + runtimeSelection, + )) + ) { return null; } - if (!input.checkGatewaySchema()) return null; - if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail); + if (!input.checkGatewaySchema(runtimeSelection)) return null; + if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail, runtimeSelection); requireCurrentTarget(sandboxName, entry, target, resumeConfig, bail, gatewayPort); if (!verifyPreparedDcodeRebuildImage(buildContext) || !pinnedBase.verify()) { fail("the prepared DCode replacement inputs changed during preflight", bail); @@ -583,8 +576,17 @@ export async function prepareDcodeReplacementBeforeMutation( export async function revalidateDcodeReplacementAtMutationEdge( input: DcodeReplacementPreflightInput & { replacement: PreparedDcodeReplacement }, ): Promise { - const { sandboxName, entry, resumeConfig, skipLiveRoute, gatewayPort, log, bail, replacement } = - input; + const { + sandboxName, + entry, + resumeConfig, + skipLiveRoute, + gatewayPort, + log, + bail, + replacement, + runtimeSelection, + } = input; const target = resolveTarget(entry, resumeConfig, bail, gatewayPort); if (replacement.gatewayName !== target.gatewayName) { fail("the prepared DCode gateway changed before deletion", bail); @@ -595,11 +597,19 @@ export async function revalidateDcodeReplacementAtMutationEdge( if (replacement.dcodeAutoApprovalMode !== input.dcodeAutoApprovalMode) { fail("the prepared DCode auto-approval mode changed before deletion", bail); } - if (!(await ensureDcodeRebuildTargetGatewaySelected(sandboxName, entry, log, bail))) { + if ( + !(await ensureDcodeRebuildTargetGatewaySelected( + sandboxName, + entry, + log, + bail, + runtimeSelection, + )) + ) { return false; } - if (!input.checkGatewaySchema()) return false; - if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail); + if (!input.checkGatewaySchema(runtimeSelection)) return false; + if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail, runtimeSelection); requireCurrentTarget(sandboxName, entry, target, resumeConfig, bail, gatewayPort); if (!replacement.verify()) { fail("the prepared DCode replacement inputs changed before deletion", bail); @@ -615,13 +625,30 @@ export async function revalidateDcodeReplacementAtMutationEdge( export async function revalidateManagedDcodeWorkloadAtMutationEdge( input: DcodeReplacementPreflightInput, ): Promise { - const { sandboxName, entry, resumeConfig, skipLiveRoute, gatewayPort, log, bail } = input; + const { + sandboxName, + entry, + resumeConfig, + skipLiveRoute, + gatewayPort, + log, + bail, + runtimeSelection, + } = input; const target = resolveTarget(entry, resumeConfig, bail, gatewayPort); - if (!(await ensureDcodeRebuildTargetGatewaySelected(sandboxName, entry, log, bail))) { + if ( + !(await ensureDcodeRebuildTargetGatewaySelected( + sandboxName, + entry, + log, + bail, + runtimeSelection, + )) + ) { return false; } - if (!input.checkGatewaySchema()) return false; - if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail); + if (!input.checkGatewaySchema(runtimeSelection)) return false; + if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail, runtimeSelection); requireCurrentTarget(sandboxName, entry, target, resumeConfig, bail, gatewayPort); return true; } diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts index 1524f78ecca..ad063c62588 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ runOpenshell: vi.fn( ( _args: string[], + _options?: Record, ): { status: number | null; stdout: string; @@ -224,6 +225,7 @@ describe("rebuild destroy phase", () => { false, true, expect.any(Function), + undefined, ); }); @@ -283,8 +285,22 @@ describe("rebuild destroy phase", () => { expect(onDeleted).toHaveBeenCalledOnce(); }); - it("pins deletion to the recorded gateway when ambient selection changes (#7062)", async () => { + it("pins deletion and the delete-edge user-file probe when ambient selection changes (#10514)", async () => { vi.stubEnv("OPENSHELL_GATEWAY", "nemoclaw-29080"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + const runtimeSelection = { + gatewayName: "nemoclaw-19080", + workspace: "default", + localTlsDir: "/authority/tls", + }; + mocks.prepareMcpForRebuild.mockResolvedValue({ + entries: [], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + runtimeSelection, + }); mocks.getSandbox.mockReturnValue({ name: "alpha", agent: "openclaw", @@ -304,6 +320,7 @@ describe("rebuild destroy phase", () => { recreateJournal: stubRecreateJournal(), backupManifest: null, force: true, + runtimeSelection, log: vi.fn(), bail: vi.fn((message: string): never => { throw new Error(message); @@ -313,8 +330,59 @@ describe("rebuild destroy phase", () => { expect(mocks.runOpenshell).toHaveBeenCalledWith( ["sandbox", "delete", "-g", "nemoclaw-19080", "alpha"], - expect.objectContaining({ ignoreError: true }), + expect.objectContaining({ + ignoreError: true, + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + expect(mocks.warnUnpreservedUserManagedFiles).toHaveBeenCalledWith( + "alpha", + expect.any(Function), + runtimeSelection, + ); + const deleteOptions = mocks.runOpenshell.mock.calls.find( + ([args]) => args[0] === "sandbox" && args[1] === "delete", + )?.[1] as { env?: Record } | undefined; + expect(deleteOptions?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["sandbox", "get", "-g", "nemoclaw-19080", "alpha"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-19080", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + }); + + it("refuses deletion when the frozen OpenShell target does not match (#10514)", async () => { + await expect( + runRebuildDestroyPhase({ + sandboxName: "alpha", + sandboxEntry: { name: "alpha", agent: "openclaw", gatewayName: "nemoclaw" }, + staleRecovery: false, + recreateJournal: stubRecreateJournal(), + backupManifest: null, + force: true, + runtimeSelection: { gatewayName: "nemoclaw-19080", workspace: "default" }, + log: vi.fn(), + bail: vi.fn((message: string): never => { + throw new Error(message); + }), + onDeleted: vi.fn(), + }), + ).rejects.toThrow( + "Rebuild delete target gateway 'nemoclaw' does not match recorded OpenShell gateway 'nemoclaw-19080'. NemoClaw did not delete the original sandbox. Restore recorded gateway 'nemoclaw-19080', confirm it is healthy, then retry.", ); + + expectNoSandboxDelete(mocks.runOpenshell); }); it.each([ @@ -376,6 +444,7 @@ describe("rebuild destroy phase", () => { "alpha", [{ server: "github" }], [], + undefined, ); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); expect(mocks.stopNimContainer).not.toHaveBeenCalled(); @@ -414,7 +483,12 @@ describe("rebuild destroy phase", () => { expect(revalidateBeforeDelete).toHaveBeenCalledOnce(); expect(mocks.runOpenshell).not.toHaveBeenCalled(); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); - expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith("alpha", [], []); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith( + "alpha", + [], + [], + undefined, + ); expect(mocks.stopNimContainer).not.toHaveBeenCalled(); expect(mocks.stopNimContainerByName).not.toHaveBeenCalled(); }); @@ -454,7 +528,12 @@ describe("rebuild destroy phase", () => { expect(revalidateBeforeDelete.mock.invocationCallOrder[0]).toBeLessThan( mocks.runOpenshell.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); - expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith("alpha", [], []); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith( + "alpha", + [], + [], + undefined, + ); expect(mocks.teardownSandboxDashboardForward).not.toHaveBeenCalled(); expect(mocks.restoreSandboxLaunchForwards).not.toHaveBeenCalled(); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); @@ -1024,6 +1103,7 @@ describe("rebuild destroy phase", () => { "alpha", [{ providerName: "nemoclaw-mcp-alpha-github" }], [{ server: "github" }], + undefined, ); expect(mocks.runOpenshell).not.toHaveBeenCalledWith( ["sandbox", "delete", "-g", "nemoclaw", "alpha"], diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index dedbad6db9d..e6d7c7a0a82 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { buildSelectedOpenShellSubprocessEnv } from "../../adapters/openshell/command-argv"; import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import { G, R } from "../../cli/terminal-style"; import { waitUntil } from "../../core/wait"; @@ -41,10 +43,13 @@ export interface RebuildDestroyPhaseInput { log: RebuildLog; bail: RebuildBail; force?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; validateAfterMcpPreparation?: ( preparation: McpRebuildPreparation, ) => Promise; - validateAtDeleteEdge?: () => RebuildDeleteValidationResult; + validateAtDeleteEdge?: ( + runtimeSelection?: OpenShellRuntimeSelection, + ) => RebuildDeleteValidationResult; cleanupDockerOrphanAfterDelete?: () => void; onDeleted: () => void; onDeleteStateAmbiguous?: () => void; @@ -79,6 +84,7 @@ interface RebuildDeleteAbsenceDeps { }; now?: () => number; sleep?: (milliseconds: number) => void; + runtimeSelection?: OpenShellRuntimeSelection; } const REBUILD_DELETE_ABSENCE_MAX_ATTEMPTS = 20; @@ -124,6 +130,9 @@ export function waitForRebuildDeleteAbsence( log: RebuildLog, deps: RebuildDeleteAbsenceDeps = {}, ): boolean { + if (deps.runtimeSelection && deps.runtimeSelection.gatewayName !== gatewayName) { + throw new Error("Rebuild delete gateway does not match the frozen OpenShell target."); + } const now = deps.now ?? Date.now; const deadlineMs = now() + OPENSHELL_PROBE_TIMEOUT_MS; const captureSandboxGet = @@ -134,6 +143,12 @@ export function waitForRebuildDeleteAbsence( includeStderr: true, includeStreams: true, timeout: timeoutMs, + ...(deps.runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(deps.runtimeSelection), + replaceEnv: true, + } + : {}), }); return probe; }); @@ -180,6 +195,7 @@ function reconcileFailedSandboxDelete( sandboxName: string, sandboxEntry: RebuildSandboxEntry, log: RebuildLog, + runtimeSelection?: OpenShellRuntimeSelection, ): PostDeleteReconciliation { let gatewayName: string; try { @@ -188,6 +204,10 @@ function reconcileFailedSandboxDelete( log("Post-delete reconciliation could not resolve the recorded sandbox gateway."); return { state: "ambiguous", phase: null, status: null }; } + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + log("Post-delete reconciliation target does not match the frozen OpenShell target."); + return { state: "ambiguous", phase: null, status: null }; + } let probe: ReturnType; try { @@ -195,6 +215,12 @@ function reconcileFailedSandboxDelete( ignoreError: true, stdio: ["ignore", "pipe", "pipe"], timeout: OPENSHELL_PROBE_TIMEOUT_MS, + ...(runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}), }); } catch { log(`Post-delete reconciliation could not query recorded gateway '${gatewayName}'.`); @@ -275,6 +301,7 @@ export async function runRebuildDestroyPhase( staleRecovery, input.force === true, bail, + input.runtimeSelection, ); return preparation; }, @@ -283,7 +310,9 @@ export async function runRebuildDestroyPhase( // fingerprints match the registry. Probe afterward so a Deep Agents // user `.mcp.json` is not confused with the separate managed projection. // This can block on SSH, so it must finish before the final DCode check. - if (!staleRecovery) warnUnpreservedUserManagedFiles(sandboxName, log); + if (!staleRecovery) { + warnUnpreservedUserManagedFiles(sandboxName, log, preparation.runtimeSelection); + } if (validateAfterMcpPreparation) { let validation: RebuildDeleteValidationResult; try { @@ -301,6 +330,7 @@ export async function runRebuildDestroyPhase( sandboxName, preparation.detachedProviderEntries, preparation.scrubbedAdapterEntries, + preparation.runtimeSelection, ); bail( mcpRecoveryFailure @@ -319,6 +349,24 @@ export async function runRebuildDestroyPhase( if (!mcpPreparation) return null; const rebuildDetachedMcpProviderEntries = mcpPreparation.detachedProviderEntries; const rebuildScrubbedMcpAdapterEntries = mcpPreparation.scrubbedAdapterEntries; + const rebuildMcpRuntimeSelection = input.runtimeSelection ?? mcpPreparation.runtimeSelection; + if ( + rebuildMcpRuntimeSelection && + rebuildMcpRuntimeSelection.gatewayName !== deleteTarget.gatewayName + ) { + const mcpRecoveryFailure = await reattachMcpAfterDeleteFailure( + sandboxName, + rebuildDetachedMcpProviderEntries, + rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, + ); + bail( + mcpRecoveryFailure + ? `Rebuild delete target gateway '${deleteTarget.gatewayName}' does not match recorded OpenShell gateway '${rebuildMcpRuntimeSelection.gatewayName}'. NemoClaw did not delete the original sandbox. MCP provider recovery also failed: ${mcpRecoveryFailure}. Restore recorded gateway '${rebuildMcpRuntimeSelection.gatewayName}', confirm it is healthy, then retry.` + : `Rebuild delete target gateway '${deleteTarget.gatewayName}' does not match recorded OpenShell gateway '${rebuildMcpRuntimeSelection.gatewayName}'. NemoClaw did not delete the original sandbox. Restore recorded gateway '${rebuildMcpRuntimeSelection.gatewayName}', confirm it is healthy, then retry.`, + ); + return null; + } // Exec-unavailable recovery deliberately made no MCP mutation during // preparation. Re-prove target, policy, provider, and registry state while @@ -337,6 +385,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); const detail = error instanceof Error ? error.message : String(error); bail( @@ -355,6 +404,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); bail( mcpRecoveryFailure @@ -367,7 +417,7 @@ export async function runRebuildDestroyPhase( if (validateAtDeleteEdge) { let validation: RebuildDeleteValidationResult; try { - validation = validateAtDeleteEdge(); + validation = validateAtDeleteEdge(rebuildMcpRuntimeSelection); } catch (error) { const detail = error instanceof Error ? error.message : String(error); log(`Unexpected delete-edge validation failure: ${redactFull(detail)}`); @@ -381,6 +431,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); bail( mcpRecoveryFailure @@ -403,6 +454,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); const detail = error instanceof Error ? error.message : String(error); bail( @@ -423,12 +475,23 @@ export async function runRebuildDestroyPhase( : runOpenshell(["sandbox", "delete", "-g", gatewayName, sandboxName], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"], + ...(rebuildMcpRuntimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(rebuildMcpRuntimeSelection), + replaceEnv: true, + } + : {}), }); const alreadyGone = deleteResult === null || getSandboxDeleteOutcome(deleteResult).alreadyGone; if (deleteResult) log(`Delete result: exit=${deleteResult.status}, alreadyGone=${alreadyGone}`); let deletionConfirmed = alreadyGone; if (deleteResult && deleteResult.status !== 0) { - const reconciledDelete = reconcileFailedSandboxDelete(sandboxName, input.sandboxEntry, log); + const reconciledDelete = reconcileFailedSandboxDelete( + sandboxName, + input.sandboxEntry, + log, + rebuildMcpRuntimeSelection, + ); if (reconciledDelete.state === "deleted") { log("Delete returned nonzero, but exact post-delete state confirms sandbox removal."); deletionConfirmed = true; @@ -441,6 +504,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); if (mcpRecoveryFailure) { console.error( @@ -479,7 +543,9 @@ export async function runRebuildDestroyPhase( return null; } } - deletionConfirmed ||= waitForRebuildDeleteAbsence(sandboxName, gatewayName, log); + deletionConfirmed ||= waitForRebuildDeleteAbsence(sandboxName, gatewayName, log, { + runtimeSelection: rebuildMcpRuntimeSelection, + }); if (!deletionConfirmed) { console.error( " Sandbox delete was accepted, but OpenShell did not confirm that the sandbox is absent.", diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts index cf323378778..4dcb2f29bf3 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { restoreEnvBulk } from "../../../../test/helpers/env-test-helpers"; import * as dockerImage from "../../adapters/docker/image"; import * as agentDefs from "../../agent/defs"; import * as agentOnboard from "../../agent/onboard"; @@ -65,20 +66,30 @@ function makeBail(): (msg: string, code?: number) => never { } describe("rebuild target gateway preflight", () => { - const priorGateway = process.env.OPENSHELL_GATEWAY; + const priorOpenShellEnv = { + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY, + OPENSHELL_GATEWAY_ENDPOINT: process.env.OPENSHELL_GATEWAY_ENDPOINT, + OPENSHELL_LOCAL_TLS_DIR: process.env.OPENSHELL_LOCAL_TLS_DIR, + OPENSHELL_TOKEN: process.env.OPENSHELL_TOKEN, + OPENSHELL_WORKSPACE: process.env.OPENSHELL_WORKSPACE, + }; afterEach(() => { vi.restoreAllMocks(); - switch (priorGateway) { - case undefined: - delete process.env.OPENSHELL_GATEWAY; - break; - default: - process.env.OPENSHELL_GATEWAY = priorGateway; - } + restoreEnvBulk(priorOpenShellEnv); }); it("health-checks and pins the sandbox's persisted gateway", async () => { + process.env.OPENSHELL_GATEWAY = "hostile-gateway"; + process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://hostile.invalid"; + process.env.OPENSHELL_LOCAL_TLS_DIR = "/hostile/tls"; + process.env.OPENSHELL_TOKEN = "hostile-token"; + process.env.OPENSHELL_WORKSPACE = "hostile-workspace"; + const runtimeSelection = { + gatewayName: "nemoclaw-19080", + localTlsDir: "/authority/tls", + workspace: "default", + }; const recover = vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockResolvedValue({ recovered: true, before: { state: "connected_other", status: "", gatewayInfo: "", activeGateway: null }, @@ -92,11 +103,54 @@ describe("rebuild target gateway preflight", () => { { name: "alpha", gatewayName: "nemoclaw-19080", gatewayPort: 19080 }, () => undefined, makeBail(), + runtimeSelection, + ), + ).resolves.toBe(true); + + expect(recover).toHaveBeenCalledWith({ + gatewayName: "nemoclaw-19080", + runtimeSelection, + }); + expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw-19080"); + expect(process.env.OPENSHELL_WORKSPACE).toBe("default"); + expect(process.env.OPENSHELL_LOCAL_TLS_DIR).toBe("/authority/tls"); + expect(process.env.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(process.env.OPENSHELL_TOKEN).toBeUndefined(); + }); + + it("keeps non-MCP ambient selectors while pinning the recorded gateway (#10514)", async () => { + process.env.OPENSHELL_GATEWAY = "hostile-gateway"; + process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://hostile.invalid"; + process.env.OPENSHELL_LOCAL_TLS_DIR = "/hostile/tls"; + process.env.OPENSHELL_TOKEN = "hostile-token"; + process.env.OPENSHELL_WORKSPACE = "hostile-workspace"; + const recover = vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockResolvedValue({ + recovered: true, + before: { state: "connected_other", status: "", gatewayInfo: "", activeGateway: null }, + after: { + state: "healthy_named", + status: "", + gatewayInfo: "", + activeGateway: "nemoclaw-19080", + }, + attempted: true, + }); + + await expect( + ensureRebuildTargetGatewaySelected( + "alpha", + { name: "alpha", gatewayName: "nemoclaw-19080", gatewayPort: 19080 }, + vi.fn(), + makeBail(), ), ).resolves.toBe(true); expect(recover).toHaveBeenCalledWith({ gatewayName: "nemoclaw-19080" }); expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw-19080"); + expect(process.env.OPENSHELL_GATEWAY_ENDPOINT).toBe("https://hostile.invalid"); + expect(process.env.OPENSHELL_LOCAL_TLS_DIR).toBe("/hostile/tls"); + expect(process.env.OPENSHELL_TOKEN).toBe("hostile-token"); + expect(process.env.OPENSHELL_WORKSPACE).toBe("hostile-workspace"); }); it("fails closed when the target gateway cannot become healthy", async () => { @@ -844,7 +898,7 @@ describe("warnUnpreservedUserManagedFiles", () => { warnUnpreservedUserManagedFiles("alpha", () => undefined); expect(probeSpy).toHaveBeenCalledOnce(); - expect(probeSpy).toHaveBeenCalledWith("alpha"); + expect(probeSpy).toHaveBeenCalledWith("alpha", undefined); const warnLines = warnSpy.mock.calls.map((args: unknown[]) => String(args[0])); expect( diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index dde27b49786..f95979b8eec 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -3,6 +3,11 @@ import { dockerRmi } from "../../adapters/docker/image"; import { printOpenShellStateRpcIssue } from "../../adapters/openshell/gateway-drift"; +import { + replaceOpenShellRuntimeSelectionEnv, + snapshotOpenShellEnv, + type OpenShellRuntimeSelection, +} from "../../adapters/openshell/runtime-selection"; import { loadAgent } from "../../agent/defs"; import { bindLocalAgentBaseImageHandoffToResolution, @@ -49,6 +54,7 @@ import { import * as snapshotBackup from "./snapshot/backup-authority"; export { removeStaleRebuildDockerOrphan }; +export { replaceOpenShellRuntimeSelectionEnv, snapshotOpenShellEnv }; export type RebuildSandboxEntry = SandboxEntry & { agents?: unknown[] }; @@ -136,9 +142,19 @@ export async function ensureRebuildTargetGatewaySelected( sb: RebuildSandboxEntry, log: (message: string) => void, bail: (message: string, code?: number) => never, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise { const gatewayName = resolveSandboxGatewayName(sb); - const recovery = await recoverNamedGatewayRuntime({ gatewayName }); + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + bail( + `OpenShell runtime selection '${runtimeSelection.gatewayName}' does not match recorded gateway '${gatewayName}'`, + ); + return false; + } + const recovery = await recoverNamedGatewayRuntime({ + gatewayName, + ...(runtimeSelection ? { runtimeSelection } : {}), + }); if (!recovery.recovered || recovery.after.state !== "healthy_named") { console.error(""); console.error( @@ -151,7 +167,8 @@ export async function ensureRebuildTargetGatewaySelected( bail(`Could not select healthy gateway '${gatewayName}' for sandbox '${sandboxName}'`); return false; } - process.env.OPENSHELL_GATEWAY = gatewayName; + if (runtimeSelection) replaceOpenShellRuntimeSelectionEnv(process.env, runtimeSelection); + else process.env.OPENSHELL_GATEWAY = gatewayName; log(`Pinned rebuild subprocesses to target gateway '${gatewayName}'`); return true; } @@ -539,10 +556,11 @@ export function backupSandboxStateForRebuild( export function warnUnpreservedUserManagedFiles( sandboxName: string, log: (msg: string) => void, + runtimeSelection?: OpenShellRuntimeSelection, ): void { let probe: userManagedFilesProbe.UserManagedFilesProbe; try { - probe = userManagedFilesProbe.probeUserManagedFiles(sandboxName); + probe = userManagedFilesProbe.probeUserManagedFiles(sandboxName, runtimeSelection); } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`User-managed file probe errored: ${message}`); diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 1d0ca8a7120..90a7f04da2a 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -633,6 +633,38 @@ describe("rebuildSandbox flow: lifecycle", () => { }); }); + it("rebuilds prepared-only MCP state without claiming runtime authority", async () => { + const preparedMcpEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://mcp.example.test/mcp", + env: ["GITHUB_TOKEN"], + policyName: "mcp-bridge-github", + addedAt: "2026-06-01T00:00:00.000Z", + addState: "prepared" as const, + }; + const harness = createRebuildFlowHarness({ + sandboxEntry: { + mcp: { bridges: { github: preparedMcpEntry } }, + }, + mcpPreparation: { + entries: [], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--force"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith( + "alpha", + ); + expect(harness.onboardSpy).toHaveBeenCalledOnce(); + }); + it("keeps the journaled row when replacement creation fails (#7734)", async () => { const harness = createRebuildFlowHarness({ onboard: () => { diff --git a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts index eaf47775078..d3b22f35edc 100644 --- a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts @@ -119,6 +119,27 @@ describe("rebuild gateway drift preflight", () => { expect(recoverNamedGatewayRuntimeSpy).not.toHaveBeenCalled(); }); + it("binds gateway schema preflight to the frozen runtime target (#10514)", () => { + const runtimeSelection = { + gatewayName: "nemoclaw", + localTlsDir: "/authority/tls", + workspace: "default", + }; + + expect( + checkRebuildGatewaySchemaPreflight( + "alpha", + makeSandboxEntry(), + bail, + runtimeSelection, + ), + ).toBe(true); + expect(gatewayDrift.detectOpenShellStateRpcPreflightIssue).toHaveBeenCalledWith({ + gatewayName: "nemoclaw", + runtimeSelection, + }); + }); + it("prints the safe-abort diagnostic before bailing on gateway schema drift (#7794)", () => { vi.mocked(gatewayDrift.detectOpenShellStateRpcPreflightIssue).mockReturnValue(driftIssue); const nonThrowingBail = vi.fn(); diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts index b16b7cd8bcd..5a1f5a5a6c2 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { loadAgent } from "../../agent/defs"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { type InferenceEndpointSource, normalizeInferenceEndpointSource, @@ -120,6 +121,7 @@ export type RebuildRecreateOnboardOpts = { controlUiPort: number | null; targetGatewayName: string; targetGatewayPort: number; + runtimeSelection?: OpenShellRuntimeSelection; onboardLockAlreadyHeld: true; deferProcessExit: true; /** Target fingerprint of the replacement journal opened before deletion. */ diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts index abbef103048..d035a4fe171 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { CLI_NAME } from "../../cli/branding"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime"; import { isDirectSandboxFallbackUnavailableError } from "../../sandbox/privileged-exec"; import type { GatewayRestartResult } from "./gateway-restart"; import { @@ -101,16 +102,17 @@ type GatewayRecoveryObservation = { interface HermesPostRestoreGatewayDeps { checkAndRecoverSandboxProcesses?: ( sandboxName: string, - options: { quiet: boolean }, + options: { quiet: boolean; runtimeSelection?: OpenShellRuntimeSelection }, ) => GatewayRecoveryObservation; restartSandboxGateway?: ( sandboxName: string, - options: { quiet: boolean }, + options: { quiet: boolean; runtimeSelection?: OpenShellRuntimeSelection }, ) => GatewayRestartResult; observeHermesCronReplacement?: ( sandboxName: string, originalIdentity: HermesCronRestoreIdentity, ) => HermesCronRestoreIdentity; + runtimeSelection?: OpenShellRuntimeSelection; } export interface HermesPostRestoreGatewayVerification { @@ -169,7 +171,10 @@ export function restartHermesGatewayAfterStateRestore( ): HermesPostRestoreGatewayRestartState { if (agentName !== "hermes") return "not-applicable"; const restart = deps.restartSandboxGateway ?? restartSandboxGateway; - const result = restart(sandboxName, { quiet: true }); + const result = restart(sandboxName, { + quiet: true, + ...(deps.runtimeSelection ? { runtimeSelection: deps.runtimeSelection } : {}), + }); if (result.ok) return "restarted"; const mcpRestoreCanSupersede = result.failureLayer === "MCP reconciliation refusal" && @@ -237,7 +242,10 @@ function verifyHermesGatewayAfterStateRestoreImpl( // later iteration must observe it both before and after health. } } - const observation: GatewayRecoveryObservation = checkAndRecover(sandboxName, { quiet: true }); + const observation: GatewayRecoveryObservation = checkAndRecover(sandboxName, { + quiet: true, + ...(deps.runtimeSelection ? { runtimeSelection: deps.runtimeSelection } : {}), + }); if ( observation.forwardRecoveryFailed === true || observation.secretBoundaryRefused === true || diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts index bd559c2d7f6..87479b7bbea 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts @@ -6,11 +6,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ executeSandboxCommand: vi.fn(), executeSandboxExecCommand: vi.fn(), + getMcpProviderInspectionRuntimeSelection: vi.fn(), + getSandbox: vi.fn(), prepareAbsent: vi.fn(), prepareExecUnavailable: vi.fn(), prepareLive: vi.fn(), })); +vi.mock("../../state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: mocks.getSandbox, +})); + vi.mock("./mcp-bridge", () => ({ prepareMcpBridgesForAbsentSandboxRebuild: mocks.prepareAbsent, prepareMcpBridgesForExecUnavailableRebuild: mocks.prepareExecUnavailable, @@ -24,7 +31,18 @@ vi.mock("./process-recovery", () => ({ executeSandboxExecCommand: mocks.executeSandboxExecCommand, })); -import { prepareMcpForRebuild, printMcpRebuildRetryCommand } from "./rebuild-mcp-phase"; +vi.mock("./mcp-bridge-provider", async (importOriginal) => ({ + ...(await importOriginal()), + getMcpProviderInspectionRuntimeSelection: mocks.getMcpProviderInspectionRuntimeSelection, +})); + +import { + prepareMcpForRebuild, + printMcpRebuildRetryCommand, + resolveMcpPreparationRuntimeSelection, +} from "./rebuild-mcp-phase"; + +const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; const emptyPreparation = { entries: [], @@ -40,6 +58,10 @@ describe("forced rebuild MCP preparation", () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getSandbox.mockReturnValue({ + mcp: { bridges: { github: { addState: undefined } } }, + }); + mocks.getMcpProviderInspectionRuntimeSelection.mockReturnValue(runtimeSelection); mocks.executeSandboxCommand.mockReturnValue({ status: 0, stdout: "", stderr: "" }); mocks.executeSandboxExecCommand.mockReturnValue({ status: 0, stdout: "", stderr: "" }); mocks.prepareAbsent.mockResolvedValue(emptyPreparation); @@ -59,9 +81,12 @@ describe("forced rebuild MCP preparation", () => { expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection, + }); + expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection, }); - expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":"); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", runtimeSelection); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); expect(mocks.prepareLive).not.toHaveBeenCalled(); }); @@ -76,11 +101,14 @@ describe("forced rebuild MCP preparation", () => { emptyPreparation, ); - expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":"); + expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection, + }); expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", runtimeSelection); expect(mocks.prepareLive).not.toHaveBeenCalled(); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); }); @@ -101,8 +129,9 @@ describe("forced rebuild MCP preparation", () => { expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", runtimeSelection); expect(mocks.prepareLive).not.toHaveBeenCalled(); }); @@ -122,7 +151,7 @@ describe("forced rebuild MCP preparation", () => { emptyPreparation, ); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", runtimeSelection); expect(mocks.prepareLive).not.toHaveBeenCalled(); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); }, @@ -138,10 +167,13 @@ describe("forced rebuild MCP preparation", () => { "Failed to preserve MCP bridges before rebuild: generated policy drifted", ); - expect(mocks.prepareLive).toHaveBeenCalledWith("alpha"); - expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":"); + expect(mocks.prepareLive).toHaveBeenCalledWith("alpha", runtimeSelection); + expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection, + }); expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); }); @@ -176,7 +208,7 @@ describe("forced rebuild MCP preparation", () => { expect(mocks.executeSandboxExecCommand).not.toHaveBeenCalled(); expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); - expect(mocks.prepareLive).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareLive).toHaveBeenCalledWith("alpha", runtimeSelection); expect(mocks.prepareExecUnavailable).not.toHaveBeenCalled(); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); }); @@ -196,6 +228,26 @@ describe("forced rebuild MCP preparation", () => { expect(mocks.prepareExecUnavailable).not.toHaveBeenCalled(); expect(mocks.prepareLive).not.toHaveBeenCalled(); }); + + it("does not resolve or probe a runtime target for prepared-only MCP state", async () => { + mocks.getSandbox.mockReturnValue({ + mcp: { bridges: { github: { addState: "prepared" } } }, + }); + const bail = vi.fn((message: string): never => { + throw new Error(message); + }); + + expect(resolveMcpPreparationRuntimeSelection("alpha")).toBeUndefined(); + await expect(prepareMcpForRebuild("alpha", false, true, bail)).resolves.toEqual( + emptyPreparation, + ); + + expect(mocks.getMcpProviderInspectionRuntimeSelection).not.toHaveBeenCalled(); + expect(mocks.executeSandboxCommand).not.toHaveBeenCalled(); + expect(mocks.executeSandboxExecCommand).not.toHaveBeenCalled(); + expect(mocks.prepareExecUnavailable).not.toHaveBeenCalled(); + expect(mocks.prepareLive).toHaveBeenCalledWith("alpha"); + }); }); describe("MCP rebuild retry guidance", () => { diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.ts index 1d8edc32829..fa812cc4f07 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.ts @@ -14,20 +14,49 @@ import { reattachMcpProvidersAfterRebuildAbort, restoreMcpBridgesAfterRebuild, } from "./mcp-bridge"; +import { getMcpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import type { RebuildBail } from "./rebuild-credential-preflight"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider"; export type McpRebuildPreparation = Awaited>; -function canExecuteMcpPreparation(sandboxName: string): boolean { +export function getMcpPreparationRuntimeSelection( + sandbox: RebuildSandboxEntry, +): ReturnType { + return getMcpProviderInspectionRuntimeSelection(sandbox); +} + +/** Prepared-only MCP adds own no external runtime state and need no target authority. */ +export function mcpRebuildRequiresRuntimeSelection(sandbox: RebuildSandboxEntry): boolean { + return Object.values(sandbox.mcp?.bridges ?? {}).some((entry) => entry.addState !== "prepared"); +} + +export function resolveMcpPreparationRuntimeSelection( + sandboxName: string, +): ReturnType | undefined { + const sandbox = registry.getSandbox(sandboxName); + if (!sandbox || !mcpRebuildRequiresRuntimeSelection(sandbox)) return undefined; + try { + return getMcpPreparationRuntimeSelection(sandbox); + } catch { + return undefined; + } +} + +function canExecuteMcpPreparation( + sandboxName: string, + runtimeSelection: ReturnType, +): boolean { // Live MCP preparation uses both transports: SSH-backed adapter // inspection/mutation and OpenShell-mediated adapter/provider operations. // Prove both before any mutation. A direct Docker fallback would not prove // that the OpenShell transport itself can run. - const sshProbe = executeSandboxCommand(sandboxName, ":"); + const sshProbe = executeSandboxCommand(sandboxName, ":", { runtimeSelection }); const execProbe = executeSandboxExecCommand(sandboxName, ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); return sshProbe !== null && sshProbe.status === 0 && execProbe !== null && execProbe.status === 0; } @@ -37,7 +66,13 @@ export async function prepareMcpForRebuild( staleRecovery: boolean, force: boolean, bail: RebuildBail, + frozenRuntimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { + const sandbox = staleRecovery ? undefined : registry.getSandbox(sandboxName); + const requiresRuntimeSelection = sandbox ? mcpRebuildRequiresRuntimeSelection(sandbox) : false; + const runtimeSelection = + frozenRuntimeSelection ?? + (staleRecovery ? undefined : resolveMcpPreparationRuntimeSelection(sandboxName)); // invalidState: OpenShell still reports a live sandbox, but the // side-effect-free `:` command cannot cross every transport required by live // MCP preparation. Every nonzero result is non-authoritative, so interpreting @@ -50,10 +85,15 @@ export async function prepareMcpForRebuild( // nonzero results through this exact force-only branch. // removalCondition: remove this fallback only when OpenShell exposes an // attested read-only adapter snapshot that is safe without sandbox transport. - if (force && !staleRecovery && !canExecuteMcpPreparation(sandboxName)) { + if ( + force && + !staleRecovery && + requiresRuntimeSelection && + (!runtimeSelection || !canExecuteMcpPreparation(sandboxName, runtimeSelection)) + ) { console.error(` ${YW}⚠${R} MCP transport probe failed; --force using host-side MCP recovery`); try { - return await prepareMcpBridgesForExecUnavailableRebuild(sandboxName); + return await prepareMcpBridgesForExecUnavailableRebuild(sandboxName, runtimeSelection); } catch (error) { bail( `Failed to preserve MCP bridges before rebuild (--force host-side recovery): ${error instanceof Error ? error.message : String(error)}`, @@ -64,8 +104,12 @@ export async function prepareMcpForRebuild( try { return await (staleRecovery - ? prepareMcpBridgesForAbsentSandboxRebuild(sandboxName) - : prepareMcpBridgesForRebuild(sandboxName)); + ? runtimeSelection + ? prepareMcpBridgesForAbsentSandboxRebuild(sandboxName, runtimeSelection) + : prepareMcpBridgesForAbsentSandboxRebuild(sandboxName) + : runtimeSelection + ? prepareMcpBridgesForRebuild(sandboxName, runtimeSelection) + : prepareMcpBridgesForRebuild(sandboxName)); } catch (error) { bail( `Failed to preserve MCP bridges before rebuild: ${error instanceof Error ? error.message : String(error)}`, @@ -78,9 +122,15 @@ export async function reattachMcpAfterDeleteFailure( sandboxName: string, entries: McpRebuildPreparation["detachedProviderEntries"], scrubbedAdapterEntries: McpRebuildPreparation["scrubbedAdapterEntries"], + runtimeSelection?: McpRebuildPreparation["runtimeSelection"], ): Promise { try { - await reattachMcpProvidersAfterRebuildAbort(sandboxName, entries, scrubbedAdapterEntries); + await reattachMcpProvidersAfterRebuildAbort( + sandboxName, + entries, + scrubbedAdapterEntries, + runtimeSelection, + ); return undefined; } catch (error) { return error instanceof Error ? error.message : String(error); @@ -147,11 +197,16 @@ export function printMcpRebuildRetryCommand( export async function restoreMcpAfterRebuild( sandboxName: string, entries: McpRebuildPreparation["entries"], + runtimeSelection?: McpRebuildPreparation["runtimeSelection"], ): Promise { if (entries.length === 0) return true; console.log(" Restoring MCP bridges..."); try { - await restoreMcpBridgesAfterRebuild(sandboxName, entries); + if (runtimeSelection) { + await restoreMcpBridgesAfterRebuild(sandboxName, entries, runtimeSelection); + } else { + await restoreMcpBridgesAfterRebuild(sandboxName, entries); + } console.log(` ${G}✓${R} MCP bridges restored`); return true; } catch { diff --git a/src/lib/actions/sandbox/rebuild-messaging-phase.ts b/src/lib/actions/sandbox/rebuild-messaging-phase.ts index 3253f23cef5..b69b464de21 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-phase.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-phase.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runOpenshell } from "../../adapters/openshell/runtime"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, + runOpenshell, +} from "../../adapters/openshell/runtime"; import { RD as _RD, G, R } from "../../cli/terminal-style"; import { MessagingSetupApplier } from "../../messaging/applier/setup-applier"; import type { @@ -45,19 +49,31 @@ export async function stageRebuildMessagingPlanOrBail( } } -const runMessagingOpenshell: MessagingOpenShellRunner = (args, options = {}) => - runOpenshell([...args], { - env: options.env as NodeJS.ProcessEnv | undefined, - ignoreError: options.ignoreError, - input: options.input, - stdio: options.stdio as never, - }); +function createRunMessagingOpenshell( + runtimeSelection?: OpenShellRuntimeSelection, +): MessagingOpenShellRunner { + return (args, options = {}) => + runOpenshell([...args], { + env: runtimeSelection + ? buildSelectedOpenShellSubprocessEnv( + runtimeSelection, + options.env ? { ...options.env } : undefined, + ) + : (options.env as NodeJS.ProcessEnv | undefined), + replaceEnv: runtimeSelection ? true : undefined, + ignoreError: options.ignoreError, + input: options.input, + stdio: options.stdio as never, + }); +} export function finalizePendingMessagingRemovalsAfterRestore( plan: SandboxMessagingPlan | null, log: (message: string) => void, + runtimeSelection?: OpenShellRuntimeSelection, ): SandboxMessagingPlan | null { if (!plan) return null; + const runMessagingOpenshell = createRunMessagingOpenshell(runtimeSelection); const pendingRemovals = plan.channels.filter( (channel) => channel.pendingRemoval === true, ); @@ -97,6 +113,7 @@ export async function reapplyMessagingManifestAfterOpenClawDoctor( sandboxName: string, plan: SandboxMessagingPlan | null, log: (message: string) => void, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise { if (!plan || plan.agent !== "openclaw") { log("Messaging manifest reapply skipped: no OpenClaw messaging plan"); @@ -104,6 +121,7 @@ export async function reapplyMessagingManifestAfterOpenClawDoctor( } log("Reapplying messaging manifest render and post-agent-install hooks after doctor"); + const runMessagingOpenshell = createRunMessagingOpenshell(runtimeSelection); const result = await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { runOpenshell: runMessagingOpenshell, runHook: (request) => hookOutputsFromBuildSteps(plan, request), diff --git a/src/lib/actions/sandbox/rebuild-messaging-removal.test.ts b/src/lib/actions/sandbox/rebuild-messaging-removal.test.ts index 388d2387758..8bbb485067a 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-removal.test.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-removal.test.ts @@ -1,14 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import type { SandboxMessagingPlan } from "../../messaging/manifest"; const mocks = vi.hoisted(() => ({ runOpenshell: vi.fn() })); -vi.mock("../../adapters/openshell/runtime", () => ({ +vi.mock("../../adapters/openshell/runtime", async (importOriginal) => ({ + ...(await importOriginal()), runOpenshell: mocks.runOpenshell, })); @@ -66,6 +67,10 @@ describe("post-restore messaging removal", () => { }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("applies the config tombstone and retires it before Hermes restart", () => { const finalized = finalizePendingMessagingRemovalsAfterRestore(removalPlan(), vi.fn()); @@ -85,4 +90,42 @@ describe("post-restore messaging removal", () => { const finalized = finalizePendingMessagingRemovalsAfterRestore(removalPlan(), vi.fn()); expect(finalized?.channels).toEqual([]); }); + + it("pins every post-restore config operation to the rebuild 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"); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", + }; + + finalizePendingMessagingRemovalsAfterRestore(removalPlan(), vi.fn(), runtimeSelection); + + expect(mocks.runOpenshell).toHaveBeenCalled(); + const selectedTargets = mocks.runOpenshell.mock.calls.map(([, options]) => ({ + endpoint: options.env.OPENSHELL_GATEWAY_ENDPOINT, + gateway: options.env.OPENSHELL_GATEWAY, + insecure: options.env.OPENSHELL_GATEWAY_INSECURE, + replaceEnv: options.replaceEnv, + tlsDir: options.env.OPENSHELL_LOCAL_TLS_DIR, + token: options.env.OPENSHELL_TOKEN, + workspace: options.env.OPENSHELL_WORKSPACE, + })); + expect(selectedTargets).toEqual( + new Array(selectedTargets.length).fill({ + endpoint: undefined, + gateway: "recorded-gateway", + insecure: undefined, + replaceEnv: true, + tlsDir: "/authority/tls", + token: undefined, + workspace: "default", + }), + ); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index d8a32c718f3..c4360f62124 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import type { RebuildSandboxOptions } from "../../domain/lifecycle/options"; import { normalizeRebuildSandboxOptions } from "../../domain/lifecycle/options"; import { BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV } from "../../inference/web-search"; @@ -34,7 +35,9 @@ import { REBUILD_HERMES_DASHBOARD_ENV_KEYS } from "./rebuild-durable-config"; import { disposeRebuildAgentBaseImagePreflight, removeStaleRebuildDockerOrphan, + snapshotOpenShellEnv, } from "./rebuild-flow-helpers"; +import { mcpRebuildRequiresRuntimeSelection } from "./rebuild-mcp-phase"; import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase"; import { type HermesCronRestoreIdentity, @@ -118,11 +121,11 @@ export async function rebuildSandbox( { allowStateRecord: true }, ); assertSandboxRebuildCommandAvailable(sandboxName); + const restoreOpenShellEnv = snapshotOpenShellEnv(); const scopedEnvKeys = [ BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV, MESSAGING_SETUP_APPLIER_ENV_KEY, - "OPENSHELL_GATEWAY", DOCKER_GPU_PATCH_NETWORK_ENV, ...REBUILD_HERMES_DASHBOARD_ENV_KEYS, ...MESSAGING_CHANNEL_CONFIG_ENV_KEYS, @@ -136,6 +139,7 @@ export async function rebuildSandbox( removedImmutabilityMigration.stateRecord !== null, ); } finally { + restoreOpenShellEnv(); for (const key of scopedEnvKeys) delete process.env[key]; Object.assign( process.env, @@ -236,6 +240,15 @@ async function rebuildSandboxUnlocked( recoveryManifest = preDeleteRecovery.manifest; recoveryRegistrySnapshot = preDeleteRecovery.registrySnapshot; const activeRecoveryTransaction = onboardSession.loadSession()?.checkpoint?.sandboxRecreate; + const mcpEntries = Object.values(sandboxEntry.mcp?.bridges ?? {}); + const mcpRuntimeSelectionRequired = mcpRebuildRequiresRuntimeSelection(sandboxEntry); + const mcpRuntimeSelection = mcpRuntimeSelectionRequired + ? recreateOptions.runtimeSelection + : undefined; + if (mcpRuntimeSelectionRequired && !mcpRuntimeSelection) { + bail("MCP rebuild preflight did not retain its recorded OpenShell runtime target."); + return; + } const openRecreateJournal = () => { const expectedGatewayAuthority = recreateOptions.rebuildGatewayAuthority; if (!expectedGatewayAuthority) { @@ -251,6 +264,7 @@ async function rebuildSandboxUnlocked( expectedGatewayAuthority, agentName: rebuildAgent || "openclaw", targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), + ...(mcpRuntimeSelection ? { resolveRuntimeSelection: () => mcpRuntimeSelection } : {}), log, onAuthorityRefusal: (lines) => bail(lines.join("\n")), }); @@ -383,6 +397,7 @@ async function rebuildSandboxUnlocked( webSearchConfig: durableConfig.webSearchConfig, log, bail, + ...(mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}), }); if (!backup) return; rebuildPolicySourcePath = backup.policySourcePath; @@ -411,9 +426,13 @@ async function rebuildSandboxUnlocked( return false; } }; - const capturePolicyHandoff = (): boolean => { + const capturePolicyHandoff = (runtimeSelection?: OpenShellRuntimeSelection): boolean => { return publishPolicyHandoff( - captureRebuildPolicyDocument(sandboxName, recreateOptions.targetGatewayName), + captureRebuildPolicyDocument( + sandboxName, + recreateOptions.targetGatewayName, + runtimeSelection, + ), ); }; @@ -476,6 +495,7 @@ async function rebuildSandboxUnlocked( durableConfig.dcodeAutoApprovalMode, recoveryRecreate, recreateOptions.targetGatewayPort, + recreateOptions.runtimeSelection, )) ) { return; @@ -492,6 +512,10 @@ async function rebuildSandboxUnlocked( const recreateJournal = openRecreateJournal(); if (!recreateJournal) return; + if (mcpRuntimeSelectionRequired && !recreateJournal.runtimeSelection) { + bail("The rebuild journal did not retain its recorded OpenShell runtime target."); + return; + } recreateOptions.rebuildGatewayAuthority = recreateJournal.gatewayAuthority; const rebuildRecoveryIdentity = { sandboxName, @@ -571,6 +595,9 @@ async function rebuildSandboxUnlocked( targetAgentType: rebuildAgent || "openclaw", targetImageIsCustom: Boolean(fromDockerfile), backupManifest: recoveryBackup, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), log, }); const postRestoreVerification = await runRebuildPostRestorePhase({ @@ -578,7 +605,10 @@ async function rebuildSandboxUnlocked( targetAgentName: rebuildAgent || "openclaw", messagingPlan, backupManifest: recoveryBackup, - mcpEntries: Object.values(sandboxEntry.mcp?.bridges ?? {}), + mcpEntries, + ...(recreateJournal.runtimeSelection + ? { mcpRuntimeSelection: recreateJournal.runtimeSelection } + : {}), restoreSucceeded: restored.restoreSucceeded, preparedBackupRecovery: true, versionCheck, @@ -613,6 +643,9 @@ async function rebuildSandboxUnlocked( recreateJournal, backupManifest: backup.backupManifest, force: normalized.force, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), log, bail, validateAfterMcpPreparation: async (preparation) => { @@ -642,6 +675,7 @@ async function rebuildSandboxUnlocked( providerReconfigure.provider, log, "Delete-edge", + preparation.runtimeSelection, ) : "missing"; if (providerReconfigure && providerRegistration !== "missing") { @@ -659,9 +693,10 @@ async function rebuildSandboxUnlocked( durableConfig.dcodeAutoApprovalMode, recoveryRecreate, recreateOptions.targetGatewayPort, + preparation.runtimeSelection, ); }, - validateAtDeleteEdge: () => { + validateAtDeleteEdge: (runtimeSelection) => { const validation = revalidateManagedWorkloadRebuildBeforeDelete( sandboxName, @@ -679,7 +714,7 @@ async function rebuildSandboxUnlocked( // prepared recovery manifest, so there is no live policy to recapture. if (staleRecovery) return validation; try { - return capturePolicyHandoff() + return capturePolicyHandoff(runtimeSelection) ? validation : { ok: false, @@ -745,6 +780,9 @@ async function rebuildSandboxUnlocked( targetAgentType: rebuildAgent || "openclaw", targetImageIsCustom: Boolean(fromDockerfile), backupManifest: backup.backupManifest, + ...(mcpPreparation.runtimeSelection + ? { runtimeSelection: mcpPreparation.runtimeSelection } + : {}), log, }); let hermesCronRestoreIdentity: HermesCronRestoreIdentity | undefined; @@ -781,6 +819,7 @@ async function rebuildSandboxUnlocked( messagingPlan, backupManifest: backup.backupManifest, mcpEntries: mcpPreparation.entries, + mcpRuntimeSelection: mcpPreparation.runtimeSelection, restoreSucceeded: restored.restoreSucceeded, hermesCronRestoreIdentity, preparedBackupRecovery, diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index 6e1e6d77a33..8ecaea6fc54 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -117,6 +117,7 @@ describe("rebuild post-restore phase", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); function input() { @@ -159,6 +160,47 @@ describe("rebuild post-restore phase", () => { ); }); + it("reuses the MCP rebuild target for every post-restore sandbox command (#10514)", async () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const args = { ...input(), mcpRuntimeSelection: runtimeSelection }; + + await runRebuildPostRestorePhase(args); + + expect(processRecovery.executeSandboxExecCommand).toHaveBeenCalledWith( + "alpha", + "openclaw doctor --fix", + 300_000, + { allowLocalDockerFallback: false, runtimeSelection }, + ); + expect(rebuildMessaging.reapplyMessagingManifestAfterOpenClawDoctor).toHaveBeenCalledWith( + "alpha", + null, + args.log, + runtimeSelection, + ); + expect(sessionModels.reconcileStalePinnedSessionModelsAfterRebuild).toHaveBeenCalledWith( + "alpha", + args.log, + runtimeSelection, + ); + expect( + rebuildConfigHash.refreshMutableOpenClawConfigHashAfterPostRestoreWrites, + ).toHaveBeenCalledExactlyOnceWith("alpha", args.log, runtimeSelection); + expect(vi.mocked(rebuildConfigHash.verifyFinalMutableOpenClawConfigHash).mock.calls).toEqual([ + ["alpha", args.log, runtimeSelection], + ["alpha", args.log, runtimeSelection], + ]); + expect(process.env.OPENSHELL_GATEWAY).toBe("hostile-gateway"); + }); + it("does not record a final hash without trusted doctor completion (#9946)", async () => { vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue(null); const args = input(); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 89cd4e3e8aa..dfc4b3900ab 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -82,6 +82,7 @@ export interface RebuildPostRestorePhaseInput { messagingPlan: SandboxMessagingPlan | null; backupManifest: RebuildBackupManifest; mcpEntries: McpRebuildPreparation["entries"]; + mcpRuntimeSelection?: McpRebuildPreparation["runtimeSelection"]; restoreSucceeded: boolean; hermesCronRestoreIdentity?: HermesCronRestoreIdentity; preparedBackupRecovery: boolean; @@ -119,6 +120,7 @@ export async function runRebuildPostRestorePhase( messagingPlan, backupManifest, mcpEntries, + mcpRuntimeSelection, restoreSucceeded, hermesCronRestoreIdentity, preparedBackupRecovery, @@ -166,7 +168,10 @@ export async function runRebuildPostRestorePhase( sandboxName, "openclaw doctor --fix", OPENCLAW_DOCTOR_TIMEOUT_MS, - { allowLocalDockerFallback: false }, + { + allowLocalDockerFallback: false, + ...(mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}), + }, ); log(`doctor --fix: exit=${doctorResult?.status ?? "unverified"}`); if (doctorResult === null) { @@ -185,10 +190,15 @@ export async function runRebuildPostRestorePhase( // #7102: clear stale per-session pinned models left over from an // `inference set` before this rebuild, while the gateway is still down. - reconcileStalePinnedSessionModelsAfterRebuild(sandboxName, log); + reconcileStalePinnedSessionModelsAfterRebuild(sandboxName, log, mcpRuntimeSelection); try { - await reapplyMessagingManifestAfterOpenClawDoctor(sandboxName, messagingPlan, log); + await reapplyMessagingManifestAfterOpenClawDoctor( + sandboxName, + messagingPlan, + log, + mcpRuntimeSelection, + ); } catch (error) { log( `Messaging manifest reapply failed: ${error instanceof Error ? error.message : String(error)}`, @@ -227,6 +237,7 @@ export async function runRebuildPostRestorePhase( const finalizedMessagingPlan = finalizePendingMessagingRemovalsAfterRestore( effectiveMessagingPlan, log, + mcpRuntimeSelection, ); if (finalizedMessagingPlan !== effectiveMessagingPlan && finalizedMessagingPlan) { if ( @@ -254,15 +265,22 @@ export async function runRebuildPostRestorePhase( const hermesGatewayRestartState = restartHermesGatewayAfterStateRestore( sandboxName, targetAgentName, + mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}, ); - const mcpBridgeRestoreUnverified = !(await restoreMcpAfterRebuild(sandboxName, mcpEntries)); + const mcpBridgeRestoreUnverified = !(await restoreMcpAfterRebuild( + sandboxName, + mcpEntries, + mcpRuntimeSelection, + )); if (targetAgentName === "openclaw" && mcpBridgeRestoreUnverified) { mutableConfigHashRefreshUnverified = true; } else if (targetAgentName === "openclaw") { log("Refreshing mutable OpenClaw config hash after MCP restoration"); - if (!refreshMutableOpenClawConfigHashAfterPostRestoreWrites(sandboxName, log)) { + if ( + !refreshMutableOpenClawConfigHashAfterPostRestoreWrites(sandboxName, log, mcpRuntimeSelection) + ) { mutableConfigHashRefreshUnverified = true; - } else if (!verifyFinalMutableOpenClawConfigHash(sandboxName, log)) { + } else if (!verifyFinalMutableOpenClawConfigHash(sandboxName, log, mcpRuntimeSelection)) { finalMutableConfigHashUnverified = true; } } @@ -272,12 +290,14 @@ export async function runRebuildPostRestorePhase( targetAgentName, hermesGatewayRestartState, hermesCronRestoreIdentity, + mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}, ) : { state: verifyHermesGatewayAfterStateRestore( sandboxName, targetAgentName, hermesGatewayRestartState, + mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}, ), replacementIdentity: undefined, }; @@ -363,14 +383,20 @@ export async function runRebuildPostRestorePhase( }); log(`Registry updated: agentVersion=${agentDef.expectedVersion}`); - if (!ensureMessagingHostForwardAfterRebuild(sandboxName, effectiveMessagingPlan)) { + if ( + !ensureMessagingHostForwardAfterRebuild( + sandboxName, + effectiveMessagingPlan, + mcpRuntimeSelection, + ) + ) { messagingHostForwardUnverified = true; } if ( targetAgentName === "openclaw" && !mcpBridgeRestoreUnverified && !mutableConfigHashRefreshUnverified && - !verifyFinalMutableOpenClawConfigHash(sandboxName, log) + !verifyFinalMutableOpenClawConfigHash(sandboxName, log, mcpRuntimeSelection) ) { finalMutableConfigHashUnverified = true; } diff --git a/src/lib/actions/sandbox/rebuild-preflight-guards.ts b/src/lib/actions/sandbox/rebuild-preflight-guards.ts index 38922802347..48b637ae3c2 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-guards.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-guards.ts @@ -5,6 +5,7 @@ import { detectOpenShellStateRpcPreflightIssue, printOpenShellStateRpcIssue, } from "../../adapters/openshell/gateway-drift"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { CLI_NAME } from "../../cli/branding"; import { checkGatewayRouteCompatibility, @@ -269,9 +270,17 @@ export function checkRebuildGatewaySchemaPreflight( sandboxName: string, sb: RebuildSandboxEntry, bail: RebuildBail, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean { + const gatewayName = resolveSandboxGatewayName(sb); + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + return bail( + `Rebuild gateway schema target '${gatewayName}' does not match the frozen OpenShell target '${runtimeSelection.gatewayName}'.`, + ); + } const issue = detectOpenShellStateRpcPreflightIssue({ - gatewayName: resolveSandboxGatewayName(sb), + gatewayName, + ...(runtimeSelection ? { runtimeSelection } : {}), }); if (issue) { printOpenShellStateRpcIssue(issue, { diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index c01b4caca6a..0f875ab79f1 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -50,7 +50,11 @@ import { type RebuildRoutePreflightReceipt, runRebuildGatewayIntentPreflight, } from "./rebuild-preflight-guards"; -import { prepareRebuildTargetPreflights } from "./rebuild-preflight-target-phase"; +import { + pinRebuildTargetGatewayForReadiness, + prepareRebuildTargetPreflights, + resolveRebuildMcpRuntimeSelection, +} from "./rebuild-preflight-target-phase"; import { disposePreparedBuildContext } from "./rebuild-prepared-image-context"; import { type RebuildSandboxExecutionOptions, @@ -138,7 +142,6 @@ export async function runRebuildPreflightPhase( const sandboxEntry = getRebuildSandboxEntryOrBail(sandboxName, bail); if (!sandboxEntry) return null; if (blockRebuildOnRetainedSandboxRecovery(sandboxName, bail)) return null; - const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); // #6376: refuse a stuck MCP destroy transaction up front — before backup, // image prep, or the old-sandbox delete. The only MCP marker check used to // live inside the destroy phase, which runs AFTER the backup phase, so a @@ -203,10 +206,15 @@ export async function runRebuildPreflightPhase( return null; } const agentName = getRebuildAgentDisplayName(sandboxName); + const mcpRuntimeSelection = resolveRebuildMcpRuntimeSelection(sandboxEntry, bail); + if (mcpRuntimeSelection) { + pinRebuildTargetGatewayForReadiness(sandboxName, sandboxEntry, log, mcpRuntimeSelection); + } + const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); const versionCheck = await runRebuildGatewayIntentPreflight({ checkGatewaySchema: () => isDcodeRebuildAgent(rebuildAgent) || - checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail), + checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail, mcpRuntimeSelection), confirmIntent: () => confirmRebuildIntent( sandboxName, @@ -231,8 +239,13 @@ export async function runRebuildPreflightPhase( log, bail, deps: { - checkGatewaySchema: (name, scopedBail) => - checkRebuildGatewaySchemaPreflight(name, expectedSandboxEntry, scopedBail), + checkGatewaySchema: (name, scopedBail, runtimeSelection) => + checkRebuildGatewaySchemaPreflight( + name, + expectedSandboxEntry, + scopedBail, + runtimeSelection, + ), preflightCredentials: (_name, entry, scopedLog, scopedBail) => preflightRebuildCredentials(entry, scopedLog, scopedBail), // Non-DCode rebuilds stay on the existing typed base-image preflight. @@ -262,6 +275,7 @@ export async function runRebuildPreflightPhase( requestedDcodeAutoApprovalMode, requestedObservabilityEnabled, allowLegacyManagedImageRecovery, + mcpRuntimeSelection, // A validated prepared backup is the only path allowed to reconstruct // a missing gateway provider and route during recreate. The exact // endpoint, credential, image, and registry checks still run before @@ -297,6 +311,7 @@ export async function runRebuildPreflightPhase( { resolutionHint: preparedTarget.recreateOptions.baseImageResolutionHint, }, + preparedTarget.recreateOptions.runtimeSelection, ); if (!imageReady) return null; if (!preparedTarget.recreateOptions.managedWorkloadRebuild) { diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts index ab4fcdeb885..359a6fecf61 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase-orchestration.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ bail: vi.fn(), + getMcpPreparationRuntimeSelection: vi.fn(), preflightAuthoritativeOnboardRuntime: vi.fn(async (..._args: unknown[]) => false), prepareManagedWorkloadRebuildHandoff: vi.fn(), prepareSandboxWorkloadSourceFromRebuildHandoff: vi.fn(), @@ -15,6 +16,11 @@ const mocks = vi.hoisted(() => ({ stageManagedWorkloadRebuildProfile: vi.fn(), })); +vi.mock("./rebuild-mcp-phase", async (importOriginal) => ({ + ...(await importOriginal()), + getMcpPreparationRuntimeSelection: mocks.getMcpPreparationRuntimeSelection, +})); + vi.mock("../../onboard/workload/rebuild", async (importOriginal) => ({ ...(await importOriginal()), prepareManagedWorkloadRebuildHandoff: mocks.prepareManagedWorkloadRebuildHandoff, @@ -49,16 +55,25 @@ vi.mock("./rebuild-messaging-conflict-preflight", () => ({ })); import { managedRebuildProfileDependencies } from "./agents/managed-workload-rebuild-profile"; +import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; import { prepareRebuildTargetPreflights } from "./rebuild-preflight-target-phase"; describe("prepareRebuildTargetPreflights", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.getMcpPreparationRuntimeSelection.mockReturnValue({ + gatewayName: "nemoclaw", + localTlsDir: "/authority/tls", + workspace: "default", + }); mocks.prepareManagedWorkloadRebuildHandoff.mockResolvedValue(null); mocks.preflightAuthoritativeOnboardRuntime.mockResolvedValue(false); }); - async function prepareN1xTarget(endpointSource: "onboard" | "inference-set") { + async function prepareN1xTarget( + endpointSource: "onboard" | "inference-set", + mcp: { bridges: Record } | null = null, + ) { const resumeConfig = { provider: "vllm-local", model: "nvidia/Qwen3.6-35B-A3B-NVFP4", @@ -100,13 +115,16 @@ describe("prepareRebuildTargetPreflights", () => { model: resumeConfig.model, endpointUrl: "http://host.openshell.internal:8000/v1", endpointSource, + mcp, } as never, rebuildAgent: "openclaw", autoYes: true, log: vi.fn(), bail: mocks.bail as never, }); - return mocks.preflightAuthoritativeOnboardRuntime.mock.calls[0]?.[2]; + return mocks.preflightAuthoritativeOnboardRuntime.mock.calls[0]?.[2] as + | RebuildRecreateOnboardOpts + | undefined; } it("resolves the Ollama context window through target preparation", async () => { @@ -195,4 +213,20 @@ describe("prepareRebuildTargetPreflights", () => { expect(readinessOptions).not.toHaveProperty("allowDeferredN1xManagedVllm"); }); + + it("freezes one MCP runtime target before authoritative readiness (#10514)", async () => { + const runtimeSelection = { + gatewayName: "nemoclaw", + localTlsDir: "/authority/tls", + workspace: "default", + }; + mocks.getMcpPreparationRuntimeSelection.mockReturnValue(runtimeSelection); + + const readinessOptions = await prepareN1xTarget("onboard", { + bridges: { github: { server: "github" } }, + }); + + expect(mocks.getMcpPreparationRuntimeSelection).toHaveBeenCalledOnce(); + expect(readinessOptions?.runtimeSelection).toBe(runtimeSelection); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts index 86b7a30144c..8fcccc0e62a 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { restoreEnvBulk } from "../../../../test/helpers/env-test-helpers"; import type { ProviderRecoveryReceipt, RegistryInferenceRoute, @@ -10,36 +11,93 @@ import type { import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; import { pinRebuildTargetGatewayForReadiness, + resolveRebuildMcpRuntimeSelection, runRebuildGatewayRecoveryAfterReadiness, stageRebuildBaseImageResolutionHandoff, stageRegistryProviderRecoveryReceipt, } from "./rebuild-preflight-target-phase"; -const originalGateway = process.env.OPENSHELL_GATEWAY; +const originalOpenShellEnv = { + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY, + OPENSHELL_GATEWAY_ENDPOINT: process.env.OPENSHELL_GATEWAY_ENDPOINT, + OPENSHELL_LOCAL_TLS_DIR: process.env.OPENSHELL_LOCAL_TLS_DIR, + OPENSHELL_TOKEN: process.env.OPENSHELL_TOKEN, + OPENSHELL_WORKSPACE: process.env.OPENSHELL_WORKSPACE, +}; afterEach(() => { - originalGateway === undefined - ? Reflect.deleteProperty(process.env, "OPENSHELL_GATEWAY") - : (process.env.OPENSHELL_GATEWAY = originalGateway); + restoreEnvBulk(originalOpenShellEnv); }); describe("rebuild readiness gateway pin", () => { + it("does not require runtime authority for prepared-only MCP state", () => { + const bail = vi.fn((message: string): never => { + throw new Error(message); + }); + + expect( + resolveRebuildMcpRuntimeSelection( + { + mcp: { + bridges: { + github: { addState: "prepared" }, + }, + }, + } as never, + bail, + ), + ).toBeUndefined(); + expect(bail).not.toHaveBeenCalled(); + }); + it("pins the recorded target without selecting or recovering a gateway (#7411)", () => { const log = vi.fn(); + process.env.OPENSHELL_GATEWAY = "hostile-gateway"; + process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://hostile.invalid"; + process.env.OPENSHELL_LOCAL_TLS_DIR = "/hostile/tls"; + process.env.OPENSHELL_TOKEN = "hostile-token"; + process.env.OPENSHELL_WORKSPACE = "hostile-workspace"; + const runtimeSelection = { gatewayName: "nemoclaw-9443", workspace: "default" }; expect( pinRebuildTargetGatewayForReadiness( "alpha", { gatewayName: "nemoclaw-9443", gatewayPort: 9443 } as never, log, + runtimeSelection, ), ).toBe("nemoclaw-9443"); expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw-9443"); + expect(process.env.OPENSHELL_WORKSPACE).toBe("default"); + expect(process.env.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + expect(process.env.OPENSHELL_LOCAL_TLS_DIR).toBeUndefined(); + expect(process.env.OPENSHELL_TOKEN).toBeUndefined(); expect(log).toHaveBeenCalledWith( "Pinned rebuild readiness probes for 'alpha' to target gateway 'nemoclaw-9443'", ); }); + it("keeps non-MCP ambient selectors while pinning the recorded gateway (#10514)", () => { + process.env.OPENSHELL_GATEWAY = "hostile-gateway"; + process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://hostile.invalid"; + process.env.OPENSHELL_LOCAL_TLS_DIR = "/hostile/tls"; + process.env.OPENSHELL_TOKEN = "hostile-token"; + process.env.OPENSHELL_WORKSPACE = "hostile-workspace"; + + expect( + pinRebuildTargetGatewayForReadiness( + "alpha", + { gatewayName: "nemoclaw-9443", gatewayPort: 9443 } as never, + vi.fn(), + ), + ).toBe("nemoclaw-9443"); + expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw-9443"); + expect(process.env.OPENSHELL_GATEWAY_ENDPOINT).toBe("https://hostile.invalid"); + expect(process.env.OPENSHELL_LOCAL_TLS_DIR).toBe("/hostile/tls"); + expect(process.env.OPENSHELL_TOKEN).toBe("hostile-token"); + expect(process.env.OPENSHELL_WORKSPACE).toBe("hostile-workspace"); + }); + it("does not select or recover the gateway when readiness rejects (#7411)", async () => { const afterReadiness = vi.fn(); const recoverGateway = vi.fn(async () => true); diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts index 0c415187ce8..496005d3755 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { randomUUID } from "node:crypto"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { CLI_NAME } from "../../cli/branding"; import type { SandboxMessagingPlan } from "../../messaging"; import { isSandboxBaseImageRefreshRequested } from "../../onboard/base-image-resolution-flow"; @@ -36,10 +37,15 @@ import { ensureRebuildAgentBaseImage, ensureRebuildTargetGatewaySelected, pinRebuildAgentBaseImageForRecreate, + replaceOpenShellRuntimeSelectionEnv, type RebuildAgentBaseImagePreflight, type RebuildSandboxEntry, } from "./rebuild-flow-helpers"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; +import { + getMcpPreparationRuntimeSelection, + mcpRebuildRequiresRuntimeSelection, +} from "./rebuild-mcp-phase"; import { preflightRebuildMessagingConflicts } from "./rebuild-messaging-conflict-preflight"; import { stageRebuildMessagingPlanOrBail } from "./rebuild-messaging-phase"; import { @@ -93,14 +99,36 @@ export interface RebuildPreparedTarget { routePreflightReceipt: RebuildRoutePreflightReceipt; } +/** Freeze the MCP-bearing rebuild on the recorded OpenShell target before live probes. */ +export function resolveRebuildMcpRuntimeSelection( + sandboxEntry: RebuildSandboxEntry, + bail: RebuildBail, +): OpenShellRuntimeSelection | undefined { + if (!mcpRebuildRequiresRuntimeSelection(sandboxEntry)) return undefined; + try { + return getMcpPreparationRuntimeSelection(sandboxEntry); + } catch (error) { + return bail( + `Could not bind MCP rebuild preflight to the recorded OpenShell target: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + /** Pin read-only rebuild probes without selecting, starting, or repairing a gateway. */ export function pinRebuildTargetGatewayForReadiness( sandboxName: string, sandboxEntry: RebuildSandboxEntry, log: RebuildLog, + runtimeSelection?: OpenShellRuntimeSelection, ): string { const gatewayName = getPersistedSandboxTargetGatewayName(sandboxEntry); - process.env.OPENSHELL_GATEWAY = gatewayName; + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) { + throw new Error( + `OpenShell runtime selection '${runtimeSelection.gatewayName}' does not match recorded gateway '${gatewayName}'.`, + ); + } + if (runtimeSelection) replaceOpenShellRuntimeSelectionEnv(process.env, runtimeSelection); + else process.env.OPENSHELL_GATEWAY = gatewayName; log(`Pinned rebuild readiness probes for '${sandboxName}' to target gateway '${gatewayName}'`); return gatewayName; } @@ -154,6 +182,7 @@ export async function prepareRebuildTargetPreflights(args: { requestedObservabilityEnabled?: boolean; allowLegacyManagedImageRecovery?: boolean; preparedBackupRecovery?: boolean; + mcpRuntimeSelection?: OpenShellRuntimeSelection; log: RebuildLog; bail: RebuildBail; }): Promise { @@ -167,11 +196,14 @@ export async function prepareRebuildTargetPreflights(args: { requestedObservabilityEnabled, allowLegacyManagedImageRecovery, preparedBackupRecovery, + mcpRuntimeSelection: frozenMcpRuntimeSelection, log, bail, } = args; + const mcpRuntimeSelection = + frozenMcpRuntimeSelection ?? resolveRebuildMcpRuntimeSelection(sandboxEntry, bail); hydrateMessagingConfigForRebuild(sandboxName, log); - pinRebuildTargetGatewayForReadiness(sandboxName, sandboxEntry, log); + pinRebuildTargetGatewayForReadiness(sandboxName, sandboxEntry, log, mcpRuntimeSelection); const targetConfig = prepareRebuildTargetConfig( sandboxName, @@ -198,6 +230,7 @@ export async function prepareRebuildTargetPreflights(args: { bail, ); if (!recreateOptions) return null; + if (mcpRuntimeSelection) recreateOptions.runtimeSelection = mcpRuntimeSelection; let managedWorkloadRebuildCatalog: Awaited< ReturnType > = null; @@ -297,10 +330,13 @@ export async function prepareRebuildTargetPreflights(args: { }, resumeConfig.registryInferenceRoute, ), - recoverGateway: () => ensureRebuildTargetGatewaySelected(sandboxName, sandboxEntry, log, bail), + recoverGateway: () => + ensureRebuildTargetGatewaySelected(sandboxName, sandboxEntry, log, bail, mcpRuntimeSelection), }); if (!gatewayRecovered) return null; - if (!checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail)) return null; + if (!checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail, mcpRuntimeSelection)) { + return null; + } const rebuildsDcodeSandbox = isDcodeRebuildAgent(rebuildAgent); const rebuildsManagedWorkload = recreateOptions.managedWorkloadRebuild !== undefined; diff --git a/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts b/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts index 801bdb03c54..1850c971f57 100644 --- a/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, expect, it, vi } from "vitest"; +import * as openshellRuntime from "../../adapters/openshell/runtime"; import type { GatewayProviderMetadata } from "../../onboard/gateway-provider-metadata"; import { canRecreateMissingRebuildGatewayProvider, checkRebuildGatewayCredentialReuseOrBail, checkRebuildGatewayProviderOrBail, classifyRebuildGatewayProviderRegistration, + inspectRebuildGatewayProviderRegistration, shouldVerifyRebuildGatewayProvider, } from "./rebuild-provider-preflight"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; @@ -48,6 +50,7 @@ const throwingBail = (message: string): never => { }; afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -192,6 +195,48 @@ describe("classifyRebuildGatewayProviderRegistration", () => { }); }); +describe("inspectRebuildGatewayProviderRegistration", () => { + it("pins the delete-edge lookup to the frozen target under hostile ambient selectors (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + const runOpenshell = vi.spyOn(openshellRuntime, "runOpenshell").mockReturnValue({ + status: 1, + stdout: "", + stderr: "provider not found", + } as never); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", + }; + + expect( + inspectRebuildGatewayProviderRegistration( + "compatible-endpoint", + vi.fn(), + "Delete-edge", + runtimeSelection, + ), + ).toBe("missing"); + + expect(runOpenshell).toHaveBeenCalledWith( + ["provider", "get", "compatible-endpoint"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + const env = runOpenshell.mock.calls[0]?.[1]?.env as Record; + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + }); +}); + describe("checkRebuildGatewayCredentialReuseOrBail", () => { it("accepts an exact complete registry route and gateway provider identity", () => { expect( diff --git a/src/lib/actions/sandbox/rebuild-provider-preflight.ts b/src/lib/actions/sandbox/rebuild-provider-preflight.ts index 26489a176b2..14c4b0053c2 100644 --- a/src/lib/actions/sandbox/rebuild-provider-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-provider-preflight.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { runOpenshell } from "../../adapters/openshell/runtime"; +import { buildSelectedOpenShellSubprocessEnv } from "../../adapters/openshell/command-argv"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { RD as _RD, R } from "../../cli/terminal-style"; import { hasBedrockRuntimeAwsAuthEnv, @@ -85,10 +87,17 @@ export function inspectRebuildGatewayProviderRegistration( provider: string, log: (msg: string) => void, phase = "Preflight", + runtimeSelection?: OpenShellRuntimeSelection, ): RebuildGatewayProviderRegistration { const result = runOpenshell(["provider", "get", provider], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"], + ...(runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}), }); const registration = classifyRebuildGatewayProviderRegistration(result, provider); log( diff --git a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts index e0db2c06f9c..70a71e7fd30 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts @@ -608,6 +608,72 @@ describe("rebuild replacement journal", () => { expect(resumed.id).toBe(first.id); }); + it("pins an interrupted replacement observation to its recorded OpenShell target (#10514)", () => { + open(); + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const resolveRuntimeSelection = vi.fn(() => runtimeSelection); + mocks.captureOpenshell.mockClear(); + + const resumed = openRebuildRecreateJournal({ + target: NON_DEFAULT_TARGET, + expectedGatewayAuthority: STANDALONE_GATEWAY_AUTHORITY, + agentName: "langchain-deepagents-code", + targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), + log: vi.fn(), + resolveRuntimeSelection, + }); + + expect(resolveRuntimeSelection).toHaveBeenCalledOnce(); + expect(resumed.runtimeSelection).toEqual(runtimeSelection); + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["sandbox", "get", "-g", "nemoclaw-9090", "alpha"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + }); + + it("pins the first replacement observation to its recorded OpenShell target (#10514)", () => { + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const resolveRuntimeSelection = vi.fn(() => runtimeSelection); + + const journal = openRebuildRecreateJournal({ + target: NON_DEFAULT_TARGET, + expectedGatewayAuthority: STANDALONE_GATEWAY_AUTHORITY, + agentName: "langchain-deepagents-code", + targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), + log: vi.fn(), + resolveRuntimeSelection, + }); + + expect(resolveRuntimeSelection).toHaveBeenCalledOnce(); + expect(journal.runtimeSelection).toEqual(runtimeSelection); + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["sandbox", "get", "-g", "nemoclaw-9090", "alpha"], + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + }); + it("retires the journal of a proven replacement instead of deleting it again (#7734)", () => { const first = open(); proveReplacement(first.targetGeneration); diff --git a/src/lib/actions/sandbox/rebuild-recreate-journal.ts b/src/lib/actions/sandbox/rebuild-recreate-journal.ts index b34ce2f7aea..da247607daf 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.ts @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { checkpointGatewayAuthority, gatewayOwnerFromCheckpoint, @@ -533,6 +534,7 @@ export interface RebuildRecreateJournal { readonly gatewayAuthority: CheckpointGatewayAuthority; readonly targetGeneration: string; readonly targetIntentFingerprint: string; + readonly runtimeSelection?: OpenShellRuntimeSelection; beginDelete(): RebuildRecreateSourcePresence; confirmDeleted(): void; completeAcceptedTarget(): void; @@ -600,6 +602,8 @@ export interface OpenRebuildRecreateJournalInput { readonly targetIntentFingerprint: string; readonly log: (message: string) => void; readonly observe?: RebuildSandboxObserver; + readonly runtimeSelection?: OpenShellRuntimeSelection; + readonly resolveRuntimeSelection?: () => OpenShellRuntimeSelection; /** * Invoked with ready-to-print lines when gateway authority cannot be * revalidated, so the command layer can fail cleanly (#8103). @@ -611,7 +615,12 @@ export function openRebuildRecreateJournal( input: OpenRebuildRecreateJournalInput, ): RebuildRecreateJournal { const { target, agentName, targetIntentFingerprint, log } = input; - const observe = input.observe ?? observeRebuildSandbox; + const observeTarget = ( + runtimeSelection = input.runtimeSelection, + ): ReturnType => + input.observe + ? input.observe(target) + : observeRebuildSandbox(target, undefined, runtimeSelection); // Authority revalidation runs before the destroy phase. Handing the refusal // to the caller lets rebuild report the migration and its remedy instead of // crashing with a Node stack trace (#8103). The dedicated rebuild resolver @@ -639,6 +648,9 @@ export function openRebuildRecreateJournal( throw error; } const gatewayAuthority = checkpointGatewayAuthority(authority); + const runtimeSelection = input.resolveRuntimeSelection + ? input.resolveRuntimeSelection() + : input.runtimeSelection; const owned = ownSandboxRecreateTransaction({ sessionStore: { loadSession: onboardSession.loadSession, @@ -650,7 +662,7 @@ export function openRebuildRecreateJournal( gatewayPort: target.gatewayPort, targetIntentFingerprint, readRegistryEntry: () => registry.getSandbox(target.sandboxName), - observe: () => observe(target), + observe: () => observeTarget(runtimeSelection), decorateCheckpoint: (current, checkpoint, now) => ({ ...checkpoint, machineState: current.machine.state, @@ -704,6 +716,7 @@ export function openRebuildRecreateJournal( gatewayAuthority, targetGeneration: transaction.targetGeneration, targetIntentFingerprint: transaction.targetIntentFingerprint, + ...(runtimeSelection ? { runtimeSelection } : {}), beginDelete: () => { const begun = beginSandboxRecreateDelete({ sessionStore: { @@ -716,14 +729,14 @@ export function openRebuildRecreateJournal( targetIntentFingerprint, revalidateGatewayAuthority, readRegistryEntry: () => registry.getSandbox(target.sandboxName), - observe: () => observe(target), + observe: () => observeTarget(runtimeSelection), }); currentTransaction = begun.transaction; phase = currentTransaction.phase; return begun.sourcePresence; }, confirmDeleted: () => { - if (observe(target).state !== "missing") { + if (observeTarget(runtimeSelection).state !== "missing") { throw new Error( `Cannot continue sandbox '${target.sandboxName}' replacement: OpenShell still reports the journaled source after delete.`, ); diff --git a/src/lib/actions/sandbox/rebuild-recreate-phase.ts b/src/lib/actions/sandbox/rebuild-recreate-phase.ts index ea100668b5e..c0ff898c1af 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-phase.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-phase.ts @@ -273,6 +273,9 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): try { await rebuildOnboardDependencies.onboard({ ...recreateOptions, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), ...(preparedBackupRecovery ? { allowRemovedImmutabilityStateRecord: true } : {}), rebuildGatewayAuthority, rebuildPolicySourcePath, diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index 7c754d4a999..994eb0769a5 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -77,6 +77,40 @@ describe("rebuild filesystem restore", () => { ); }); + it("carries the frozen OpenShell target into fresh-plugin discovery and SSH restore reads (#10514)", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const restore = vi + .spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority") + .mockReturnValue({ + success: true, + restoredDirs: [], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + const runtimeSelection = { + gatewayName: "nemoclaw-8081", + localTlsDir: "/authority/tls", + workspace: "default", + }; + + runRebuildRestorePhase({ + sandboxName: "alpha", + targetAgentType: "openclaw", + targetImageIsCustom: false, + backupManifest, + runtimeSelection, + log: vi.fn(), + }); + + expect(restore).toHaveBeenCalledWith( + "alpha", + backupManifest, + { targetAgentType: "openclaw", runtimeSelection }, + { getSandbox: expect.any(Function) }, + ); + }); + it("migrates restored Hermes dashboard state into its current profile", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority").mockReturnValue({ diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index 79c01047851..70ec9c9362b 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { G, R, YW } from "../../cli/terminal-style"; import * as sandboxConfig from "../../sandbox/config"; import { load as loadRegistry } from "../../state/registry/persistence"; @@ -14,6 +15,7 @@ export interface RebuildRestorePhaseInput { targetImageIsCustom: boolean; backupManifest: RebuildBackupManifest; reconcileManagedDcodeObservability?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; log: RebuildLog; } @@ -23,7 +25,14 @@ export interface RebuildRestorePhaseResult { /** Restore sandbox files. The replacement already received the captured live OpenShell policy. */ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): RebuildRestorePhaseResult { - const { sandboxName, targetAgentType, targetImageIsCustom, backupManifest, log } = input; + const { + sandboxName, + targetAgentType, + targetImageIsCustom, + backupManifest, + runtimeSelection, + log, + } = input; let restoreSucceeded = true; if (backupManifest) { console.log(""); @@ -34,6 +43,7 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild { targetAgentType, ...(targetImageIsCustom ? { allowCustomImageWholeStateFileRestore: true } : {}), + ...(runtimeSelection ? { runtimeSelection } : {}), }, { getSandbox: (name) => loadRegistry().sandboxes[name] ?? null }, ); diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index 4b5f88c68d4..ed52d5daa6a 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -24,6 +24,7 @@ import * as registry from "../../state/registry"; import * as sandboxState from "../../state/sandbox"; import * as sandboxSession from "../../state/sandbox-session"; import * as destroy from "./destroy"; +import * as mcpBridgeProvider from "./mcp-bridge-provider"; import { rebuildSandbox } from "./rebuild"; import * as rebuildImagePreflight from "./rebuild-custom-image-preflight"; import { rebuildOnboardDependencies } from "./rebuild-onboard-dependencies"; @@ -203,6 +204,10 @@ describe("rebuild resume snapshot repair", () => { } as never), vi.spyOn(registry, "updateSandbox").mockReturnValue(true), vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [] } as never), + vi.spyOn(mcpBridgeProvider, "getMcpProviderInspectionRuntimeSelection").mockReturnValue({ + gatewayName: "nemoclaw", + workspace: "default", + }), vi.spyOn(rebuildRoutePreflight, "commitRebuildRoutePreflight").mockReturnValue({ ok: true, receipt: { diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts index 0b549a78e15..3592792193f 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts @@ -199,10 +199,16 @@ describe("preflightRebuildTargetRuntime GPU route", () => { describe("authoritative rebuild readiness", () => { it("passes recorded managed-vLLM intent to the pre-delete readiness gate (#9292)", async () => { const authority = { checkpoint: "gateway-authority" }; + const runtimeSelection = { + gatewayName: "nemoclaw", + localTlsDir: "/authority/tls", + workspace: "default", + }; mocks.preflightAuthoritativeRebuildTarget.mockResolvedValue(authority); const recreateOptions = { ...RECREATE_OPTIONS, allowDeferredN1xManagedVllm: true, + runtimeSelection, } as RebuildRecreateOnboardOpts; const bail = vi.fn((message: string): never => { throw new Error(message); @@ -222,6 +228,7 @@ describe("authoritative rebuild readiness", () => { allowDeferredN1xManagedVllm: true, provider: "vllm-local", model: "test-model", + runtimeSelection, sandboxName: "alpha", }), ); diff --git a/src/lib/actions/sandbox/reconcile-session-models.test.ts b/src/lib/actions/sandbox/reconcile-session-models.test.ts index f1bcb98a097..77978d01ed3 100644 --- a/src/lib/actions/sandbox/reconcile-session-models.test.ts +++ b/src/lib/actions/sandbox/reconcile-session-models.test.ts @@ -295,6 +295,27 @@ describe("reconcileStalePinnedSessionModelsAfterRebuild", () => { ); }); + it("reuses the rebuild target for every restored session read and write (#10514)", () => { + executeSandboxCommandMock + .mockReturnValueOnce({ status: 0, stdout: config, stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: staleStore, stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", + }; + + reconcileStalePinnedSessionModelsAfterRebuild("alpha", vi.fn(), runtimeSelection); + + expect(executeSandboxCommandMock).toHaveBeenCalledTimes(3); + expect(executeSandboxCommandMock.mock.calls.map((call) => call[2])).toEqual([ + { runtimeSelection }, + { runtimeSelection }, + { runtimeSelection }, + ]); + }); + it("stops when the restored config has no primary model (#7102)", () => { executeSandboxCommandMock.mockReturnValueOnce({ status: 0, diff --git a/src/lib/actions/sandbox/reconcile-session-models.ts b/src/lib/actions/sandbox/reconcile-session-models.ts index 3a20f18fd3c..39be8602e33 100644 --- a/src/lib/actions/sandbox/reconcile-session-models.ts +++ b/src/lib/actions/sandbox/reconcile-session-models.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from "node:crypto"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime"; import { shellQuote } from "../../core/shell-quote"; import { MANAGED_PROVIDER_ID } from "../../inference/config"; import { isSafeModelId } from "../../validation"; @@ -221,8 +222,25 @@ export function reconcilePinnedSessionModels( }; } -function readPrimaryModelRef(sandboxName: string): string | null { - const res = executeSandboxCommand(sandboxName, `cat ${OPENCLAW_CONFIG_PATH} 2>/dev/null`); +function executeReconcileCommand( + sandboxName: string, + command: string, + runtimeSelection?: OpenShellRuntimeSelection, +) { + return runtimeSelection + ? executeSandboxCommand(sandboxName, command, { runtimeSelection }) + : executeSandboxCommand(sandboxName, command); +} + +function readPrimaryModelRef( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): string | null { + const res = executeReconcileCommand( + sandboxName, + `cat ${OPENCLAW_CONFIG_PATH} 2>/dev/null`, + runtimeSelection, + ); if (!res || res.status !== 0 || !res.stdout.trim()) return null; try { const config = JSON.parse(res.stdout) as { @@ -252,14 +270,19 @@ function readPrimaryModelRef(sandboxName: string): string | null { export function reconcileStalePinnedSessionModelsAfterRebuild( sandboxName: string, log: RebuildLog, + runtimeSelection?: OpenShellRuntimeSelection, ): void { - const primary = readPrimaryModelRef(sandboxName); + const primary = readPrimaryModelRef(sandboxName, runtimeSelection); if (!primary) { log("Session model reconcile skipped: could not read agents.defaults.model.primary"); return; } const sessionsPath = defaultAgentSessionsPath(DEFAULT_AGENT_ID); - const readResult = executeSandboxCommand(sandboxName, `cat ${sessionsPath} 2>/dev/null`); + const readResult = executeReconcileCommand( + sandboxName, + `cat ${sessionsPath} 2>/dev/null`, + runtimeSelection, + ); if (!readResult || readResult.status !== 0 || !readResult.stdout.trim()) { log(`Session model reconcile skipped: no session store at ${sessionsPath}`); return; @@ -269,9 +292,10 @@ export function reconcileStalePinnedSessionModelsAfterRebuild( log("Session model reconcile: no stale pinned session models"); return; } - const writeResult = executeSandboxCommand( + const writeResult = executeReconcileCommand( sandboxName, buildSessionStoreReplaceCommand(sessionsPath, reconciled.content, readResult.stdout), + runtimeSelection, ); if (!writeResult || writeResult.status !== 0) { log( diff --git a/src/lib/adapters/openshell/forward-service.test.ts b/src/lib/adapters/openshell/forward-service.test.ts index 9a4d12b727d..2de4e61ca73 100644 --- a/src/lib/adapters/openshell/forward-service.test.ts +++ b/src/lib/adapters/openshell/forward-service.test.ts @@ -39,6 +39,12 @@ describe("OpenShell forward service", () => { ]); }); + it("builds the direct ForwardTcp command for a selected non-default workspace", () => { + expect(buildForwardServiceArgs({ ...target, workspace: "review-workspace" })).toContain( + "review-workspace", + ); + }); + it("detaches the OpenShell child and waits for its local port", () => { const unref = vi.fn(); const spawnDetached = vi.fn(() => ({ unref })); diff --git a/src/lib/adapters/openshell/forward-service.ts b/src/lib/adapters/openshell/forward-service.ts index fe4f0f75519..8f78fe81983 100644 --- a/src/lib/adapters/openshell/forward-service.ts +++ b/src/lib/adapters/openshell/forward-service.ts @@ -15,7 +15,7 @@ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)); export interface ForwardServiceTarget { readonly executable: string; readonly gatewayName: string; - readonly workspace: "default"; + readonly workspace: string; readonly sandboxName: string; readonly localHost: "127.0.0.1" | "0.0.0.0"; readonly localPort: number; @@ -56,8 +56,8 @@ export function validateForwardServiceTarget(target: ForwardServiceTarget): Forw if (!isCanonicalNemoClawGatewayName(target.gatewayName)) { throw new Error("OpenShell forward service gateway must be a canonical NemoClaw gateway"); } - if (target.workspace !== "default") { - throw new Error("OpenShell forward service workspace must be default"); + if (!isValidName(target.workspace)) { + throw new Error("OpenShell forward service workspace is invalid"); } if (!isValidName(target.sandboxName)) { throw new Error("OpenShell forward service sandbox name is invalid"); diff --git a/src/lib/adapters/openshell/gateway-drift.test.ts b/src/lib/adapters/openshell/gateway-drift.test.ts index 3008874cdeb..214e29ddf3f 100644 --- a/src/lib/adapters/openshell/gateway-drift.test.ts +++ b/src/lib/adapters/openshell/gateway-drift.test.ts @@ -23,6 +23,7 @@ describe("OpenShell gateway drift preflight", () => { afterEach(() => { for (const spy of spies) spy.mockRestore(); spies = []; + vi.unstubAllEnvs(); }); it("parses OpenShell cluster image versions", () => { @@ -120,6 +121,71 @@ describe("OpenShell gateway drift preflight", () => { expect(isGatewayClusterActiveForGateway("nemoclaw", { expectedGatewayPort: 9090 })).toBe(false); }); + it("pins gateway health probes to the frozen OpenShell target (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.example.invalid"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_TOKEN", "hostile-token"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + const openshellRuntime = requireDist("./runtime.js"); + const docker = requireDist("../docker/inspect.js"); + const captureOpenshell = vi + .spyOn(openshellRuntime, "captureOpenshell") + .mockReturnValueOnce({ + status: 0, + output: "Server Status\n\n Gateway: nemoclaw-9090\n Status: Connected", + }) + .mockReturnValue({ + status: 0, + output: + "Gateway Info\n\n Gateway: nemoclaw-9090\n Gateway endpoint: https://127.0.0.1:9090", + }); + const inspectContainer = vi + .spyOn(docker, "dockerContainerInspectFormat") + .mockReturnValueOnce("true") + .mockReturnValue('{"30051/tcp":[{"HostIp":"0.0.0.0","HostPort":"9090"}]}'); + spies.push( + captureOpenshell, + inspectContainer, + ); + const runtimeSelection = { + gatewayName: "nemoclaw-9090", + localTlsDir: "/authority/tls", + workspace: "default", + }; + + expect( + isGatewayClusterActiveForGateway(runtimeSelection.gatewayName, { + expectedGatewayPort: 9090, + runtimeSelection, + }), + ).toBe(true); + const selectedProbeOptions = expect.objectContaining({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + OPENSHELL_WORKSPACE: "default", + }), + replaceEnv: true, + }); + expect(captureOpenshell).toHaveBeenNthCalledWith(1, ["status"], selectedProbeOptions); + expect(captureOpenshell).toHaveBeenNthCalledWith( + 2, + ["gateway", "info", "-g", "nemoclaw-9090"], + selectedProbeOptions, + ); + expect(captureOpenshell).toHaveBeenNthCalledWith( + 3, + ["gateway", "info"], + selectedProbeOptions, + ); + const firstProbeOptions = captureOpenshell.mock.calls[0]?.[1] as + | { env?: Record } + | undefined; + const selectedEnv = firstProbeOptions?.env; + expect(selectedEnv).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(selectedEnv).not.toHaveProperty("OPENSHELL_TOKEN"); + }); + it("ignores stale cluster containers whose published port is not the active gateway endpoint", () => { const openshellRuntime = requireDist("./runtime.js"); const docker = requireDist("../docker/inspect.js"); diff --git a/src/lib/adapters/openshell/gateway-drift.ts b/src/lib/adapters/openshell/gateway-drift.ts index 9f1d33bb9df..254302b8dcc 100644 --- a/src/lib/adapters/openshell/gateway-drift.ts +++ b/src/lib/adapters/openshell/gateway-drift.ts @@ -30,6 +30,8 @@ import { } from "./client"; import { resolveOpenshell } from "./resolve"; import { captureOpenshell, getInstalledOpenshellVersionOrNull } from "./runtime"; +import { buildSelectedOpenShellSubprocessEnv } from "./command-argv"; +import type { OpenShellRuntimeSelection } from "./runtime-selection"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts"; export type { GatewayReuseState, ManagedGatewayEndpointBinding }; @@ -99,6 +101,7 @@ export function isHostProcessGatewayDrift( export type GatewayDriftOptions = { gatewayName?: string; deps?: GatewayDriftDeps; + runtimeSelection?: OpenShellRuntimeSelection; timeoutMs?: number; }; @@ -215,19 +218,34 @@ export function isGatewayClusterActiveForGateway( gatewayName = DEFAULT_GATEWAY_NAME, { expectedGatewayPort, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, - }: { expectedGatewayPort?: number; timeoutMs?: number } = {}, + }: { + expectedGatewayPort?: number; + runtimeSelection?: OpenShellRuntimeSelection; + timeoutMs?: number; + } = {}, ): boolean { + if (runtimeSelection && runtimeSelection.gatewayName !== gatewayName) return false; + const selectedOptions = runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}; const status = captureOpenshell(["status"], { ignoreError: true, + ...selectedOptions, timeout: timeoutMs, }); const gatewayInfo = captureOpenshell(["gateway", "info", "-g", gatewayName], { ignoreError: true, + ...selectedOptions, timeout: timeoutMs, }); const activeGatewayInfo = captureOpenshell(["gateway", "info"], { ignoreError: true, + ...selectedOptions, timeout: timeoutMs, }); if (!isGatewayHealthy(status.output, gatewayInfo.output, activeGatewayInfo.output, gatewayName)) { @@ -255,6 +273,7 @@ export function isGatewayClusterActiveForGateway( export function getGatewayClusterImageDrift({ gatewayName = DEFAULT_GATEWAY_NAME, deps = {}, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, }: GatewayDriftOptions = {}): GatewayClusterImageDrift | null { if (isGatewayDriftPreflightDisabled(deps)) { @@ -268,7 +287,7 @@ export function getGatewayClusterImageDrift({ deps.isGatewayClusterActive?.(gatewayName) ?? (typeof deps.getGatewayClusterImageRef === "function" ? true - : isGatewayClusterActiveForGateway(gatewayName, { timeoutMs })); + : isGatewayClusterActiveForGateway(gatewayName, { runtimeSelection, timeoutMs })); if (!clusterActive) { return null; } @@ -412,6 +431,7 @@ export function getHostProcessGatewayRuntimeOrNull({ export function getGatewayHostProcessDrift({ gatewayName = DEFAULT_GATEWAY_NAME, deps = {}, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, }: GatewayDriftOptions = {}): GatewayHostProcessDrift | null { if (isGatewayDriftPreflightDisabled(deps)) { @@ -431,7 +451,7 @@ export function getGatewayHostProcessDrift({ if (clusterImage) { const clusterActive = deps.isGatewayClusterActive?.(gatewayName) ?? - isGatewayClusterActiveForGateway(gatewayName, { timeoutMs }); + isGatewayClusterActiveForGateway(gatewayName, { runtimeSelection, timeoutMs }); if (clusterActive) { return null; } @@ -466,6 +486,7 @@ export function observeOpenShellGatewayVersionCompatibility({ source, gatewayName = DEFAULT_GATEWAY_NAME, deps = {}, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, }: GatewayVersionCompatibilityOptions): GatewayVersionCompatibility { if (isGatewayDriftPreflightDisabled(deps)) return "unknown"; @@ -478,7 +499,7 @@ export function observeOpenShellGatewayVersionCompatibility({ if (source === "legacy-cluster") { const clusterActive = deps.isGatewayClusterActive?.(gatewayName) ?? - isGatewayClusterActiveForGateway(gatewayName, { timeoutMs }); + isGatewayClusterActiveForGateway(gatewayName, { runtimeSelection, timeoutMs }); if (!clusterActive) return "unknown"; const clusterImage = typeof deps.getGatewayClusterImageRef === "function" @@ -503,13 +524,24 @@ export function observeOpenShellGatewayVersionCompatibility({ export function detectOpenShellStateRpcPreflightIssue({ gatewayName = DEFAULT_GATEWAY_NAME, deps = {}, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, }: GatewayDriftOptions = {}): OpenShellStateRpcIssue | null { - const imageDrift = getGatewayClusterImageDrift({ gatewayName, deps, timeoutMs }); + const imageDrift = getGatewayClusterImageDrift({ + gatewayName, + deps, + runtimeSelection, + timeoutMs, + }); if (imageDrift) { return { kind: "image_drift", drift: imageDrift }; } - const hostDrift = getGatewayHostProcessDrift({ gatewayName, deps, timeoutMs }); + const hostDrift = getGatewayHostProcessDrift({ + gatewayName, + deps, + runtimeSelection, + timeoutMs, + }); if (hostDrift) { return { kind: "host_process_drift", drift: hostDrift }; } @@ -521,6 +553,7 @@ export function detectOpenShellStateRpcResultIssue( { gatewayName = DEFAULT_GATEWAY_NAME, deps = {}, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, }: GatewayDriftOptions = {}, ): OpenShellStateRpcIssue | null { @@ -532,8 +565,8 @@ export function detectOpenShellStateRpcResultIssue( kind: "protobuf_mismatch", drift: isGatewayDriftPreflightDisabled(deps) ? null - : (getGatewayClusterImageDrift({ gatewayName, deps, timeoutMs }) ?? - getGatewayHostProcessDrift({ gatewayName, deps, timeoutMs })), + : (getGatewayClusterImageDrift({ gatewayName, deps, runtimeSelection, timeoutMs }) ?? + getGatewayHostProcessDrift({ gatewayName, deps, runtimeSelection, timeoutMs })), output, }; } diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index 9d05d258482..6ba1a34de50 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { authoritativeRebuildSandboxFlowOptions, authoritativeRebuildRuntimePreflightOptions, + beginAuthoritativeRebuildRuntimeSelectionScope, type AuthoritativeRebuildTargetDeps, type AuthoritativeRebuildPreflightOptions, preflightAuthoritativeRebuildTarget, @@ -152,6 +153,48 @@ describe("authoritative rebuild gateway binding", () => { }); }); +describe("authoritative rebuild OpenShell runtime selection", () => { + it("replaces hostile ambient selectors for inner onboard and restores them (#10514)", () => { + const env: NodeJS.ProcessEnv = { + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "hostile-gateway", + OPENSHELL_GATEWAY_AUTH_TOKEN: "hostile-token", + OPENSHELL_GATEWAY_ENDPOINT: "https://hostile.invalid", + OPENSHELL_LOCAL_TLS_DIR: "/hostile/tls", + OPENSHELL_WORKSPACE: "hostile-workspace", + }; + const previous = { ...env }; + const restore = beginAuthoritativeRebuildRuntimeSelectionScope( + { + authoritativeResumeConfig: true, + onboardLockAlreadyHeld: true, + recreateSandbox: true, + resume: true, + targetGatewayName: "nemoclaw-8081", + targetGatewayPort: 8081, + runtimeSelection: { + gatewayName: "nemoclaw-8081", + localTlsDir: "/authority/tls", + workspace: "default", + }, + }, + env, + ); + + expect(env).toMatchObject({ + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "nemoclaw-8081", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + OPENSHELL_WORKSPACE: "default", + }); + expect(env.OPENSHELL_GATEWAY_AUTH_TOKEN).toBeUndefined(); + expect(env.OPENSHELL_GATEWAY_ENDPOINT).toBeUndefined(); + + restore(); + expect(env).toEqual(previous); + }); +}); + describe("prepared provider reconfiguration handoff", () => { const providerTarget = { sandboxName: "alpha", diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index c08cee58c06..aeda139f451 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -2,6 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { findDashboardForwardOwner } from "./dashboard-port"; +import { + replaceOpenShellRuntimeSelectionEnv, + snapshotOpenShellEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection"; import { resolveGatewayName } from "./gateway-binding"; import type { InferenceRouteState } from "./inference-route"; import { assertDashboardPortNotReserved } from "./preflight-ports"; @@ -34,9 +39,53 @@ export type AuthoritativeGatewayOptions = Pick< "authoritativeResumeConfig" | "targetGatewayName" | "targetGatewayPort" | "onboardLockAlreadyHeld" >; +type AuthoritativeRuntimeSelectionOptions = AuthoritativeGatewayOptions & + Pick; + +function beginOpenShellRuntimeSelectionEnvScope( + runtimeSelection: OpenShellRuntimeSelection, + env: NodeJS.ProcessEnv, +): () => void { + const restore = snapshotOpenShellEnv(env); + replaceOpenShellRuntimeSelectionEnv(env, runtimeSelection); + return restore; +} + +/** Keep every OpenShell child in an inner rebuild onboard on its frozen target. */ +export function beginAuthoritativeRebuildRuntimeSelectionScope( + opts: AuthoritativeRuntimeSelectionOptions, + env: NodeJS.ProcessEnv = process.env, +): () => void { + const runtimeSelection = opts.runtimeSelection; + if (!runtimeSelection) return () => undefined; + const gateway = resolveAuthoritativeOnboardGatewayBinding(opts); + if ( + opts.authoritativeResumeConfig !== true || + opts.resume !== true || + opts.recreateSandbox !== true || + opts.onboardLockAlreadyHeld !== true || + !gateway + ) { + throw new Error( + "An OpenShell runtime selection may be supplied only for a locked authoritative rebuild resume.", + ); + } + if (runtimeSelection.gatewayName !== gateway.name) { + throw new Error( + `OpenShell runtime selection '${runtimeSelection.gatewayName}' does not match authoritative gateway '${gateway.name}'.`, + ); + } + return beginOpenShellRuntimeSelectionEnvScope(runtimeSelection, env); +} + export type AuthoritativeRebuildPreflightOptions = Pick< OnboardOptions, - "sandboxGpu" | "sandboxGpuDevice" | "noGpu" | "controlUiPort" | "allowDeferredN1xManagedVllm" + | "sandboxGpu" + | "sandboxGpuDevice" + | "noGpu" + | "controlUiPort" + | "allowDeferredN1xManagedVllm" + | "runtimeSelection" > & { authoritativeResumeConfig: true; /** Internal prepared-backup recovery defers route repair to authoritative onboard. */ @@ -102,6 +151,7 @@ export type AuthoritativeRebuildTarget = { model: string; targetGatewayName: string; controlUiPort: number | null; + runtimeSelection?: OpenShellRuntimeSelection; }; /** Validate the one-shot authority to reconstruct a provider during a locked rebuild resume. */ @@ -221,11 +271,23 @@ export async function preflightAuthoritativeRebuildTarget( deps: AuthoritativeRebuildTargetDeps, ): Promise { const env = deps.env ?? process.env; - const previousGateway = env.OPENSHELL_GATEWAY; const fail = (message: string): never => { throw new Error(message); }; - env.OPENSHELL_GATEWAY = target.targetGatewayName; + const runtimeSelection = target.runtimeSelection; + if (runtimeSelection && runtimeSelection.gatewayName !== target.targetGatewayName) { + fail( + `OpenShell runtime selection '${runtimeSelection.gatewayName}' does not match authoritative gateway '${target.targetGatewayName}'.`, + ); + } + const previousGateway = env.OPENSHELL_GATEWAY; + const restoreRuntimeSelection = runtimeSelection + ? beginOpenShellRuntimeSelectionEnvScope(runtimeSelection, env) + : () => { + if (previousGateway === undefined) delete env.OPENSHELL_GATEWAY; + else env.OPENSHELL_GATEWAY = previousGateway; + }; + if (!runtimeSelection) env.OPENSHELL_GATEWAY = target.targetGatewayName; try { if (!deps.resolveBaselinePolicy(target.sandboxName)) { fail(`Could not read the baseline policy for sandbox '${target.sandboxName}'.`); @@ -261,7 +323,6 @@ export async function preflightAuthoritativeRebuildTarget( // sandbox unavailable first; the natural OpenShell lifecycle releases its // port. The replacement launch then refuses any port that stayed occupied. } finally { - if (previousGateway === undefined) delete env.OPENSHELL_GATEWAY; - else env.OPENSHELL_GATEWAY = previousGateway; + restoreRuntimeSelection(); } } diff --git a/src/lib/onboard/entry-options.ts b/src/lib/onboard/entry-options.ts index a25f813cac9..acd09476bf0 100644 --- a/src/lib/onboard/entry-options.ts +++ b/src/lib/onboard/entry-options.ts @@ -4,6 +4,7 @@ import { isNonInteractiveEnv } from "../core/non-interactive"; import { getNameValidationGuidance } from "../name-validation"; export { enforceRemovedImmutabilityMigrationBoundary } from "../state/migrations/removed-immutability"; +import { beginAuthoritativeRebuildRuntimeSelectionScope } from "./authoritative-rebuild-target"; import { cliDisplayName } from "./branding"; import { canonicalPlaceholderKeys, @@ -11,6 +12,7 @@ import { parseExtraPlaceholderKeys, } from "./extra-placeholder-keys"; import { RESERVED_SANDBOX_NAMES } from "./sandbox-agent"; +import type { OnboardOptions } from "./types"; import { requireStationExpressResumeIntent, type StationExpressSessionLike, @@ -74,11 +76,19 @@ interface DefaultRunEntryState { } type NonInteractiveEntryOptions = { nonInteractive?: boolean }; -type ResumableEntryOptions = NonInteractiveEntryOptions & { - resume?: boolean; - fresh?: boolean; - apfInterceptorRequested?: boolean | null; -}; +type ResumableEntryOptions = Pick< + OnboardOptions, + | "apfInterceptorRequested" + | "authoritativeResumeConfig" + | "fresh" + | "nonInteractive" + | "onboardLockAlreadyHeld" + | "recreateSandbox" + | "resume" + | "runtimeSelection" + | "targetGatewayName" + | "targetGatewayPort" +>; const PROVIDER_INTENT_ENV_KEYS = [ "NEMOCLAW_PROVIDER", @@ -256,7 +266,12 @@ export function wrapOnboard( options?.apfInterceptorRequested === true, process.env, ); - await run(options); + const restoreRuntimeSelection = beginAuthoritativeRebuildRuntimeSelectionScope(options ?? {}); + try { + await run(options); + } finally { + restoreRuntimeSelection(); + } }; return wrapStationExpressOnboard( withNonInteractiveEnvironment(guardProviderlessInput), diff --git a/src/lib/onboard/gateway/registration.ts b/src/lib/onboard/gateway/registration.ts index 7cc72033468..c02ecaf9d57 100644 --- a/src/lib/onboard/gateway/registration.ts +++ b/src/lib/onboard/gateway/registration.ts @@ -48,8 +48,7 @@ export function createGatewayRegistration(deps: GatewayRegistrationDeps): Gatewa const runCaptureOpenshell: GatewayRegistrationDeps["runCaptureOpenshell"] = ( args, options = {}, - ) => - deps.runCaptureOpenshell(args, { ...options, ...runtimeOptions }); + ) => deps.runCaptureOpenshell(args, { ...options, ...runtimeOptions }); const runOpenshell: GatewayRegistrationDeps["runOpenshell"] = (args, options = {}) => deps.runOpenshell(args, { ...options, ...runtimeOptions }); const runQuietOpenshell = (args: string[]) => diff --git a/src/lib/onboard/sandbox-recreate-probe.ts b/src/lib/onboard/sandbox-recreate-probe.ts index 1290ef64fb1..ad0e66af54b 100644 --- a/src/lib/onboard/sandbox-recreate-probe.ts +++ b/src/lib/onboard/sandbox-recreate-probe.ts @@ -8,6 +8,8 @@ import { stripAnsi, } from "../adapters/openshell/client"; import { captureOpenshell } from "../adapters/openshell/runtime"; +import { buildSelectedOpenShellSubprocessEnv } from "../adapters/openshell/command-argv"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { parseSandboxPhase } from "../state/gateway"; import { @@ -97,12 +99,24 @@ export function observeSandboxPresenceOnGateway( export function observeSandboxOnGateway( target: SandboxRecreateTarget, capture: SandboxRecreateCapture = captureOpenshell, + runtimeSelection?: OpenShellRuntimeSelection, ): SandboxRecreateObservation { + if (runtimeSelection && runtimeSelection.gatewayName !== target.gatewayName) { + throw new Error( + `Cannot journal sandbox '${target.sandboxName}' replacement: selected gateway does not match the recorded target.`, + ); + } const probe = capture(["sandbox", "get", "-g", target.gatewayName, target.sandboxName], { ignoreError: true, includeStderr: true, includeStreams: true, timeout: OPENSHELL_PROBE_TIMEOUT_MS, + ...(runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}), }); const stdout = String(probe.stdout ?? (probe.status === 0 ? probe.output : "")).trim(); const combined = `${stdout}\n${String(probe.stderr ?? probe.output ?? "")}`.trim(); diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index c8a36bd1cec..c8e8b63deb3 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -140,6 +140,8 @@ export type OnboardOptions = { targetGatewayName?: string | null; /** Internal authoritative rebuild target; must match targetGatewayName. */ targetGatewayPort?: number | null; + /** Exact OpenShell client target frozen by the outer rebuild transaction. */ + runtimeSelection?: import("../adapters/openshell/runtime-selection").OpenShellRuntimeSelection; /** Internal rebuild handoff: the outer destructive lifecycle owns the onboard lock. */ onboardLockAlreadyHeld?: boolean; /** Internal command handoff: propagate an exit request after onboarding restores its scopes. */ diff --git a/src/lib/state/openclaw-config-restore-input.ts b/src/lib/state/openclaw-config-restore-input.ts index f518b8f017e..5c8c6b17e58 100644 --- a/src/lib/state/openclaw-config-restore-input.ts +++ b/src/lib/state/openclaw-config-restore-input.ts @@ -20,6 +20,7 @@ export type OpenClawConfigRestoreInputResult = export interface OpenClawConfigRestoreFromSandboxOptions { backupContents: Buffer; dir: string; + env?: NodeJS.ProcessEnv; freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; log?: (message: string) => void; previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; @@ -47,9 +48,11 @@ function readCurrentOpenClawConfig( dir: string, specPath: string, log: (message: string) => void, + env?: NodeJS.ProcessEnv, ): Buffer | null { const command = buildOpenClawConfigReadCommand(dir, specPath); const result = spawnSync("ssh", [...sshArgs, command], { + ...(env ? { env } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024, @@ -91,6 +94,7 @@ export function buildOpenClawConfigRestoreInput( export function buildOpenClawConfigRestoreInputFromSandbox({ backupContents, dir, + env, freshImagePluginInstalls, log = () => {}, previousImagePluginInstalls, @@ -117,7 +121,7 @@ export function buildOpenClawConfigRestoreInputFromSandbox({ } return buildOpenClawConfigRestoreInput( backupContents, - readCurrentOpenClawConfig(sshArgs, dir, specPath, log), + readCurrentOpenClawConfig(sshArgs, dir, specPath, log, env), { freshImagePluginInstalls, previousImagePluginInstalls }, ); } diff --git a/src/lib/state/openclaw-plugin-restore.ts b/src/lib/state/openclaw-plugin-restore.ts index d78ee0d8dbf..7193f629608 100644 --- a/src/lib/state/openclaw-plugin-restore.ts +++ b/src/lib/state/openclaw-plugin-restore.ts @@ -49,6 +49,7 @@ export type CompleteOpenClawImagePluginInstall = Omit; backupExtensionDirs: string[]; + discoverFreshPluginInstalls?: boolean; freshConfig: Record; freshPluginInstalls: OpenClawImagePluginInstall[]; previousPluginInstalls?: OpenClawImagePluginInstall[]; + runtimeSelection?: { + gatewayName: string; + localTlsDir?: string; + workspace: string; + }; }): { cleanupCommand: string | undefined; freshMarkers: Record; + openshellInvocations: Array<{ args: string[]; env: Record }>; restore: ReturnType; restoredConfig: Record; + sshInvocations: Array<{ cmd: string; env: Record }>; staleUserExtensionExists: boolean; userExtensionMarker: string; } { @@ -50,6 +58,7 @@ function runRestoreScenario(options: { const extensionsDir = path.join(openclawDir, "extensions"); const backupPath = path.join(fixture, "backup"); const backupExtensionsDir = path.join(backupPath, "extensions"); + const openshellLog = path.join(fixture, "openshell-log.jsonl"); const sshLog = path.join(fixture, "ssh-log.jsonl"); const freshExtensionDirs = [ "nemoclaw", @@ -101,7 +110,10 @@ function runRestoreScenario(options: { writeExecutable( openshell, `#!/usr/bin/env node +const fs = require("node:fs"); const args = process.argv.slice(2); +const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.startsWith("OPENSHELL_"))); +fs.appendFileSync(${JSON.stringify(openshellLog)}, JSON.stringify({ args, env }) + "\\n"); if (args[0] === "sandbox" && args[1] === "ssh-config") { process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); } @@ -117,7 +129,8 @@ const { spawnSync } = require("node:child_process"); const cmd = process.argv[process.argv.length - 1] || ""; const openclawDir = ${JSON.stringify(openclawDir)}; const extensionsDir = ${JSON.stringify(extensionsDir)}; -fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); +const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.startsWith("OPENSHELL_"))); +fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd, env }) + "\\n"); function readStdin() { const chunks = []; for (;;) { @@ -135,6 +148,10 @@ if (cmd.includes("${OPENCLAW_DIR}/extensions") && cmd.includes("-exec rm -rf")) } process.exit(0); } +if (cmd.includes("installed_plugin_index")) { + process.stdout.write(JSON.stringify({ version: 1, installRecords: {}, loadPaths: [] })); + process.exit(0); +} if (cmd.includes("tar --no-same-owner -xf -")) { const result = spawnSync("tar", ["--no-same-owner", "-xf", "-", "-C", openclawDir], { input: readStdin(), @@ -159,13 +176,22 @@ process.exit(1); process.env.PATH = `${binDir}:${previousPath ?? ""}`; const restore = restoreRecreatedSandboxState("alpha", backupPath, { targetAgentType: "openclaw", - freshOpenClawImagePluginInstalls: options.freshPluginInstalls, + ...(options.discoverFreshPluginInstalls + ? {} + : { freshOpenClawImagePluginInstalls: options.freshPluginInstalls }), + ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), }); - const loggedCommands = fs + const sshInvocations = fs .readFileSync(sshLog, "utf8") .trim() .split("\n") - .map((line) => JSON.parse(line).cmd as string); + .map((line) => JSON.parse(line) as { cmd: string; env: Record }); + const loggedCommands = sshInvocations.map(({ cmd }) => cmd); + const openshellInvocations = fs + .readFileSync(openshellLog, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { args: string[]; env: Record }); return { cleanupCommand: loggedCommands.find((command) => command.includes("-exec rm -rf")), @@ -175,8 +201,10 @@ process.exit(1); fs.readFileSync(path.join(extensionsDir, name, "marker.txt"), "utf8"), ]), ), + openshellInvocations, restore, restoredConfig: JSON.parse(fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf8")), + sshInvocations, staleUserExtensionExists: fs.existsSync(path.join(extensionsDir, "stale-user-extension")), userExtensionMarker: fs.readFileSync( path.join(extensionsDir, "user-extension", "marker.txt"), @@ -201,46 +229,95 @@ function expectSuccessfulRestore(result: ReturnType): expect(result.userExtensionMarker).toBe("restored\n"); } -describe("recreated OpenClaw state restore", () => { +describe("recreated OpenClaw state restore", { timeout: 30_000 }, () => { + it("pins plugin discovery and restore SSH to the frozen OpenShell target (#10514)", () => { + const previousEnv = { + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY, + OPENSHELL_GATEWAY_ENDPOINT: process.env.OPENSHELL_GATEWAY_ENDPOINT, + OPENSHELL_GATEWAY_INSECURE: process.env.OPENSHELL_GATEWAY_INSECURE, + OPENSHELL_LOCAL_TLS_DIR: process.env.OPENSHELL_LOCAL_TLS_DIR, + OPENSHELL_TOKEN: process.env.OPENSHELL_TOKEN, + OPENSHELL_WORKSPACE: process.env.OPENSHELL_WORKSPACE, + }; + process.env.OPENSHELL_GATEWAY = "hostile-gateway"; + process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://hostile.example.invalid"; + process.env.OPENSHELL_GATEWAY_INSECURE = "1"; + process.env.OPENSHELL_LOCAL_TLS_DIR = "/hostile/tls"; + process.env.OPENSHELL_TOKEN = "hostile-token"; + process.env.OPENSHELL_WORKSPACE = "hostile-workspace"; + try { + const result = runRestoreScenario({ + backupConfig: { plugins: { entries: {} } }, + backupExtensionDirs: [], + discoverFreshPluginInstalls: true, + freshConfig: { plugins: { entries: {} } }, + freshPluginInstalls: [], + previousPluginInstalls: [], + runtimeSelection: { + gatewayName: "nemoclaw-9090", + localTlsDir: "/authority/tls", + workspace: "default", + }, + }); + + expectSuccessfulRestore(result); + expect(result.openshellInvocations.length).toBeGreaterThan(0); + expect(result.sshInvocations.length).toBeGreaterThan(1); + const invocationEnvironments = [...result.openshellInvocations, ...result.sshInvocations].map( + ({ env }) => env, + ); + expect(invocationEnvironments).toEqual( + new Array(invocationEnvironments.length).fill({ + OPENSHELL_GATEWAY: "nemoclaw-9090", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + OPENSHELL_WORKSPACE: "default", + }), + ); + } finally { + restoreEnvBulk(previousEnv); + } + }); + it.each([ { provenance: "missing legacy", previousPluginInstalls: undefined }, { provenance: "known-empty", previousPluginInstalls: [] }, - ])("restores config and extensions with $provenance previous provenance", ({ - previousPluginInstalls, - }) => { - const weather = imageInstall("weather", "weather"); - const result = runRestoreScenario({ - previousPluginInstalls, - freshPluginInstalls: [weather], - backupExtensionDirs: ["weather"], - backupConfig: { - gateway: { auth: { token: "stale-token" } }, - mcpServers: { filesystem: { command: "npx" } }, - plugins: { entries: { "user-plugin": { enabled: true } } }, - }, - freshConfig: { - gateway: { auth: { token: "fresh-token" } }, - plugins: { - entries: { weather: { enabled: true, config: { revision: "fresh" } } }, - load: { paths: weather.loadPaths }, + ])( + "restores config and extensions with $provenance previous provenance", + ({ previousPluginInstalls }) => { + const weather = imageInstall("weather", "weather"); + const result = runRestoreScenario({ + previousPluginInstalls, + freshPluginInstalls: [weather], + backupExtensionDirs: ["weather"], + backupConfig: { + gateway: { auth: { token: "stale-token" } }, + mcpServers: { filesystem: { command: "npx" } }, + plugins: { entries: { "user-plugin": { enabled: true } } }, }, - }, - }); + freshConfig: { + gateway: { auth: { token: "fresh-token" } }, + plugins: { + entries: { weather: { enabled: true, config: { revision: "fresh" } } }, + load: { paths: weather.loadPaths }, + }, + }, + }); - expectSuccessfulRestore(result); - expect(result.freshMarkers).toEqual({ - nemoclaw: "fresh-nemoclaw\n", - weather: "fresh-weather\n", - }); - expect(result.restoredConfig.gateway.auth.token).toBe("fresh-token"); - expect(result.restoredConfig.mcpServers.filesystem.command).toBe("npx"); - expect(result.restoredConfig.plugins.entries).toEqual({ - "user-plugin": { enabled: true }, - weather: { enabled: true, config: { revision: "fresh" } }, - }); - expect(result.cleanupCommand).toContain("! -name 'nemoclaw'"); - expect(result.cleanupCommand).toContain("! -name 'weather'"); - }); + expectSuccessfulRestore(result); + expect(result.freshMarkers).toEqual({ + nemoclaw: "fresh-nemoclaw\n", + weather: "fresh-weather\n", + }); + expect(result.restoredConfig.gateway.auth.token).toBe("fresh-token"); + expect(result.restoredConfig.mcpServers.filesystem.command).toBe("npx"); + expect(result.restoredConfig.plugins.entries).toEqual({ + "user-plugin": { enabled: true }, + weather: { enabled: true, config: { revision: "fresh" } }, + }); + expect(result.cleanupCommand).toContain("! -name 'nemoclaw'"); + expect(result.cleanupCommand).toContain("! -name 'weather'"); + }, + ); it("uses fresh primary-model routing during an ordinary sandbox re-create (#7011)", () => { const result = runRestoreScenario({ diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 3af01a7e231..eeb860a36fb 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -39,7 +39,9 @@ import { isOpenShellSandboxPolicyCredentialFree, resolveOpenshellSandboxSshHost, } from "../adapters/openshell/client.js"; +import { buildSelectedOpenShellSubprocessEnv } from "../adapters/openshell/command-argv.js"; import { resolveOpenshell } from "../adapters/openshell/resolve.js"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection.js"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; @@ -271,6 +273,8 @@ export interface RecreatedSandboxRestoreOptions extends SnapshotRestoreOptions { allowCustomImageWholeStateFileRestore?: true; /** Pre-captured baseline avoids a second remote read during onboarding finalization. */ freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; + /** Exact OpenShell target frozen by the enclosing rebuild transaction. */ + runtimeSelection?: OpenShellRuntimeSelection; } interface InternalRestoreOptions { @@ -278,6 +282,7 @@ interface InternalRestoreOptions { allowCustomImageWholeStateFileRestore?: true; discoverFreshOpenClawImagePluginInstalls?: true; freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; + runtimeSelection?: OpenShellRuntimeSelection; authority?: SnapshotRestoreAuthority; validateBeforeMutation?: () => void; } @@ -716,6 +721,18 @@ export function getSshConfig( return result.output; } +function selectedSshConfigOptions( + runtimeSelection?: OpenShellRuntimeSelection, +): Parameters[1] { + return runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + gatewayName: runtimeSelection.gatewayName, + replaceEnv: true, + } + : undefined; +} + export function sshArgs(configFile: string, sandboxName: string): string[] { const sshHost = resolveOpenshellSandboxSshHost(sandboxName, readFileSync(configFile, "utf8")); if (sshHost === null) { @@ -2052,6 +2069,7 @@ export function restoreRecreatedSandboxState( ? { discoverFreshOpenClawImagePluginInstalls: true } : {}), freshOpenClawImagePluginInstalls: options.freshOpenClawImagePluginInstalls, + ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), ...(options.authority ? { authority: options.authority } : {}), ...(options.validateBeforeMutation ? { validateBeforeMutation: options.validateBeforeMutation } @@ -2065,6 +2083,9 @@ function restoreSandboxStateInternal( options: InternalRestoreOptions, ): RestoreResult { _log(`restoreSandboxState: sandbox=${sandboxName}, backupPath=${backupPath}`); + const selectedSshEnv = options.runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(options.runtimeSelection) + : undefined; const manifest = readManifest(backupPath); if (!manifest) { _log("FAILED: Could not read rebuild-manifest.json"); @@ -2238,7 +2259,12 @@ function restoreSandboxStateInternal( } else if (options.discoverFreshOpenClawImagePluginInstalls === true) { const discovery = discoverFreshOpenClawImagePluginInstalls( sandboxName, - { getSshConfig, sshArgs }, + { + ...(selectedSshEnv ? { env: selectedSshEnv } : {}), + getSshConfig: (name) => + getSshConfig(name, selectedSshConfigOptions(options.runtimeSelection)), + sshArgs, + }, targetAgent.configPaths.dir, ); if (!discovery.ok) { @@ -2261,7 +2287,10 @@ function restoreSandboxStateInternal( } _log("Getting SSH config for restore"); - const sshConfig = getSshConfig(sandboxName); + const sshConfig = getSshConfig( + sandboxName, + selectedSshConfigOptions(options.runtimeSelection), + ); if (!sshConfig) { _log("FAILED: Could not get SSH config for restore"); return { @@ -2367,6 +2396,7 @@ function restoreSandboxStateInternal( ); _log(`Cleaning target dirs before restore: ${rmCmd}`); const rmResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), rmCmd], { + ...(selectedSshEnv ? { env: selectedSshEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 30000, }); @@ -2390,6 +2420,7 @@ function restoreSandboxStateInternal( if (restoreTar !== undefined) { const extractCmd = `tar --no-same-owner -xf - -C ${shellQuote(dir)}`; const sshResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), extractCmd], { + ...(selectedSshEnv ? { env: selectedSshEnv } : {}), input: restoreTar, stdio: ["pipe", "pipe", "pipe"], timeout: 120000, @@ -2405,6 +2436,7 @@ function restoreSandboxStateInternal( const chownCmd = `chown -R sandbox:sandbox -- ${restoredPaths.map(shellQuote).join(" ")} 2>/dev/null || true`; _log(`Best-effort ownership repair: ${chownCmd}`); const chownResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), chownCmd], { + ...(selectedSshEnv ? { env: selectedSshEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 30000, }); @@ -2428,6 +2460,7 @@ function restoreSandboxStateInternal( "ssh", [...sshArgs(configFile, sandboxName), usabilityCmd], { + ...(selectedSshEnv ? { env: selectedSshEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 30000, }, @@ -2464,6 +2497,7 @@ function restoreSandboxStateInternal( _log, configFreshOpenClawImagePluginInstalls, previousOpenClawImagePluginInstalls, + selectedSshEnv, ) ) { restoredFiles.push(spec.path); diff --git a/src/lib/state/state-file-restore.ts b/src/lib/state/state-file-restore.ts index 5219ef8ab67..fa5c277d66e 100644 --- a/src/lib/state/state-file-restore.ts +++ b/src/lib/state/state-file-restore.ts @@ -147,6 +147,7 @@ export function restoreStateFile( log: (message: string) => void, freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], + env?: NodeJS.ProcessEnv, ): boolean { const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; @@ -161,6 +162,7 @@ export function restoreStateFile( const result = buildOpenClawConfigRestoreInputFromSandbox({ backupContents, dir, + env, freshImagePluginInstalls, log, previousImagePluginInstalls, @@ -185,6 +187,7 @@ export function restoreStateFile( if (input === null) return false; const result = spawnSync("ssh", [...sshArgs, command], { + ...(env ? { env } : {}), input, stdio: ["pipe", "pipe", "pipe"], timeout: 120000, diff --git a/src/lib/state/user-managed-files-probe.test.ts b/src/lib/state/user-managed-files-probe.test.ts index 721cd571b99..ac441de53fe 100644 --- a/src/lib/state/user-managed-files-probe.test.ts +++ b/src/lib/state/user-managed-files-probe.test.ts @@ -13,12 +13,16 @@ type SandboxStateModule = typeof import("./sandbox"); type RegistryModule = typeof import("./registry"); type DefsModule = typeof import("../agent/defs"); type ProbeModule = typeof import("./user-managed-files-probe"); +type OpenShellClientModule = typeof import("../adapters/openshell/client"); +type OpenShellResolveModule = typeof import("../adapters/openshell/resolve"); const requireDist = createRequire(import.meta.url); const sandboxStatePath = "./sandbox.js"; const registryPath = "./registry.js"; const defsPath = "../agent/defs.js"; const probePath = "./user-managed-files-probe.js"; +const openshellClientPath = "../adapters/openshell/client.js"; +const openshellResolvePath = "../adapters/openshell/resolve.js"; function loadProbe(): ProbeModule { delete require.cache[requireDist.resolve(probePath)]; @@ -37,6 +41,14 @@ function loadDefs(): DefsModule { return requireDist(defsPath); } +function loadOpenShellClient(): OpenShellClientModule { + return requireDist(openshellClientPath); +} + +function loadOpenShellResolve(): OpenShellResolveModule { + return requireDist(openshellResolvePath); +} + function makeFakeAgent(declared: string[]): ReturnType { return { name: "fake-agent", @@ -70,12 +82,14 @@ function makeFakeAgent(declared: string[]): ReturnType describe("probeUserManagedFiles", () => { let recordedArgs: string[][]; + let recordedOptions: Array>; let spawnSpy: ReturnType; let tempSshFiles: Set; let originalMkdtempSync: typeof fs.mkdtempSync; beforeEach(() => { recordedArgs = []; + recordedOptions = []; tempSshFiles = new Set(); const sandboxState = loadSandboxState(); const registry = loadRegistry(); @@ -99,6 +113,7 @@ describe("probeUserManagedFiles", () => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); for (const dir of tempSshFiles) { try { @@ -113,10 +128,12 @@ describe("probeUserManagedFiles", () => { spawnSpy = vi.spyOn(child_process, "spawnSync").mockImplementation((( command: string, args?: readonly string[], + options?: Record, ) => { const argList = Array.isArray(args) ? [...args] : []; const sshCall = command === "ssh" && argList.length > 0; sshCall && recordedArgs.push(argList); + sshCall && recordedOptions.push(options ?? {}); return { status, signal: null, @@ -145,6 +162,44 @@ describe("probeUserManagedFiles", () => { expect(probeCmd).not.toContain("/sandbox/.fake/"); }); + it("pins SSH config and ProxyCommand environment to the frozen target (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const sandboxState = loadSandboxState(); + const getSshConfig = vi.mocked(sandboxState.getSshConfig); + stubSpawnSync(".env\n", 0); + const { probeUserManagedFiles } = loadProbe(); + + probeUserManagedFiles("alpha", runtimeSelection); + + expect(getSshConfig).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + gatewayName: "recorded-gateway", + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + const env = recordedOptions[0]?.env as Record; + expect(env).toMatchObject({ + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }); + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + }); + it("supports nested declared files relative to the sandbox root", () => { const defs = loadDefs(); vi.spyOn(defs, "loadAgent").mockImplementation(() => makeFakeAgent([".hermes/.env"])); @@ -255,3 +310,50 @@ describe("probeUserManagedFiles", () => { expect(result.existing).toEqual([]); }); }); + +describe("getSshConfig frozen target", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("uses replacement environment, workspace, TLS, and gateway for SSH config (#10514)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); + vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); + vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://hostile.invalid"); + vi.spyOn(loadOpenShellResolve(), "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); + const capture = vi + .spyOn(loadOpenShellClient(), "captureSandboxSshConfigCommand") + .mockReturnValue({ status: 0, output: "Host openshell-alpha.default\n" }); + + const runtimeOptions = { + gatewayName: "recorded-gateway", + replaceEnv: true, + env: { + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }, + }; + expect(loadSandboxState().getSshConfig("alpha", runtimeOptions)).toContain( + "openshell-alpha.default", + ); + + expect(capture).toHaveBeenCalledWith( + "/usr/bin/openshell", + "alpha", + expect.objectContaining({ + gatewayName: "recorded-gateway", + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "recorded-gateway", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), + ); + const env = capture.mock.calls[0]?.[2]?.env as Record; + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + }); +}); diff --git a/src/lib/state/user-managed-files-probe.ts b/src/lib/state/user-managed-files-probe.ts index f89ab8658e3..4f61f56a3cc 100644 --- a/src/lib/state/user-managed-files-probe.ts +++ b/src/lib/state/user-managed-files-probe.ts @@ -4,6 +4,8 @@ import { spawnSync } from "child_process"; import { loadAgent } from "../agent/defs.js"; +import { buildSelectedOpenShellSubprocessEnv } from "../adapters/openshell/command-argv.js"; +import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection.js"; import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; @@ -23,7 +25,10 @@ function _log(msg: string): void { if (_verbose()) console.error(` [user-managed-files-probe ${new Date().toISOString()}] ${msg}`); } -export function probeUserManagedFiles(sandboxName: string): UserManagedFilesProbe { +export function probeUserManagedFiles( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): UserManagedFilesProbe { const sb = registry.getSandbox(sandboxName); const agentName = sb?.agent || "openclaw"; const agent = loadAgent(agentName); @@ -34,7 +39,19 @@ export function probeUserManagedFiles(sandboxName: string): UserManagedFilesProb `sandbox=${sandboxName}, agent=${agentName}, declared=[${declared.join(",")}], base=${USER_MANAGED_FILES_BASE}`, ); - const sshConfig = getSshConfig(sandboxName); + const selectedEnv = runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(runtimeSelection) + : undefined; + const sshConfig = getSshConfig( + sandboxName, + runtimeSelection + ? { + env: selectedEnv, + gatewayName: runtimeSelection.gatewayName, + replaceEnv: true, + } + : undefined, + ); if (!sshConfig) { _log("no SSH config — cannot probe declared user-managed files"); throw new Error( @@ -54,6 +71,7 @@ export function probeUserManagedFiles(sandboxName: string): UserManagedFilesProb .join("; ") + " 2>/dev/null"; const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), probeCmd], { encoding: "utf-8", + ...(selectedEnv ? { env: selectedEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 30000, }); diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 9879e3b1fc8..c180db8ca0b 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -41,6 +41,8 @@ export const gatewayTeardownAuthority = requireDist( ) as typeof import("../../src/lib/onboard/gateway-teardown-authority"); export const hermesProviderAuth = requireDist("../../hermes-provider-auth.js"); export const mcpBridge = requireDist("./mcp-bridge.js"); +export const mcpBridgeProvider = requireDist("./mcp-bridge-provider.js"); +export const mcpBridgeProviderInspection = requireDist("./mcp-bridge-provider-inspection.js"); export const messaging = requireDist("../../messaging/index.js"); export const messagingHostForwardLifecycle = requireDist("./messaging-host-forward-lifecycle.js"); export const mutableConfigPerms = requireDist("../../sandbox/mutable-config-perms.js"); diff --git a/test/mcp/mcp-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index 4984227e5d4..a6a78f9e652 100644 --- a/test/mcp/mcp-destroy-lifecycle.test.ts +++ b/test/mcp/mcp-destroy-lifecycle.test.ts @@ -49,6 +49,7 @@ const testState = vi.hoisted(() => { removePreset: vi.fn(), runOpenshell: vi.fn(), runOpenshellProviderCommand: vi.fn(), + runtimeSelection: { gatewayName: "nemoclaw", workspace: "default" }, stopNimContainer: vi.fn(), stopNimContainerByName: vi.fn(), warnUnpreservedUserManagedFiles: vi.fn(), @@ -981,8 +982,11 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(testState.executeSandboxExecCommand).toHaveBeenCalledOnce(); expect(testState.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection: testState.runtimeSelection, + }); + expect(testState.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection: testState.runtimeSelection, }); - expect(testState.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":"); expect(testState.runOpenshell).toHaveBeenCalledWith( ["sandbox", "delete", "-g", "nemoclaw", "alpha"], expect.any(Object),