From b150040a844df69c01e6983b0f82e770844f8d4c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 30 Aug 2026 15:45:06 -0700 Subject: [PATCH 01/13] fix(mcp): pin provider inspection target Signed-off-by: Apurv Kumaria --- .../mcp-bridge-provider-inspection.test.ts | 59 +++++++++++++ .../sandbox/mcp-bridge-provider-inspection.ts | 21 ++++- .../mcp-bridge-status-boundaries.test.ts | 83 ++++++++++++++++++- src/lib/actions/sandbox/mcp-bridge-status.ts | 10 ++- .../openshell/provider-command.test.ts | 26 ++++++ .../adapters/openshell/provider-command.ts | 19 ++++- test/mcp/mcp-provider-ownership.test.ts | 6 +- 7 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts 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..f721827c284 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from "vitest"; + +import { setProviderCommandRuntimeHooksForTest } from "../../adapters/openshell/provider-command"; +import { inspectMcpProvider } from "./mcp-bridge-provider-inspection"; + +afterEach(() => setProviderCommandRuntimeHooksForTest({})); + +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")).toMatchObject({ exists: false }); + }); + + 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..86f9f6ad56e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -3,6 +3,7 @@ import { stripAnsi } from "../../adapters/openshell/client"; import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { reportsExactProviderNotFound } from "../../onboard/extra-provider-diagnostic-parser"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; import { listExtraProviders, type McpBridgeEntry } from "../../state/registry"; import { McpBridgeError } from "./mcp-bridge-contracts"; @@ -36,6 +37,11 @@ export type McpProviderAttachmentInspection = { error?: string; }; +export type McpProviderInspectionRuntimeSelection = { + gatewayName: string; + workspace: string; +}; + const MCP_PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; export function parseMcpProviderMetadata(output: string): Omit { @@ -65,7 +71,10 @@ export function parseMcpProviderMetadata(output: string): Omit { - const provider = inspectMcpProvider(name); + const provider = inspectMcpProvider(name, runtimeSelection); if ( provider.exists !== true || !provider.id || @@ -406,9 +418,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-status-boundaries.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts index 1757a5dc0f4..34d31c74069 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,86 @@ afterEach(() => { tempHomes.clear(); }); -describe("cross-agent MCP status boundaries", () => { +describe("cross-agent MCP status boundaries", testTimeoutOptions(15_000), () => { + it("pins provider diagnostics to the recorded gateway and workspace (#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_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"], + 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(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 environments = JSON.parse(result.stdout) as Array>; + expect(environments.length).toBeGreaterThan(0); + expect( + environments.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` diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index a4580968b40..5a6a01d4348 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { type AgentDefinition, type AgentMcpAdapter, loadAgent } from "../../agent/defs"; +import { OPENSHELL_DEFAULT_WORKSPACE } from "../../adapters/openshell/sandbox-ssh-host"; import type { McpBridgeEntry } from "../../state/registry"; +import { getPersistedSandboxTargetGatewayName } from "./gateway-target"; import { buildDeepAgentsMcpStatusCommand, buildHermesMcpStatusCommand, @@ -183,6 +185,10 @@ export async function statusMcpBridge( validateSandboxName(sandboxName); if (server !== undefined) validateMcpServerName(server); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = { + gatewayName: getPersistedSandboxTargetGatewayName(sandbox), + workspace: OPENSHELL_DEFAULT_WORKSPACE, + }; const agent = getSandboxAgent(sandbox); const bridges = bridgeState(sandbox); if (Object.keys(bridges).length > 0) { @@ -299,7 +305,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 +316,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) { diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 871e2d70eeb..872224df44a 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -66,4 +66,30 @@ describe("OpenShell provider command runtime", () => { ); expect(result).toEqual({ status: 0 }); }); + + it("pins provider inspection 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_WORKSPACE: "ambient-workspace", + PATH: "/usr/bin", + }); + + runOpenshellProviderCommand(["provider", "get", "alpha-mcp-fake"], { + runtimeSelection: { gatewayName: "recorded-gateway", workspace: "default" }, + }); + + expect(mocks.runOpenshell).toHaveBeenCalledWith( + ["provider", "get", "alpha-mcp-fake"], + 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..56fd6430ec4 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -11,6 +11,10 @@ export { OPENSHELL_OPERATION_TIMEOUT_MS }; export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; + runtimeSelection?: { + gatewayName: string; + workspace: string; + }; stdio?: StdioOptions; timeout?: number; }; @@ -26,14 +30,23 @@ 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 = buildSubprocessEnv(explicitEnv); + if (runtimeSelection) { + for (const name of Object.keys(env)) { + if (name.startsWith("OPENSHELL_")) delete env[name]; + } + env.OPENSHELL_GATEWAY = runtimeSelection.gatewayName; + env.OPENSHELL_WORKSPACE = runtimeSelection.workspace; + } const providerOpts = { - ...opts, - env: buildSubprocessEnv(explicitEnv), + ...runtimeOptions, + env, replaceEnv: true, }; const commandRunner = runtimeHooks.runOpenshell ?? runOpenshell; diff --git a/test/mcp/mcp-provider-ownership.test.ts b/test/mcp/mcp-provider-ownership.test.ts index 02c0acda9e5..6f7f2c39efd 100644 --- a/test/mcp/mcp-provider-ownership.test.ts +++ b/test/mcp/mcp-provider-ownership.test.ts @@ -139,7 +139,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 { @@ -343,7 +343,7 @@ const attached = new Set(["alpha-mcp-fake", "alpha-mcp-second"]); 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 @@ -497,7 +497,7 @@ const calls = []; 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(" ")); }; From ab24e8dae4ec95304433f8c2c36d4baf83e49036 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 30 Aug 2026 16:29:54 -0700 Subject: [PATCH 02/13] fix(mcp): pin collision inspection target Signed-off-by: Apurv Kumaria --- .../actions/sandbox/mcp-bridge-add-restart.ts | 8 +- .../sandbox/mcp-bridge-provider-inspection.ts | 26 ++++- .../sandbox/mcp-bridge-provider.test.ts | 98 ++++++++++++++++++- .../actions/sandbox/mcp-bridge-provider.ts | 1 + .../mcp-bridge-rebuild-exec-unavailable.ts | 7 +- src/lib/actions/sandbox/mcp-bridge-rebuild.ts | 11 ++- src/lib/actions/sandbox/mcp-bridge-restart.ts | 13 ++- src/lib/actions/sandbox/mcp-bridge-status.ts | 8 +- 8 files changed, 147 insertions(+), 25 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index a07134f04ff..f98131476b2 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -39,6 +39,7 @@ import { detachMissingProviderReference, detachProvider, ensureMcpBridgeProviderProfile, + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, type McpCredentialRevisionObservation, observeMcpCredentialRevision, @@ -185,6 +186,7 @@ async function addMcpBridgeUnlocked( } const matchingTrustedPrivateHosts = allTrustedPrivateHosts.filter((host) => host === urlHost); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); assertMcpDestroyNotPending(sandbox); const agent = getSandboxAgent(sandbox); const adapter = getBridgeAdapter(agent); @@ -311,7 +313,7 @@ async function addMcpBridgeUnlocked( // 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); }); } @@ -381,7 +383,7 @@ 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]); + assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); ensureMcpBridgeProviderProfile(); // Load the real protocol:mcp policy without a credential binding before // provider mutation. OpenShell requires the endpointless provider to be @@ -418,7 +420,7 @@ 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}'.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 86f9f6ad56e..7904a1fac04 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -3,9 +3,11 @@ import { stripAnsi } from "../../adapters/openshell/client"; import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { OPENSHELL_DEFAULT_WORKSPACE } from "../../adapters/openshell/sandbox-ssh-host"; import { reportsExactProviderNotFound } from "../../onboard/extra-provider-diagnostic-parser"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; -import { listExtraProviders, type McpBridgeEntry } from "../../state/registry"; +import { listExtraProviders, type McpBridgeEntry, type SandboxEntry } from "../../state/registry"; +import { getPersistedSandboxTargetGatewayName } 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"; @@ -42,6 +44,15 @@ export type McpProviderInspectionRuntimeSelection = { workspace: string; }; +export function getMcpProviderInspectionRuntimeSelection( + sandbox: SandboxEntry, +): McpProviderInspectionRuntimeSelection { + return { + gatewayName: getPersistedSandboxTargetGatewayName(sandbox), + 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 { @@ -181,10 +192,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}'.`, @@ -210,12 +222,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; @@ -242,9 +257,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( diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts index 0eaaff30d83..6ca56a83b79 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -223,9 +223,12 @@ 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], { + gatewayName: "nemoclaw-8080", + workspace: "default", + }), + ).toThrow("MCP server 'example' has no complete authenticated credential binding"); expect(() => assertNoRegisteredProviderCredentialCollisions([entry], { listExtraProviders: () => ["foreign-registered"], @@ -262,6 +265,95 @@ 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 === runtimeSelection), + ).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" }, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.ts b/src/lib/actions/sandbox/mcp-bridge-provider.ts index 42b991c6b10..b98e95e01bd 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.ts @@ -11,6 +11,7 @@ export { 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..059833e0636 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,7 @@ import { } from "./mcp-bridge-destroy-preflight"; import { assertNoProviderCredentialCollisions, + getMcpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, } from "./mcp-bridge-provider"; import { @@ -142,7 +143,11 @@ async function inspectReadOnlyRecoveryState( providerByServer.set(entry.server, providerFingerprint(provider)); targetsByServer.set(entry.server, targetFingerprint(target)); } - assertNoProviderCredentialCollisions(sandboxName, entries); + assertNoProviderCredentialCollisions( + sandboxName, + entries, + getMcpProviderInspectionRuntimeSelection(getSandboxOrThrow(sandboxName)), + ); return { providerByServer, targetsByServer }; } diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index b9aaefb080f..3a5cc74ce3d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -29,6 +29,7 @@ import { assertNoProviderCredentialCollisions, assertNoRegisteredProviderCredentialCollisions, detachProvider, + getMcpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; @@ -145,11 +146,16 @@ export async function prepareMcpBridgesForAbsentSandboxRebuild( } await preflightMcpEntryTargets(entries); await ensureSandboxGatewaySelected(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection( + getSandboxOrThrow(sandboxName), + ); for (const entry of entries) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } for (const entry of entries) assertMcpProviderRecoverable(entry); - assertNoRegisteredProviderCredentialCollisions(entries); + assertNoRegisteredProviderCredentialCollisions(entries, { + runtimeSelection: providerRuntimeSelection, + }); return { entries, detachedProviderEntries: [], @@ -171,10 +177,11 @@ export async function prepareMcpBridgesForRebuild( } await preflightMcpEntryTargets(entries); await ensureSandboxGatewaySelected(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); for (const entry of entries) assertGeneratedPolicyMutationSafe(sandboxName, entry); assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); for (const entry of entries) assertMcpProviderRecoverable(entry); - assertNoProviderCredentialCollisions(sandboxName, entries); + 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 diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index 379812df5ec..ebb64e5bd17 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -16,6 +16,7 @@ import { attachProvider, detachMissingProviderReference, ensureMcpBridgeProviderProfile, + getMcpProviderInspectionRuntimeSelection, refreshMcpProviderEnvironment, type McpCredentialRevisionObservation, type McpProviderInspection, @@ -71,6 +72,7 @@ export async function restartMcpBridge(sandboxName: string, server?: string): Pr async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): Promise { validateSandboxName(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); assertMcpDestroyNotPending(sandbox); const agent = getSandboxAgent(sandbox); const adapter = getBridgeAdapter(agent); @@ -124,7 +126,7 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P } // 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,7 +135,7 @@ 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. @@ -162,7 +164,7 @@ 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}'.`, @@ -211,6 +213,7 @@ export async function restoreExistingMcpBridgeRuntime( } await ensureSandboxGatewaySelected(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); assertMcpDestroyNotPending(sandbox); if (options.lifecyclePhase === "teardown-rollback") { // A failed delete/rebuild must be able to restore a backward-compatible @@ -235,9 +238,9 @@ 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]); + assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); ensureMcpBridgeProviderProfile(); if (options.applyPolicy !== false) { applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index 5a6a01d4348..0463ecca9b5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { type AgentDefinition, type AgentMcpAdapter, loadAgent } from "../../agent/defs"; -import { OPENSHELL_DEFAULT_WORKSPACE } from "../../adapters/openshell/sandbox-ssh-host"; import type { McpBridgeEntry } from "../../state/registry"; -import { getPersistedSandboxTargetGatewayName } from "./gateway-target"; import { buildDeepAgentsMcpStatusCommand, buildHermesMcpStatusCommand, @@ -20,6 +18,7 @@ import { import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; import { getPolicyPresence, getRegisteredGeneratedPolicy } from "./mcp-bridge-policy"; import { + getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, observeMcpCredentialRevision, providerAttached, @@ -185,10 +184,7 @@ export async function statusMcpBridge( validateSandboxName(sandboxName); if (server !== undefined) validateMcpServerName(server); const sandbox = getSandboxOrThrow(sandboxName); - const providerRuntimeSelection = { - gatewayName: getPersistedSandboxTargetGatewayName(sandbox), - workspace: OPENSHELL_DEFAULT_WORKSPACE, - }; + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); const agent = getSandboxAgent(sandbox); const bridges = bridgeState(sandbox); if (Object.keys(bridges).length > 0) { From 66c37e49701a3b05dc8f9eec6c79f547bf1e5ca9 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 30 Aug 2026 23:16:28 -0700 Subject: [PATCH 03/13] fix(mcp): pin provider lifecycle target Signed-off-by: Apurv Kumaria --- .../mcp-bridge-adapter-teardown.test.ts | 4 + .../actions/sandbox/mcp-bridge-add-restart.ts | 43 +++-- .../sandbox/mcp-bridge-destroy-preflight.ts | 20 +- src/lib/actions/sandbox/mcp-bridge-destroy.ts | 30 ++- .../mcp-bridge-hermes-reconciliation.test.ts | 7 +- .../mcp-bridge-hermes-reconciliation.ts | 3 + .../mcp-bridge-provider-attachments.ts | 38 ++-- .../sandbox/mcp-bridge-provider-inspection.ts | 7 +- .../sandbox/mcp-bridge-provider-mutation.ts | 58 ++++-- .../mcp-bridge-provider-profile.test.ts | 20 +- .../sandbox/mcp-bridge-provider.test.ts | 180 ++++++++++++++++-- .../actions/sandbox/mcp-bridge-provider.ts | 1 + .../mcp-bridge-rebuild-exec-unavailable.ts | 14 +- src/lib/actions/sandbox/mcp-bridge-rebuild.ts | 17 +- src/lib/actions/sandbox/mcp-bridge-remove.ts | 31 ++- src/lib/actions/sandbox/mcp-bridge-restart.ts | 22 ++- .../openshell/provider-command.test.ts | 2 +- test/mcp/mcp-provider-detach-retry.test.ts | 8 +- test/mcp/mcp-provider-ownership.test.ts | 15 +- 19 files changed, 405 insertions(+), 115 deletions(-) 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..98571995479 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts @@ -42,6 +42,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(), diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index f98131476b2..7215d30d491 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -97,6 +97,7 @@ function assertPreparedMcpAddResourcesAbsent( adapter: AgentMcpAdapter, entry: McpBridgeEntry, target: McpBridgeTargetValidation, + providerRuntimeSelection: ReturnType, ): void { const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry); if (adapterInspection.state !== "absent") { @@ -109,7 +110,7 @@ function assertPreparedMcpAddResourcesAbsent( ); } - const providerInspection = inspectMcpProvider(entry.providerName); + const providerInspection = inspectMcpProvider(entry.providerName, providerRuntimeSelection); if (providerInspection.exists !== false) { const detail = providerInspection.exists === null @@ -325,7 +326,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 ?? @@ -339,7 +340,7 @@ 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; } } @@ -352,7 +353,7 @@ async function addMcpBridgeUnlocked( // 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 }); throw error; @@ -360,7 +361,13 @@ async function addMcpBridgeUnlocked( } 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 @@ -384,7 +391,7 @@ async function addMcpBridgeUnlocked( // supplied by a foreign attachment before opening its MCP route, then check // again after provider creation to close the intervening race. assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); - ensureMcpBridgeProviderProfile(); + 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 @@ -397,6 +404,7 @@ 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 @@ -427,7 +435,7 @@ async function addMcpBridgeUnlocked( ); } providerAttachAttempted = true; - attachProvider(sandboxName, entry); + attachProvider(sandboxName, entry, providerRuntimeSelection); applyGeneratedPolicy(sandboxName, entry, target); let refreshedAfterObservedAbsence = false; let credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { @@ -457,8 +465,11 @@ 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) { @@ -471,6 +482,7 @@ async function addMcpBridgeUnlocked( allowExisting: true, expectedProviderId: entry.providerId, requireExisting: true, + runtimeSelection: providerRuntimeSelection, }); credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { previousRevision: credentialRevision, @@ -494,7 +506,7 @@ async function addMcpBridgeUnlocked( } catch (error) { const rollbackProviderInspection = (providerAttachAttempted || providerCreated) && entry.providerId - ? inspectMcpProvider(providerName) + ? inspectMcpProvider(providerName, providerRuntimeSelection) : undefined; const rollbackProviderOwned = !!rollbackProviderInspection && @@ -510,7 +522,10 @@ async function addMcpBridgeUnlocked( removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); } const detachOutcome = providerAttachAttempted - ? detachProvider(sandboxName, entry, { bestEffort: true }) + ? detachProvider(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }) : "absent"; let reservationCleanupProved = !providerAttachAttempted; if (providerAttachAttempted && detachOutcome !== "unknown") { @@ -522,9 +537,13 @@ async function addMcpBridgeUnlocked( } } 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. diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts index 01c0da3d863..14a5bd1010e 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, @@ -71,13 +73,14 @@ export async function discardSafeIncompleteMcpAdds( (entry) => entry.addState === "preflighted" && !entry.providerId, ); if (providerlessCandidates.length > 0) await ensureSandboxGatewaySelected(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); 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; @@ -122,7 +125,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) { @@ -130,7 +137,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}'.`, @@ -169,8 +176,13 @@ export async function prepareMcpBridgesForAbsentSandboxDestroy( const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; + const 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, diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index 56e249d5dc6..8163b86e473 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"; @@ -70,6 +71,7 @@ export async function prepareMcpBridgesForDestroy( ); const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; const incompleteAdd = entries.find((entry) => entry.addState === "preflighted"); @@ -94,6 +96,7 @@ export async function prepareMcpBridgesForDestroy( for (const entry of entries) { inspectExactMcpDestroyProvider(entry, { allowMissing: destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }); } if (destroyAlreadyPending) { @@ -132,8 +135,14 @@ export async function prepareMcpBridgesForDestroy( 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}'.`, @@ -224,6 +233,7 @@ export async function restoreMcpBridgesAfterDestroyAbort( return; } const preparedSandbox = assertMcpDestroySnapshotCurrent(sandboxName, preparation.entries); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(preparedSandbox); const destroyPreparedAt = preparedSandbox.mcp?.destroyPreparedAt ?? nowIso(); const cleared = registry.updateSandbox(sandboxName, { mcp: { @@ -244,7 +254,10 @@ 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", }); @@ -292,6 +305,7 @@ export async function finalizeMcpBridgesAfterSandboxDelete( await ensureSandboxGatewaySelected(sandboxName); const sandbox = assertMcpDestroySnapshotCurrent(sandboxName, entries); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); if (!sandbox.mcp?.destroyPendingAt) { const marked = registry.updateSandbox(sandboxName, { mcp: { @@ -319,6 +333,7 @@ export async function finalizeMcpBridgesAfterSandboxDelete( inspectExactMcpDestroyProvider(entry, { allowMissing: true, force: options.force, + runtimeSelection: providerRuntimeSelection, }), ); for (const [index, entry] of entries.entries()) { @@ -326,10 +341,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..c9dc10d4c57 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts @@ -11,6 +11,7 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import { getMcpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { sleepMcpBridgeRetry } from "./mcp-bridge/timing"; @@ -161,10 +162,12 @@ export function inspectHermesMcpRuntimeIntent( managedServerNames, options.credentialRevisions, ); + const 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-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.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 7904a1fac04..1f5b99eac42 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -334,7 +334,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( @@ -342,7 +345,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}'.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts index 575223e5943..43c47b5629c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts @@ -28,6 +28,7 @@ import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output" import { inspectMcpProvider, MCP_BRIDGE_PROVIDER_TYPE, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, providerMatchesCredential, providerMatchesManagedCredential, @@ -67,24 +68,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 +141,7 @@ export function upsertMcpProvider( expectedProviderId?: string; requireExisting?: boolean; prepareMutation?: (action: "create" | "update") => void; + runtimeSelection: McpProviderInspectionRuntimeSelection; }, ): { action: "created" | "updated" | "reused" | "none"; @@ -149,7 +161,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 +214,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 +237,7 @@ export function upsertMcpProvider( { ignoreError: true, env: envValues, + runtimeSelection: options.runtimeSelection, stdio: ["ignore", "pipe", "pipe"], }, ) as OpenShellCommandResult; @@ -236,7 +249,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 +276,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 +294,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 +303,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 +318,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 +333,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,13 +358,19 @@ 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; @@ -352,7 +380,7 @@ export function deleteProvider( 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.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts index 6ca56a83b79..e23b247e926 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -20,9 +20,16 @@ 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"; @@ -30,6 +37,7 @@ import * as processRecovery from "./process-recovery"; describe("OpenShell MCP provider state", () => { afterEach(() => { + providerCommand.setProviderCommandRuntimeHooksForTest({}); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); @@ -93,11 +101,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,9 +139,12 @@ Provider: addedAt: "2026-08-19T00:00:00.000Z", }; - expect(() => assertMcpProviderRecoverable(entry)).toThrow( - /legacy generic profile.*cannot bind to an MCP endpoint/, - ); + expect(() => + assertMcpProviderRecoverable(entry, { + gatewayName: "nemoclaw-8080", + workspace: "default", + }), + ).toThrow(/legacy generic profile.*cannot bind to an MCP endpoint/); }); it("republishes an exact provider only after policy binding without reading its credential", () => { @@ -168,22 +175,156 @@ Provider: .mockReturnValueOnce(providerResult(8)); expect( - 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", - }), + 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("distinguishes a real detach from OpenShell's idempotent success", () => { expect( providerDetachChangedState(0, "✓ Detached provider alpha-mcp-github from sandbox alpha"), @@ -776,5 +917,4 @@ alpha-mcp-slack generic 1 0 ); 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 b98e95e01bd..609faf20675 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.ts @@ -5,6 +5,7 @@ export type { McpProviderAttachment, McpProviderAttachmentInspection, McpProviderInspection, + McpProviderInspectionRuntimeSelection, } from "./mcp-bridge-provider-inspection"; export { assertMcpProviderRecoverable, 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 059833e0636..887ef25869f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -134,20 +134,22 @@ async function inspectReadOnlyRecoveryState( // 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); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection( + getSandboxOrThrow(sandboxName), + ); 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, - getMcpProviderInspectionRuntimeSelection(getSandboxOrThrow(sandboxName)), - ); + assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); return { providerByServer, targetsByServer }; } diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index 3a5cc74ce3d..eba80c3c7cc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -152,7 +152,9 @@ export async function prepareMcpBridgesForAbsentSandboxRebuild( for (const entry of entries) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } - for (const entry of entries) assertMcpProviderRecoverable(entry); + for (const entry of entries) { + assertMcpProviderRecoverable(entry, providerRuntimeSelection); + } assertNoRegisteredProviderCredentialCollisions(entries, { runtimeSelection: providerRuntimeSelection, }); @@ -180,7 +182,9 @@ export async function prepareMcpBridgesForRebuild( const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); for (const entry of entries) assertGeneratedPolicyMutationSafe(sandboxName, entry); assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); - for (const entry of entries) assertMcpProviderRecoverable(entry); + 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 @@ -216,8 +220,13 @@ export async function prepareMcpBridgesForRebuild( 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}'.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index cbab3068043..cebe0bae1f8 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}'.`, @@ -152,6 +158,7 @@ async function removeMcpBridgeUnlocked( validateSandboxName(sandboxName); validateMcpServerName(server); const sandbox = getSandboxOrThrow(sandboxName); + const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); // #6376: `--force` on `mcp remove` is the documented non-destructive recovery // for a stuck MCP destroy transaction. It is PHASE-AWARE: only the prepared // (phase-one) marker — in-sandbox scrub + provider detach done, deletion not @@ -216,7 +223,7 @@ async function removeMcpBridgeUnlocked( 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( @@ -368,11 +378,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 @@ -409,10 +422,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-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index ebb64e5bd17..e2df9ae4d8c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -107,7 +107,10 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P 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, @@ -118,7 +121,7 @@ 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); for (const entry of missingProviderEntries) { @@ -139,11 +142,12 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P // Revalidate the actual running supervisor before rotating or recreating // credentials. The temporary policy cannot bind the provider until an // endpointless profile is attached. - ensureMcpBridgeProviderProfile(); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false }); const providerResult = upsertMcpProvider(entry.providerName ?? "", envRefs, { allowExisting: true, expectedProviderId: entry.providerId, + runtimeSelection: providerRuntimeSelection, prepareMutation: (action) => { if (action === "update") { previousCredentialRevision = observeMcpCredentialRevision(sandboxName, entry); @@ -170,9 +174,9 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P `Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`, ); } - attachProvider(sandboxName, entry); + attachProvider(sandboxName, entry, providerRuntimeSelection); applyGeneratedPolicy(sandboxName, entry, target); - refreshMcpProviderEnvironment(entry); + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); const entryAdapter = (entry.adapter as AgentMcpAdapter | undefined) ?? adapter; const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { ...(providerResult.action === "updated" @@ -227,7 +231,7 @@ export async function restoreExistingMcpBridgeRuntime( 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]}'.`, @@ -241,18 +245,18 @@ export async function restoreExistingMcpBridgeRuntime( assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); for (const entry of entries) { assertNoAttachedProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection); - ensureMcpBridgeProviderProfile(); + ensureMcpBridgeProviderProfile(providerRuntimeSelection); if (options.applyPolicy !== false) { applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { bindCredential: false, }); } - attachProvider(sandboxName, entry); + attachProvider(sandboxName, entry, providerRuntimeSelection); if (options.applyPolicy !== false) { applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry)); } const adapter = (entry.adapter as AgentMcpAdapter | undefined) ?? defaultAdapter; - refreshMcpProviderEnvironment(entry); + refreshMcpProviderEnvironment(entry, providerRuntimeSelection); const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 872224df44a..0a3cdcbf5c1 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -67,7 +67,7 @@ describe("OpenShell provider command runtime", () => { expect(result).toEqual({ status: 0 }); }); - it("pins provider inspection to the recorded gateway and workspace (#10514)", () => { + 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", diff --git a/test/mcp/mcp-provider-detach-retry.test.ts b/test/mcp/mcp-provider-detach-retry.test.ts index c96df17b054..6df4f02754c 100644 --- a/test/mcp/mcp-provider-detach-retry.test.ts +++ b/test/mcp/mcp-provider-detach-retry.test.ts @@ -16,7 +16,11 @@ 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 !== runtimeSelection) { + 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 +66,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 6f7f2c39efd..c4ef59c76b1 100644 --- a/test/mcp/mcp-provider-ownership.test.ts +++ b/test/mcp/mcp-provider-ownership.test.ts @@ -340,6 +340,7 @@ 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") { @@ -372,17 +373,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 +430,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 +459,7 @@ try { { allowExisting: true, expectedProviderId: "11111111-2222-4333-8444-555555555555", + runtimeSelection, }, ); } catch (error) { @@ -494,6 +497,7 @@ 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") { @@ -511,6 +515,7 @@ try { allowExisting: true, expectedProviderId: "11111111-2222-4333-8444-555555555555", requireExisting: true, + runtimeSelection, }, ); } catch (error) { From 52e089053f7c5ea51f13370d5c4be101550489a5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 03:24:32 -0700 Subject: [PATCH 04/13] fix(mcp): pin provider lifecycle commands Signed-off-by: Apurv Kumaria --- ...mcp-bridge-adapter-hermes-branding.test.ts | 51 +++++++++- .../sandbox/mcp-bridge-adapter-hermes.ts | 8 ++ .../mcp-bridge-adapter-registration.test.ts | 4 + .../mcp-bridge-adapter-teardown.test.ts | 32 +++++- src/lib/actions/sandbox/mcp-bridge-destroy.ts | 3 +- .../sandbox/mcp-bridge-provider-mutation.ts | 9 +- .../sandbox/mcp-bridge-provider.test.ts | 97 ++++++++++++++++++- test/mcp/mcp-add-crash-consistency.test.ts | 6 +- ...mcp-bridge-destroy-marker-recovery.test.ts | 2 +- test/mcp/mcp-provider-detach-retry.test.ts | 5 +- test/mcp/mcp-restart-policy-order.test.ts | 4 +- 11 files changed, 210 insertions(+), 11 deletions(-) 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..2ad79a384ad 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,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { McpBridgeEntry } from "../../state/registry"; + const mocks = vi.hoisted(() => ({ + getSandboxOrThrow: vi.fn(), isShieldsDown: vi.fn(), runOpenshellProviderCommand: vi.fn(), })); @@ -16,11 +19,35 @@ 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(), +}; 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({ status: 1, @@ -38,4 +65,26 @@ describe("Hermes MCP recovery guidance", () => { "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_WORKSPACE", "ambient-workspace"); + mocks.runOpenshellProviderCommand.mockImplementation((_args, options) => { + expect(options?.runtimeSelection).toEqual({ + gatewayName: "nemoclaw-8091", + workspace: "default", + }); + return { + status: 0, + stdout: JSON.stringify({ changed: true, ok: true, reloaded: true }), + stderr: "", + }; + }); + + expect(() => assertHermesMcpMutationRuntimeCapability("alpha")).not.toThrow(); + expect(() => unregisterHermesAdapter("alpha", entry)).not.toThrow(); + expect(mocks.runOpenshellProviderCommand).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..ee7fa2972bf 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts @@ -22,7 +22,9 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import { getMcpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; +import { getSandboxOrThrow } from "./mcp-bridge-state"; import { executeGatewaySupervisorAction } from "./process-recovery"; const HERMES_MCP_EXEC_TIMEOUT_SECONDS = 620; @@ -121,6 +123,7 @@ export function assertHermesMcpConfigMutationAllowed(sandboxName: string): void */ export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): void { assertHermesMcpConfigMutationAllowed(sandboxName); + const runtimeSelection = getMcpProviderInspectionRuntimeSelection(getSandboxOrThrow(sandboxName)); let lastDetail = ""; const probe = (): boolean => { let result: ReturnType; @@ -133,6 +136,7 @@ export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): v ), { ignoreError: true, + runtimeSelection, stdio: ["ignore", "pipe", "pipe"], timeout: 45_000, }, @@ -232,8 +236,12 @@ function runHermesAdapterCommand( // placeholder and endpoint metadata. let result: ReturnType; try { + const runtimeSelection = getMcpProviderInspectionRuntimeSelection( + getSandboxOrThrow(sandboxName), + ); 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. 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..f06bc4fbd14 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts @@ -70,6 +70,7 @@ 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" }; interface AdapterCase { name: string; @@ -164,6 +165,7 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { mocks.executeGatewaySupervisorAction.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReturnValue(sandbox); }); it("re-reads the persisted definition before registration succeeds", () => { @@ -269,6 +271,7 @@ describe("Hermes MCP adapter credential revision", () => { mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); mocks.getSandbox.mockReset(); + mocks.getSandbox.mockReturnValue(sandbox); }); it("writes and verifies the readiness-proven revision", () => { @@ -302,6 +305,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"); }); 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 98571995479..9366d6b41dd 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(), @@ -54,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", () => ({ @@ -122,6 +123,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(() => { @@ -180,4 +188,26 @@ describe("MCP adapter teardown rollback", () => { 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-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index 8163b86e473..a22dac05c6b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -90,6 +90,8 @@ export async function prepareMcpBridgesForDestroy( }; } + await ensureSandboxGatewaySelected(sandboxName); + // 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. @@ -121,7 +123,6 @@ export async function prepareMcpBridgesForDestroy( }; } - await ensureSandboxGatewaySelected(sandboxName); assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); const detached: McpBridgeEntry[] = []; const scrubbedAdapters: McpScrubbedAdapterEntry[] = []; diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts index 43c47b5629c..ac9d0dfac1d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts @@ -22,6 +22,7 @@ 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"; @@ -376,7 +377,13 @@ export function deleteProvider( } 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}'.`); } diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts index e23b247e926..969dc244573 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -325,6 +325,97 @@ Provider: ); }); + 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", + 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 })).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", () => { expect( providerDetachChangedState(0, "✓ Detached provider alpha-mcp-github from sandbox alpha"), @@ -452,7 +543,11 @@ alpha-mcp-slack generic 1 0 ).toThrow("Credential key 'GITHUB_TOKEN' is already supplied by attached provider"); expect(run).toHaveBeenCalledTimes(2); expect( - run.mock.calls.every(([, options]) => options?.runtimeSelection === runtimeSelection), + run.mock.calls.every( + ([, options]) => + options?.runtimeSelection?.gatewayName === runtimeSelection.gatewayName && + options.runtimeSelection.workspace === runtimeSelection.workspace, + ), ).toBe(true); }); diff --git a/test/mcp/mcp-add-crash-consistency.test.ts b/test/mcp/mcp-add-crash-consistency.test.ts index f31cfb26bdb..7ff7cc9b309 100644 --- a/test/mcp/mcp-add-crash-consistency.test.ts +++ b/test/mcp/mcp-add-crash-consistency.test.ts @@ -134,7 +134,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) { @@ -551,7 +551,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 +581,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); diff --git a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts index 92e693793de..3f751eefb3c 100644 --- a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts +++ b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts @@ -241,7 +241,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-provider-detach-retry.test.ts b/test/mcp/mcp-provider-detach-retry.test.ts index 6df4f02754c..4584c1e9e5e 100644 --- a/test/mcp/mcp-provider-detach-retry.test.ts +++ b/test/mcp/mcp-provider-detach-retry.test.ts @@ -18,7 +18,10 @@ let liveId = expectedId; let detachCalls = 0; const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; providerCommands.runOpenshellProviderCommand = (args, options) => { - if (options.runtimeSelection !== runtimeSelection) { + 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") { diff --git a/test/mcp/mcp-restart-policy-order.test.ts b/test/mcp/mcp-restart-policy-order.test.ts index 90f0fcace30..4b109ebe4c6 100644 --- a/test/mcp/mcp-restart-policy-order.test.ts +++ b/test/mcp/mcp-restart-policy-order.test.ts @@ -78,7 +78,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", From 8aa8e1fdd9cf95522487f6fdcd8f0107df62bef2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 03:40:26 -0700 Subject: [PATCH 05/13] test(mcp): seed Hermes startup target Signed-off-by: Apurv Kumaria --- .../hermes/hermes-mcp-startup-probe.test.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/test/agents/hermes/hermes-mcp-startup-probe.test.ts b/test/agents/hermes/hermes-mcp-startup-probe.test.ts index de2e5ab8d7c..0999e6286a0 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, })); @@ -80,6 +86,11 @@ function runHermesProbe( beforeEach(() => { vi.resetAllMocks(); + mocks.getSandbox.mockReturnValue({ + agent: "hermes", + gatewayName: "nemoclaw-8091", + name: "hermes-box", + }); }); const starting: ProbeResult = { @@ -158,16 +169,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([ { From 32bb52431f7c42e690e24f1196e99adc7d07eb03 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 04:39:12 -0700 Subject: [PATCH 06/13] test(mcp): assert Hermes startup target Signed-off-by: Apurv Kumaria --- src/lib/actions/sandbox/gateway-target.ts | 15 ++ .../mcp-bridge-provider-inspection.test.ts | 153 +++++++++++++++++- .../sandbox/mcp-bridge-provider-inspection.ts | 71 +++++++- .../openshell/provider-command.test.ts | 32 +++- .../adapters/openshell/provider-command.ts | 4 + .../hermes/hermes-mcp-startup-probe.test.ts | 8 +- 6 files changed, 276 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/sandbox/gateway-target.ts b/src/lib/actions/sandbox/gateway-target.ts index 17c95ae8c89..9f2cd0d03ff 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"; @@ -27,6 +28,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-provider-inspection.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts index f721827c284..34aedf2ac97 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts @@ -1,12 +1,159 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it } from "vitest"; +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 { inspectMcpProvider } from "./mcp-bridge-provider-inspection"; +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); -afterEach(() => setProviderCommandRuntimeHooksForTest({})); + 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)", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 1f5b99eac42..6ba9bf46ee9 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -1,13 +1,25 @@ // 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 { 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, type SandboxEntry } from "../../state/registry"; -import { getPersistedSandboxTargetGatewayName } from "./gateway-target"; +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"; @@ -41,14 +53,69 @@ export type McpProviderAttachmentInspection = { export type McpProviderInspectionRuntimeSelection = { gatewayName: string; + localTlsDir?: string; workspace: string; }; +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: getPersistedSandboxTargetGatewayName(sandbox), + gatewayName, + ...(localTlsDir ? { localTlsDir } : {}), workspace: OPENSHELL_DEFAULT_WORKSPACE, }; } diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 0a3cdcbf5c1..9bf928772fd 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -72,16 +72,46 @@ describe("OpenShell provider command runtime", () => { 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", workspace: "default" }, + 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", diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index 56fd6430ec4..c39d1ff4b53 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -13,6 +13,7 @@ export type ProviderCommandOptions = { ignoreError?: boolean; runtimeSelection?: { gatewayName: string; + localTlsDir?: string; workspace: string; }; stdio?: StdioOptions; @@ -43,6 +44,9 @@ export function runOpenshellProviderCommand(args: string[], opts?: ProviderComma } env.OPENSHELL_GATEWAY = runtimeSelection.gatewayName; env.OPENSHELL_WORKSPACE = runtimeSelection.workspace; + if (runtimeSelection.localTlsDir) { + env.OPENSHELL_LOCAL_TLS_DIR = runtimeSelection.localTlsDir; + } } const providerOpts = { ...runtimeOptions, diff --git a/test/agents/hermes/hermes-mcp-startup-probe.test.ts b/test/agents/hermes/hermes-mcp-startup-probe.test.ts index 0999e6286a0..57edcf44f60 100644 --- a/test/agents/hermes/hermes-mcp-startup-probe.test.ts +++ b/test/agents/hermes/hermes-mcp-startup-probe.test.ts @@ -50,7 +50,13 @@ function runHermesProbe( 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 }); From b7366ad8ddea108563ab939ce07d97b06d87c3ef Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 14:44:58 -0700 Subject: [PATCH 07/13] fix(mcp): bind lifecycle operations to sandbox target Signed-off-by: Apurv Kumaria --- .../actions/sandbox/backup-shields-window.ts | 3 + .../sandbox/destroy-confirmation.test.ts | 19 + .../actions/sandbox/destroy-confirmation.ts | 14 +- src/lib/actions/sandbox/destroy-execution.ts | 103 ++++- .../destroy-flow-runtime-selection.test.ts | 129 ++++++ src/lib/actions/sandbox/destroy-flow.test.ts | 29 +- .../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 | 69 ++- src/lib/actions/sandbox/destroy.ts | 41 +- src/lib/actions/sandbox/forward-recovery.ts | 133 ++++-- .../sandbox/inference-invocation-probe.ts | 11 +- ...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 | 10 +- .../sandbox/mcp-bridge-adapter-hermes.ts | 38 +- .../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 | 6 +- .../sandbox/mcp-bridge-adapter-teardown.ts | 10 +- .../actions/sandbox/mcp-bridge-adapters.ts | 41 +- .../actions/sandbox/mcp-bridge-add-restart.ts | 90 +++- .../sandbox/mcp-bridge-destroy-preflight.ts | 31 +- src/lib/actions/sandbox/mcp-bridge-destroy.ts | 57 ++- .../mcp-bridge-hermes-reconciliation.ts | 9 +- .../sandbox/mcp-bridge-input-targets.test.ts | 7 +- .../actions/sandbox/mcp-bridge-policy.test.ts | 43 +- src/lib/actions/sandbox/mcp-bridge-policy.ts | 28 +- .../mcp-bridge-private-lifecycle.test.ts | 2 + .../sandbox/mcp-bridge-provider-inspection.ts | 11 +- .../sandbox/mcp-bridge-provider-readiness.ts | 25 +- .../sandbox/mcp-bridge-provider.test.ts | 63 ++- .../mcp-bridge-rebuild-exec-unavailable.ts | 53 ++- src/lib/actions/sandbox/mcp-bridge-rebuild.ts | 123 ++++-- .../actions/sandbox/mcp-bridge-recovery.ts | 10 +- src/lib/actions/sandbox/mcp-bridge-remove.ts | 18 +- .../mcp-bridge-resolution-probe.test.ts | 49 +- .../sandbox/mcp-bridge-resolution-probe.ts | 6 +- src/lib/actions/sandbox/mcp-bridge-restart.ts | 99 ++++- .../mcp-bridge-runtime-capabilities.ts | 10 +- src/lib/actions/sandbox/mcp-bridge-state.ts | 7 +- .../mcp-bridge-status-boundaries.test.ts | 49 +- .../mcp-bridge-status-resolution.test.ts | 61 ++- .../sandbox/mcp-bridge-status-state.test.ts | 17 +- src/lib/actions/sandbox/mcp-bridge-status.ts | 33 +- .../sandbox/mcp-bridge-tool-discovery.ts | 4 +- src/lib/actions/sandbox/mcp-bridge.ts | 37 +- .../messaging-host-forward-lifecycle.ts | 56 ++- src/lib/actions/sandbox/policy-get.ts | 7 +- .../sandbox/process-recovery-temp-ssh.test.ts | 133 +++++- src/lib/actions/sandbox/process-recovery.ts | 235 ++++++++-- .../actions/sandbox/rebuild-backup-phase.ts | 3 + .../sandbox/rebuild-config-hash.test.ts | 61 ++- .../actions/sandbox/rebuild-config-hash.ts | 15 +- .../rebuild-dcode-artifact-drift.test.ts | 16 +- .../rebuild-dcode-mutation-edge.test.ts | 9 +- .../sandbox/rebuild-dcode-orchestrator.ts | 17 +- .../rebuild-dcode-pre-delete-drift.test.ts | 16 +- .../sandbox/rebuild-dcode-preflight.ts | 60 ++- .../sandbox/rebuild-destroy-phase.test.ts | 103 ++++- .../actions/sandbox/rebuild-destroy-phase.ts | 83 +++- .../sandbox/rebuild-flow-helpers.test.ts | 2 +- .../actions/sandbox/rebuild-flow-helpers.ts | 4 +- .../sandbox/rebuild-flow-lifecycle.test.ts | 56 ++- .../sandbox/rebuild-flow-recovery.test.ts | 15 +- .../sandbox/rebuild-flow-target-image.test.ts | 1 + .../sandbox/rebuild-gateway-drift.test.ts | 21 + .../actions/sandbox/rebuild-gpu-opt-out.ts | 2 + .../rebuild-hermes-accepted-target.test.ts | 86 ++++ .../sandbox/rebuild-hermes-post-restore.ts | 16 +- .../actions/sandbox/rebuild-mcp-phase.test.ts | 43 +- src/lib/actions/sandbox/rebuild-mcp-phase.ts | 63 ++- .../sandbox/rebuild-messaging-phase.ts | 34 +- .../sandbox/rebuild-messaging-removal.test.ts | 47 +- src/lib/actions/sandbox/rebuild-pipeline.ts | 45 +- .../rebuild-post-restore-phase.test.ts | 52 ++- .../sandbox/rebuild-post-restore-phase.ts | 46 +- .../sandbox/rebuild-preflight-guards.ts | 11 +- .../sandbox/rebuild-preflight-phase.ts | 9 +- .../rebuild-provider-preflight.test.ts | 45 ++ .../sandbox/rebuild-provider-preflight.ts | 11 + .../sandbox/rebuild-recreate-journal.test.ts | 66 +++ .../sandbox/rebuild-recreate-journal.ts | 29 +- .../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-shields-finally.test.ts | 2 + .../sandbox/rebuild-shields-phase.test.ts | 14 +- .../actions/sandbox/rebuild-shields-phase.ts | 14 +- src/lib/actions/sandbox/rebuild-shields.ts | 11 +- .../sandbox/reconcile-session-models.test.ts | 21 + .../sandbox/reconcile-session-models.ts | 34 +- src/lib/adapters/openshell/client.ts | 2 + src/lib/adapters/openshell/command-argv.ts | 2 + .../adapters/openshell/gateway-drift.test.ts | 66 +++ src/lib/adapters/openshell/gateway-drift.ts | 51 ++- .../adapters/openshell/policy-state.test.ts | 111 +++++ src/lib/adapters/openshell/policy-state.ts | 89 +++- .../openshell/provider-command.test.ts | 6 +- .../adapters/openshell/provider-command.ts | 30 +- .../adapters/openshell/runtime-selection.ts | 45 ++ src/lib/adapters/openshell/runtime.ts | 9 + src/lib/adapters/sandbox/command-transport.ts | 23 +- src/lib/gateway-runtime-action.test.ts | 56 +++ src/lib/gateway-runtime-action.ts | 38 +- src/lib/onboard/agent-fixed-forward.ts | 2 + .../authoritative-rebuild-target.test.ts | 43 ++ .../onboard/authoritative-rebuild-target.ts | 60 +++ .../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/entry-options.ts | 27 +- src/lib/onboard/forward-start.ts | 2 + src/lib/onboard/gateway-recovery.test.ts | 57 +++ src/lib/onboard/gateway-recovery.ts | 51 ++- src/lib/onboard/gateway-reuse.test.ts | 50 ++- src/lib/onboard/gateway-reuse.ts | 47 +- .../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 | 74 +++- src/lib/onboard/gateway/start.ts | 44 +- src/lib/onboard/sandbox-recreate-probe.ts | 16 + src/lib/onboard/types.ts | 2 + src/lib/policy/commands.ts | 13 +- src/lib/policy/index.ts | 189 ++++++-- src/lib/policy/policy-live-state.test.ts | 63 ++- src/lib/runner.ts | 48 +- src/lib/shields/flow.test.ts | 200 ++++----- src/lib/shields/index.ts | 342 +++++++++++--- src/lib/shields/legacy-hermes-compat.test.ts | 417 +++++++++--------- src/lib/shields/transition-lock.ts | 10 +- .../state/openclaw-config-restore-input.ts | 6 +- src/lib/state/openclaw-plugin-restore.ts | 3 + src/lib/state/paths.ts | 5 + ...sandbox-recreated-openclaw-restore.test.ts | 153 +++++-- src/lib/state/sandbox-session.test.ts | 55 ++- src/lib/state/sandbox-session.ts | 31 +- src/lib/state/sandbox.ts | 48 +- src/lib/state/state-file-restore.ts | 3 + .../state/user-managed-files-probe.test.ts | 107 +++++ src/lib/state/user-managed-files-probe.ts | 24 +- .../deepagents-mcp-runtime-capability.test.ts | 33 +- .../hermes/hermes-mcp-startup-probe.test.ts | 6 +- test/e2e-runtime/runner.test.ts | 37 +- test/helpers/destroy-flow-test-assertions.ts | 57 ++- test/helpers/destroy-flow-test-harness.ts | 26 +- ...ermes-shields-provider-consumer-harness.ts | 129 +++++- test/helpers/rebuild-flow-dcode-harness.ts | 5 + test/helpers/rebuild-flow-generic-harness.ts | 15 +- test/helpers/rebuild-flow-harness.ts | 1 + test/helpers/rebuild-flow-test-support.ts | 5 + test/helpers/shields-flow-harness.ts | 106 +++++ .../mcp/mcp-adapter-teardown-rollback.test.ts | 20 +- test/mcp/mcp-destroy-lifecycle.test.ts | 30 +- 165 files changed, 5952 insertions(+), 1106 deletions(-) create mode 100644 src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts create mode 100644 src/lib/adapters/openshell/runtime-selection.ts diff --git a/src/lib/actions/sandbox/backup-shields-window.ts b/src/lib/actions/sandbox/backup-shields-window.ts index 128493e71b0..ee2d316dfc7 100644 --- a/src/lib/actions/sandbox/backup-shields-window.ts +++ b/src/lib/actions/sandbox/backup-shields-window.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { RD as _RD, G, R, YW } from "../../cli/terminal-style"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime"; import * as shields from "../../shields"; import { isShieldsTimerDeadlineExpired } from "../../state/mcp-lifecycle-lock/shields-timer-authority"; @@ -18,6 +19,7 @@ export interface BackupShieldsWindowOptions { shieldsUpCommand: string; deferAutoRestoreWhileOwnerAlive?: boolean; allowLegacyHermesProtocol?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; } export function openBackupShieldsWindow( @@ -102,6 +104,7 @@ export function relockBackupShieldsWindow( throwOnError: true, ...(options.allowLegacyHermesProtocol ? { allowLegacyHermesProtocol: true } : {}), ...(policySnapshotRecovery ? { policySnapshotRecovery } : {}), + ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), }); console.log(` ${G}✓${R} Shields restored to UP`); window.relocked = true; 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 f08f48b82c6..303b0588c33 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/runtime-selection"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/policy-state"; import { R, YW } from "../../cli/terminal-style"; @@ -67,6 +69,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; } @@ -119,13 +123,16 @@ type HardenedDeleteState = { timerProcessToken?: string; }; -function emptyMcpDestroyPreparation(): McpDestroyPreparation { +function emptyMcpDestroyPreparation( + runtimeSelection?: McpDestroyPreparation["runtimeSelection"], +): McpDestroyPreparation { return { entries: [], detachedProviderEntries: [], scrubbedAdapterEntries: [], destroyAlreadyPrepared: false, destroyAlreadyPending: false, + ...(runtimeSelection ? { runtimeSelection } : {}), }; } @@ -134,13 +141,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); + ? await prepareMcpBridgesForAbsentSandboxDestroy(sandboxName, { + force, + ...(runtimeSelection ? { runtimeSelection } : {}), + }) + : await prepareMcpBridgesForDestroy( + sandboxName, + 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.`, @@ -154,12 +168,17 @@ function wipeAndHardenLiveSandbox( sandboxRuntimeConfirmedAbsent: boolean, cliName: string, deps: NonNullable = {}, + selectedRunOpenshell?: DestroyRunOpenshell, + runtimeSelection?: OpenShellRuntimeSelection, ): HardenedDeleteState { if (sandboxRuntimeConfirmedAbsent) return { hardenedForDelete: false, hardeningFailed: false }; // Wipe before delete while the retained volume is still mounted. The caller // holds the timer-bound lock across this phase and all following teardown. - (deps.wipeSandboxState ?? wipeSandboxState)(sandboxName); + (deps.wipeSandboxState ?? wipeSandboxState)( + sandboxName, + selectedRunOpenshell ? { runOpenshell: selectedRunOpenshell } : {}, + ); const timerMarker = (deps.readTimerMarker ?? readTimerMarker)(sandboxName); if (!timerMarker) return { hardenedForDelete: false, hardeningFailed: false }; @@ -171,6 +190,7 @@ function wipeAndHardenLiveSandbox( shieldsUp(sandboxName, { throwOnError: true, allowLegacyHermesProtocol: true, + ...(runtimeSelection ? { runtimeSelection } : {}), }); } catch (error) { /** @@ -247,6 +267,9 @@ async function restoreMcpAfterDeleteAbort( allowLegacyHermesProtocol: true, deferAutoRestoreWhileOwnerAlive: true, processToken: hardened.timerProcessToken, + ...(preparation.runtimeSelection + ? { runtimeSelection: preparation.runtimeSelection } + : {}), }); openedRollbackWindow = true; } @@ -260,6 +283,9 @@ async function restoreMcpAfterDeleteAbort( shieldsUp(sandboxName, { throwOnError: true, allowLegacyHermesProtocol: true, + ...(preparation.runtimeSelection + ? { runtimeSelection: preparation.runtimeSelection } + : {}), }); } catch (error) { const detail = redactDestroyError(error); @@ -298,6 +324,7 @@ export async function executeSandboxDestroy({ getSandbox, listSandboxes, runOpenshell, + mcpRuntimeSelection, sandbox, sandboxConfirmedAbsent, sandboxName, @@ -309,6 +336,7 @@ export async function executeSandboxDestroy({ deps = {}, }: SandboxDestroyExecutionInput): Promise { return withTimerBoundShieldsMutationLockAsync(sandboxName, "destroy sandbox", async () => { + let destroyRuntimeSelection = mcpRuntimeSelection; type IdentityContinuity = | { status: "match" } | { status: "changed"; subject?: string } @@ -356,6 +384,7 @@ export async function executeSandboxDestroy({ const liveFingerprint = inspectIdentity({ sandboxName, gatewayName: pendingCreateIdentity.gatewayName, + ...(destroyRuntimeSelection ? { runtimeSelection: destroyRuntimeSelection } : {}), }); if ( liveFingerprint !== pendingCreateIdentity.sandboxIdentityFingerprint || @@ -472,7 +501,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 { @@ -487,6 +522,29 @@ 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, + shieldsRelockRequiresGateway: 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. @@ -554,6 +612,8 @@ export async function executeSandboxDestroy({ sandboxRuntimeConfirmedAbsent, cliName, deps, + selectedRunOpenshell, + destroyRuntimeSelection, ); } catch (error) { const mcpRecoveryFailure = await restoreMcpForAbort(notHardened); @@ -578,7 +638,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(hardened); @@ -611,15 +674,34 @@ export async function executeSandboxDestroy({ ` Managed inference cleanup and workspace wipe or hardening may already have run; inspect those resources before retrying.${detachedDetail}`, ); } - const deleteArgs = pendingCreateIdentity - ? ["sandbox", "delete", "-g", pendingCreateIdentity.gatewayName, sandboxName] + const deleteRuntimeSelection = destroyRuntimeSelection; + if ( + pendingCreateIdentity && + deleteRuntimeSelection && + pendingCreateIdentity.gatewayName !== deleteRuntimeSelection.gatewayName + ) { + const mcpRecoveryFailure = await restoreMcpForAbort(hardened); + return { + ok: false as const, + deleteOutput: "Sandbox delete target changed during destroy preparation.", + exitCode: 1, + gatewayUnreachable: false, + hostLocalInferenceOwnershipRequiresGateway: false, + mcpOwnershipRequiresGateway: false, + mcpRecoveryFailure, + shieldsRelockRequiresGateway: hardened.hardeningFailed, + }; + } + const deleteGatewayName = pendingCreateIdentity?.gatewayName ?? deleteRuntimeSelection?.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"], @@ -767,6 +849,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..1719b8a8fd1 --- /dev/null +++ b/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts @@ -0,0 +1,129 @@ +// 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 }); + + expectMcpFinalizeAfterDelete(harness); + expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { + 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", + }), + }), + ); + }); +}); diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index fc94c1bb2d9..ee07d65b602 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -16,7 +16,6 @@ import { expectFailedHardeningStillDeletes, expectFailedMcpFinalizePreservesRegistry, expectFailedMcpRestorePreservesDestroyFailure, - expectMcpFinalizeAfterDelete, expectMcpFinalizeBridgeErrorReturnsFailure, expectMcpPrepareBridgeErrorAborts, expectMcpRestoreAfterDeleteFailure, @@ -1094,7 +1093,12 @@ describe("destroySandbox flow", () => { await expect(harness.destroySandbox("alpha", { yes: true })).resolves.toBeUndefined(); - expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha"); + expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), + }); }); it("does not require mutable Hermes config for absent-sandbox cleanup", async () => { @@ -1109,6 +1113,10 @@ describe("destroySandbox flow", () => { expect(harness.prepareMcpBridgesForAbsentSandboxDestroySpy).toHaveBeenCalledWith("alpha", { force: false, + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), }); }); @@ -1366,14 +1374,6 @@ describe("destroySandbox flow", () => { expect(exitSpy).toHaveBeenCalledWith(7); }); - it("detaches MCP providers before delete and finalizes them only after delete succeeds", async () => { - const harness = createDestroyHarness({ mcpServers: ["github", "slack"] }); - - await harness.destroySandbox("alpha", { yes: true }); - - expectMcpFinalizeAfterDelete(harness); - }); - it("restores MCP runtime state when sandbox delete fails", async () => { const harness = createDestroyHarness({ activeTimer: true, @@ -1467,14 +1467,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 59180b9e3b7..20661c9bda7 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, @@ -227,6 +238,7 @@ async function runDestroy( sandboxConfirmedAbsent: options.sandboxConfirmedAbsent ?? false, sandboxName: "alpha", stopInferenceResources, + ...(options.mcpRuntimeSelection ? { mcpRuntimeSelection: options.mcpRuntimeSelection } : {}), runtimeProviders: { mxc: runtimeProvider.bundle }, deps: { ...(options.lifecycleOptions @@ -295,15 +307,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 ce24ed460ba..eec58aa3601 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/runtime-selection"; +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"; @@ -31,11 +33,23 @@ import { assertMcpAdapterConfigMutationsAllowed } from "./mcp-bridge-runtime-cap 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.keys(sandbox.mcp?.bridges ?? {}).length === 0) return undefined; + return ( + require("./mcp-bridge-provider") as typeof import("./mcp-bridge-provider") + ).getMcpProviderInspectionRuntimeSelection(sandbox); +} + export function stopSandboxInferenceResources( sandboxName: string, sandbox: SandboxEntry | null, @@ -266,12 +280,14 @@ export async function stopModelRouterForDestroyedSandbox( export function prepareSandboxDestroy( sandboxName: string, 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 @@ -289,12 +305,35 @@ 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, @@ -313,8 +352,24 @@ export function prepareSandboxDestroy( ) { // Fail before stopping local services or mutating any MCP resource when // the live adapter config cannot be changed safely. - assertMcpAdapterConfigMutationsAllowed(sandboxName, sandbox, mcpEntriesRequiringConfigMutation); + if (!runtimeSelection) { + throw new Error(`MCP destroy target is unavailable for sandbox '${sandboxName}'.`); + } + assertMcpAdapterConfigMutationsAllowed( + sandboxName, + sandbox, + mcpEntriesRequiringConfigMutation, + runtimeSelection, + ); } - 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 176e6a5a378..4fa05aed91a 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -62,6 +62,7 @@ import { } from "./destroy-presence"; import { prepareSandboxDestroy, + resolveSandboxDestroyRuntimeSelection, stopModelRouterForDestroyedSandbox, stopSandboxInferenceResources, } from "./destroy-preflight"; @@ -563,9 +564,10 @@ async function destroySandboxUnlocked( options: string[] | DestroySandboxOptions = {}, ): 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, @@ -671,9 +673,21 @@ async function destroySandboxUnlocked( }; let destroyPreflight: ReturnType; destroyPreflight = abortPreparedCleanupOnError(() => - prepareSandboxDestroy(sandboxName, retainedRecoveryAuthority?.gatewayName), + prepareSandboxDestroy( + sandboxName, + 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.`, @@ -719,6 +733,7 @@ async function destroySandboxUnlocked( getSandbox: registry.getSandbox, listSandboxes: registry.listSandboxes, runOpenshell, + ...(mcpRuntimeSelection ? { mcpRuntimeSelection } : {}), sandbox, sandboxConfirmedAbsent, sandboxName, @@ -818,8 +833,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 @@ -890,6 +917,8 @@ async function destroySandboxUnlocked( }); cleanupSandboxServices(sandboxName, { stopHostServices: shouldStopHostServices, + }, { + runOpenshell: cleanupRunOpenshell, }); }); if (deleteSucceededOrAlreadyGone && commonLlamaCppAuthorityRetired === true) { @@ -1047,11 +1076,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/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 1d59219122b..9c661db7d13 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -4,7 +4,13 @@ import { spawnSync } from "node:child_process"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; -import { captureOpenshell, isCommandTimeout, runOpenshell } from "../../adapters/openshell/runtime"; +import { + buildSelectedOpenShellSubprocessEnv, + captureOpenshell, + isCommandTimeout, + type OpenShellRuntimeSelection, + runOpenshell, +} from "../../adapters/openshell/runtime"; import { OPENSHELL_OPERATION_TIMEOUT_MS, OPENSHELL_PROBE_TIMEOUT_MS, @@ -67,8 +73,24 @@ type SandboxForwardRecoveryOptions = { afterSuccess?: () => boolean; beforeStart?: () => boolean; isWsl?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; }; +type OpenShellRunnerOptions = NonNullable[1]>; + +function withSelectedOpenShellOptions( + options: OpenShellRunnerOptions, + runtimeSelection?: OpenShellRuntimeSelection, +): OpenShellRunnerOptions { + return runtimeSelection + ? { + ...options, + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : options; +} + type DashboardForwardStopRunner = ( args: string[], options: { ignoreError: true; stdio: "ignore"; timeout: number }, @@ -209,6 +231,7 @@ export function ensureSandboxPortForward( (!remoteBindRequested || registry.getSandbox(sandboxName)?.dashboardRemoteBindPrepared === true) && (options.beforeStart?.() ?? true), + runtimeSelection: options.runtimeSelection, }); } @@ -228,7 +251,7 @@ export function ensureSandboxPortForward( */ export function isSandboxForwardHealthy( sandboxName: string, - options: { isWsl?: boolean } = {}, + options: { isWsl?: boolean; runtimeSelection?: OpenShellRuntimeSelection } = {}, ): SandboxForwardHealth { const allInterfaceBindRequired = isRemoteDashboardBindRequested(process.env.NEMOCLAW_DASHBOARD_BIND) || @@ -237,6 +260,7 @@ export function isSandboxForwardHealthy( sandboxName, resolveSandboxDashboardPort(sandboxName), allInterfaceBindRequired ? "0.0.0.0" : "127.0.0.1", + options.runtimeSelection, ); } @@ -244,11 +268,18 @@ export function isSandboxPortForwardHealthy( sandboxName: string, port: number, expectedBind?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): SandboxForwardHealth { - const result = captureOpenshell(["forward", "list"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); + const result = captureOpenshell( + ["forward", "list"], + withSelectedOpenShellOptions( + { + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }, + runtimeSelection, + ), + ); if (!result || isCommandTimeout(result) || result.status !== 0) return null; const entries = parseForwardList(result.output) as SandboxForwardListEntry[]; return classifyForwardHealthWithReachability( @@ -269,6 +300,7 @@ export function ensureSandboxPortForwardForPort( forceRestart?: boolean; expectedBind?: string; beforeStart?: () => boolean; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): boolean { const { @@ -277,6 +309,7 @@ export function ensureSandboxPortForwardForPort( forceRestart = false, expectedBind, beforeStart = () => true, + runtimeSelection, } = options; const acceptSuccessfulForward = () => { let accepted = false; @@ -286,22 +319,27 @@ export function ensureSandboxPortForwardForPort( accepted = false; } if (accepted) return true; - runOpenshell(["forward", "stop", String(port), sandboxName], { - ignoreError: true, - stdio: "ignore", - }); + runOpenshell( + ["forward", "stop", String(port), sandboxName], + withSelectedOpenShellOptions({ ignoreError: true, stdio: "ignore" }, runtimeSelection), + ); return false; }; - let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); + let forwardHealth = isSandboxPortForwardHealthy( + sandboxName, + port, + expectedBind, + runtimeSelection, + ); if (forwardHealth === true && !forceRestart) return acceptSuccessfulForward(); if (forwardHealth === "occupied") return false; const configuredWaitMs = Number(process.env.NEMOCLAW_FORWARD_RECOVERY_WAIT_MS ?? "3000"); const waitMs = Number.isFinite(configuredWaitMs) ? Math.max(0, configuredWaitMs) : 3000; - const stopResult = runOpenshell(["forward", "stop", String(port), sandboxName], { - ignoreError: true, - stdio: "ignore", - }); + const stopResult = runOpenshell( + ["forward", "stop", String(port), sandboxName], + withSelectedOpenShellOptions({ ignoreError: true, stdio: "ignore" }, runtimeSelection), + ); if (stopResult.status !== 0) { console.error( ` Warning: openshell forward stop ${port} ${sandboxName} exited ${stopResult.status}; attempting restart anyway.`, @@ -330,7 +368,12 @@ export function ensureSandboxPortForwardForPort( portReleased: false, }; waitForForwardRecoveryState(() => { - stopState.health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); + stopState.health = isSandboxPortForwardHealthy( + sandboxName, + port, + expectedBind, + runtimeSelection, + ); stopState.portReleased = !isLocalForwardReachable(port); return ( (!forceRestart && stopState.health === true) || @@ -348,14 +391,17 @@ export function ensureSandboxPortForwardForPort( if (!beforeStart()) return false; const startResult = runOpenshell( ["forward", "start", "--background", forwardTarget, sandboxName], - { - ignoreError: true, + withSelectedOpenShellOptions( + { + ignoreError: true, // OpenShell 0.0.85 leaves the background SSH forward attached to the // caller's inherited descriptors. Detach them so a scripted `recover` // can finish after the foreground OpenShell command exits. Keep this // until every supported OpenShell release redirects those descriptors. - stdio: "ignore", - }, + stdio: "ignore", + }, + runtimeSelection, + ), ); // OpenShell 0.0.85 returns an error when start preflight finds a validated // live forward for the requested port. Recovery cannot change that upstream @@ -369,14 +415,14 @@ export function ensureSandboxPortForwardForPort( // entry becomes visible. Poll for the exact live sandbox+port owner instead // of accepting an arbitrary reachable listener or failing on the first // metadata refresh. - let health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); + let health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind, runtimeSelection); if (health === true) return acceptSuccessfulForward(); if (health === "occupied") return false; if (waitMs === 0) return false; let occupied = false; const settled = waitForForwardRecoveryState(() => { - health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind); + health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind, runtimeSelection); if (health === "occupied") { occupied = true; return true; @@ -386,10 +432,15 @@ export function ensureSandboxPortForwardForPort( return settled && !occupied && acceptSuccessfulForward(); } -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 }), }); } @@ -402,20 +453,26 @@ 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; if (health === "occupied") return false; - 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."); } @@ -465,6 +522,7 @@ function resolveDeclaredAgentForwardPorts( export function ensureDeclaredAgentForwardPortsHealthy( sandboxName: string, primaryPort: number, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean | null { const agent = agentRuntime.getSessionAgent(sandboxName); if (!agent) return null; @@ -479,13 +537,13 @@ 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 (health === "occupied") { allHealthy = false; continue; } - if (!ensureSandboxPortForwardForPort(sandboxName, port)) { + if (!ensureSandboxPortForwardForPort(sandboxName, port, { runtimeSelection })) { allHealthy = false; } } @@ -564,9 +622,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/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/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 2ad79a384ad..3342d06e8fb 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 @@ -40,6 +40,8 @@ const entry: McpBridgeEntry = { addedAt: new Date(0).toISOString(), }; +const runtimeSelection = { gatewayName: "nemoclaw-8091", workspace: "default" }; + describe("Hermes MCP recovery guidance", () => { beforeEach(() => { vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemohermes"); @@ -61,7 +63,7 @@ describe("Hermes MCP recovery guidance", () => { }); 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.", ); }); @@ -83,8 +85,10 @@ describe("Hermes MCP recovery guidance", () => { }; }); - expect(() => assertHermesMcpMutationRuntimeCapability("alpha")).not.toThrow(); - expect(() => unregisterHermesAdapter("alpha", entry)).not.toThrow(); + expect(() => + assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection), + ).not.toThrow(); + expect(() => unregisterHermesAdapter("alpha", entry, runtimeSelection)).not.toThrow(); expect(mocks.runOpenshellProviderCommand).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 ee7fa2972bf..2f710f4b258 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts @@ -22,9 +22,8 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { commandOutput, redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; -import { getMcpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; -import { getSandboxOrThrow } from "./mcp-bridge-state"; import { executeGatewaySupervisorAction } from "./process-recovery"; const HERMES_MCP_EXEC_TIMEOUT_SECONDS = 620; @@ -85,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, ); } @@ -109,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.`, ); @@ -121,9 +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); - const runtimeSelection = getMcpProviderInspectionRuntimeSelection(getSandboxOrThrow(sandboxName)); +export function assertHermesMcpMutationRuntimeCapability( + sandboxName: string, + runtimeSelection: McpProviderInspectionRuntimeSelection, +): void { + assertHermesMcpConfigMutationAllowed(sandboxName, runtimeSelection); let lastDetail = ""; const probe = (): boolean => { let result: ReturnType; @@ -228,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 @@ -236,9 +243,6 @@ function runHermesAdapterCommand( // placeholder and endpoint metadata. let result: ReturnType; try { - const runtimeSelection = getMcpProviderInspectionRuntimeSelection( - getSandboxOrThrow(sandboxName), - ); result = runOpenshellProviderCommand(buildHermesMcpExecArgs(sandboxName, command), { ignoreError: true, runtimeSelection, @@ -289,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( @@ -302,6 +312,7 @@ function verifyHermesAdapterRegistration( export function registerHermesAdapter( sandboxName: string, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, replaceExisting = false, credentialRevision?: McpAttachedCredentialRevision, @@ -311,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( @@ -326,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 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 f06bc4fbd14..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"; @@ -71,6 +72,7 @@ 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; @@ -164,15 +166,14 @@ describe.each(adapterCases)("$name MCP adapter registration", (adapterCase) => { mocks.executeSandboxCommand.mockReset(); mocks.executeGatewaySupervisorAction.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); - mocks.getSandbox.mockReturnValue(sandbox); + 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(); @@ -180,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 }), ); }); @@ -187,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.`); @@ -197,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", () => { @@ -218,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( @@ -227,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", () => { @@ -249,6 +264,7 @@ describe("Deep Agents MCP adapter credential revision", () => { "alpha", "deepagents-config", entry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, { credentialRevision: "v12" }, ), @@ -270,8 +286,7 @@ describe("Hermes MCP adapter credential revision", () => { beforeEach(() => { mocks.executeSandboxCommand.mockReset(); mocks.runOpenshellProviderCommand.mockReset(); - mocks.getSandbox.mockReset(); - mocks.getSandbox.mockReturnValue(sandbox); + mocks.getSandbox.mockReset().mockReturnValue(sandbox); }); it("writes and verifies the readiness-proven revision", () => { @@ -283,6 +298,7 @@ describe("Hermes MCP adapter credential revision", () => { "alpha", "hermes-config", baseEntry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, { credentialRevision: "v12" }, ), @@ -319,6 +335,7 @@ describe.each(reconciliationCases)("$name MCP credential revision reconciliation "alpha", adapterCase.adapter, adapterCase.entry, + runtimeSelection, { GITHUB_TOKEN: "host-only-secret" }, "v11", ), @@ -335,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) => { @@ -356,6 +419,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v11", ), @@ -379,6 +443,7 @@ describe("MCP adapter credential revision reconciliation failures", () => { "alpha", "mcporter", { ...baseEntry, agent: "openclaw", adapter: "mcporter" }, + runtimeSelection, {}, "v10", ), @@ -404,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 9366d6b41dd..0f82a56fa6a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts @@ -99,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", @@ -160,6 +161,7 @@ describe("MCP adapter teardown rollback", () => { "alpha", "hermes-config", expect.objectContaining({ ...entry, credentialRevision: "v12" }), + runtimeSelection, {}, "v13", { @@ -181,7 +183,9 @@ 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(); 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 f3becd77eec..8f07fea1b21 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -32,6 +32,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; @@ -69,14 +72,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); } } @@ -93,20 +97,24 @@ export function inspectAgentAdapterRegistration( export function assertAgentMcpConfigMutationAllowed( sandboxName: string, adapter: AgentMcpAdapter, + runtimeSelection: McpProviderInspectionRuntimeSelection, ): void { - if (adapter === "hermes-config") assertHermesMcpConfigMutationAllowed(sandboxName); + if (adapter === "hermes-config") { + assertHermesMcpConfigMutationAllowed(sandboxName, 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; @@ -123,10 +131,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); } } @@ -134,6 +143,7 @@ export function registerAgentAdapter( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record = {}, options: { replaceExisting?: boolean; @@ -146,6 +156,7 @@ export function registerAgentAdapter( registerOpenClawAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.credentialRevision, @@ -155,6 +166,7 @@ export function registerAgentAdapter( registerHermesAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.credentialRevision, @@ -164,6 +176,7 @@ export function registerAgentAdapter( registerDeepAgentsAdapter( sandboxName, entry, + runtimeSelection, envValues, options.replaceExisting === true, options.teardownRollback === true, @@ -178,6 +191,7 @@ export function registerAgentAdapterAtCurrentCredentialRevision( sandboxName: string, adapter: AgentMcpAdapter, entry: McpBridgeEntry, + runtimeSelection: McpProviderInspectionRuntimeSelection, envValues: Record, initialCredentialRevision: McpAttachedCredentialRevision, options: { replaceExisting?: boolean; teardownRollback?: boolean } = {}, @@ -193,7 +207,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, @@ -203,7 +217,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); } @@ -241,16 +255,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 7215d30d491..81658d39c62 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -42,6 +42,7 @@ import { getMcpProviderInspectionRuntimeSelection, inspectMcpProvider, type McpCredentialRevisionObservation, + type McpProviderInspectionRuntimeSelection, observeMcpCredentialRevision, providerMatchesCredential, providerShapeDetail, @@ -99,7 +100,12 @@ function assertPreparedMcpAddResourcesAbsent( 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" @@ -128,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.`, @@ -139,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); @@ -149,7 +160,7 @@ export async function addMcpBridge( async function addMcpBridgeUnlocked( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise { validateSandboxName(sandboxName); validateMcpServerName(options.server); assertAuthenticatedCredentialReference(options.env); @@ -304,11 +315,11 @@ async function addMcpBridgeUnlocked( // 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 @@ -344,9 +355,9 @@ async function addMcpBridgeUnlocked( 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 { @@ -355,7 +366,10 @@ async function addMcpBridgeUnlocked( // policy cleanup happen only after the running-image capability probe. assertMcpProviderRecoverable(entry, providerRuntimeSelection); } catch (error) { - removeGeneratedPolicy(sandboxName, entry, { bestEffort: true }); + removeGeneratedPolicy(sandboxName, entry, { + bestEffort: true, + runtimeSelection: providerRuntimeSelection, + }); throw error; } } @@ -374,7 +388,12 @@ async function addMcpBridgeUnlocked( // 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") @@ -396,7 +415,10 @@ async function addMcpBridgeUnlocked( // 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 @@ -410,7 +432,11 @@ async function addMcpBridgeUnlocked( // 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, + ); } }, }); @@ -436,9 +462,15 @@ async function addMcpBridgeUnlocked( } providerAttachAttempted = true; attachProvider(sandboxName, entry, providerRuntimeSelection); - applyGeneratedPolicy(sandboxName, entry, target); + 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, @@ -471,7 +503,8 @@ async function addMcpBridgeUnlocked( 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 @@ -484,14 +517,17 @@ async function addMcpBridgeUnlocked( 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 @@ -500,7 +536,11 @@ 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) { @@ -512,14 +552,17 @@ async function addMcpBridgeUnlocked( !!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, { @@ -530,7 +573,7 @@ async function addMcpBridgeUnlocked( let reservationCleanupProved = !providerAttachAttempted; if (providerAttachAttempted && detachOutcome !== "unknown") { try { - waitForDetachedMcpCredential(sandboxName, entry); + waitForDetachedMcpCredential(sandboxName, entry, providerRuntimeSelection); reservationCleanupProved = true; } catch { reservationCleanupProved = false; @@ -551,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-preflight.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts index 14a5bd1010e..9249d75f89e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts @@ -33,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; } export function cloneMcpBridgeEntry(entry: McpBridgeEntry): McpBridgeEntry { @@ -66,14 +68,20 @@ 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 = getMcpProviderInspectionRuntimeSelection(sandbox); + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + if (providerlessCandidates.length > 0) { + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); + } const remainingEntries: Array<[string, McpBridgeEntry]> = []; const providerlessPreflighted: McpBridgeEntry[] = []; for (const [server, entry] of Object.entries(bridges)) { @@ -94,7 +102,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 @@ -167,16 +177,22 @@ 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 providerRuntimeSelection = + options.runtimeSelection ?? 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; - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); for (const entry of entries) { inspectExactMcpDestroyProvider(entry, { allowMissing: true, @@ -190,5 +206,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 a22dac05c6b..dd9efd9d417 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -54,12 +54,15 @@ export { */ export async function prepareMcpBridgesForDestroy( sandboxName: string, + options: { runtimeSelection?: McpDestroyPreparation["runtimeSelection"] } = {}, ): Promise { validateSandboxName(sandboxName); const currentSandbox = getSandboxOrThrow(sandboxName); const entriesRequiringExternalCleanup = Object.values(bridgeState(currentSandbox)).filter( (entry) => entry.addState !== "prepared", ); + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(currentSandbox); // Run the host-visible config preflight before // discardSafeIncompleteMcpAdds, which may remove the generated live policy key for a // providerless preflighted add. That cleanup has no adapter/provider to @@ -68,10 +71,12 @@ export async function prepareMcpBridgesForDestroy( sandboxName, currentSandbox, entriesRequiringExternalCleanup, + providerRuntimeSelection, ); - const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox); + const sandbox = await discardSafeIncompleteMcpAdds(sandboxName, currentSandbox, { + runtimeSelection: providerRuntimeSelection, + }); const entries = Object.values(bridgeState(sandbox)).map(cloneMcpBridgeEntry); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); const destroyAlreadyPrepared = !!sandbox.mcp?.destroyPreparedAt; const destroyAlreadyPending = !!sandbox.mcp?.destroyPendingAt; const incompleteAdd = entries.find((entry) => entry.addState === "preflighted"); @@ -87,10 +92,11 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending, + runtimeSelection: providerRuntimeSelection, }; } - await ensureSandboxGatewaySelected(sandboxName); + 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; @@ -108,6 +114,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: [], destroyAlreadyPrepared, destroyAlreadyPending: true, + runtimeSelection: providerRuntimeSelection, }; } if (destroyAlreadyPrepared) { @@ -120,19 +127,34 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: entries.map(cloneMcpBridgeEntry), destroyAlreadyPrepared: true, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, }; } - 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) { @@ -149,7 +171,7 @@ export async function prepareMcpBridgesForDestroy( `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 @@ -179,6 +201,7 @@ export async function prepareMcpBridgesForDestroy( try { await restoreExistingMcpBridgeRuntime(sandboxName, removedPolicies, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (rollbackError) { @@ -188,7 +211,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) { @@ -222,6 +252,7 @@ export async function prepareMcpBridgesForDestroy( scrubbedAdapterEntries: scrubbedAdapters, destroyAlreadyPrepared: false, destroyAlreadyPending: false, + runtimeSelection: providerRuntimeSelection, }; } @@ -234,7 +265,8 @@ export async function restoreMcpBridgesAfterDestroyAbort( return; } const preparedSandbox = assertMcpDestroySnapshotCurrent(sandboxName, preparation.entries); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(preparedSandbox); + const providerRuntimeSelection = + preparation.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(preparedSandbox); const destroyPreparedAt = preparedSandbox.mcp?.destroyPreparedAt ?? nowIso(); const cleared = registry.updateSandbox(sandboxName, { mcp: { @@ -261,6 +293,7 @@ export async function restoreMcpBridgesAfterDestroyAbort( }); await restoreExistingMcpBridgeRuntime(sandboxName, preparation.entries, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); } catch (error) { let markerRestoreFailure = ""; @@ -303,10 +336,10 @@ export async function finalizeMcpBridgesAfterSandboxDelete( const entries = preparation.entries; if (entries.length === 0) return; - await ensureSandboxGatewaySelected(sandboxName); - const sandbox = assertMcpDestroySnapshotCurrent(sandboxName, entries); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + const providerRuntimeSelection = + preparation.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); if (!sandbox.mcp?.destroyPendingAt) { const marked = registry.updateSandbox(sandboxName, { mcp: { diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts index c9dc10d4c57..3e3c26dc402 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts @@ -11,7 +11,10 @@ import { } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; -import { getMcpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; +import { + getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, +} from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; import { sleepMcpBridgeRetry } from "./mcp-bridge/timing"; @@ -34,6 +37,7 @@ export interface HermesMcpReconciliationOptions { entries?: readonly McpBridgeEntry[]; managedServerNames?: readonly string[]; credentialRevisions?: ReadonlyMap; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } export function hermesMcpReconciliationRemediationLines(sandboxName: string): readonly string[] { @@ -162,7 +166,8 @@ export function inspectHermesMcpRuntimeIntent( managedServerNames, options.credentialRevisions, ); - const runtimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + const runtimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); let result: ReturnType; try { result = runOpenshellProviderCommand(buildInspectArgs(sandboxName, JSON.stringify(payload)), { 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-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-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 6ba9bf46ee9..27e94b489d5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -6,7 +6,10 @@ 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"; @@ -51,11 +54,7 @@ export type McpProviderAttachmentInspection = { error?: string; }; -export type McpProviderInspectionRuntimeSelection = { - gatewayName: string; - localTlsDir?: string; - workspace: string; -}; +export type McpProviderInspectionRuntimeSelection = OpenShellRuntimeSelection; const GATEWAY_CLIENT_TLS_FILES = ["ca.crt", "client/tls.crt", "client/tls.key"] as const; 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 969dc244573..546637d0589 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -35,6 +35,12 @@ import { } 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({}); @@ -139,12 +145,9 @@ Provider: addedAt: "2026-08-19T00:00:00.000Z", }; - expect(() => - assertMcpProviderRecoverable(entry, { - gatewayName: "nemoclaw-8080", - workspace: "default", - }), - ).toThrow(/legacy generic profile.*cannot bind to an MCP endpoint/); + expect(() => assertMcpProviderRecoverable(entry, runtimeSelection)).toThrow( + /legacy generic profile.*cannot bind to an MCP endpoint/, + ); }); it("republishes an exact provider only after policy binding without reading its credential", () => { @@ -456,10 +459,7 @@ alpha-mcp-slack generic 1 0 }; expect(() => - assertNoAttachedProviderCredentialCollisions("alpha", [entry], { - gatewayName: "nemoclaw-8080", - workspace: "default", - }), + assertNoAttachedProviderCredentialCollisions("alpha", [entry], runtimeSelection), ).toThrow("MCP server 'example' has no complete authenticated credential binding"); expect(() => assertNoRegisteredProviderCredentialCollisions([entry], { @@ -646,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"); @@ -655,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: "" }); @@ -669,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/); }); @@ -693,6 +694,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ); @@ -723,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); }); @@ -747,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"); @@ -774,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/); }); @@ -798,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(); }); @@ -821,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); }); @@ -852,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); @@ -884,6 +889,7 @@ alpha-mcp-slack generic 1 0 policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }, + runtimeSelection, { refreshAfterObservedAbsence }, ); } catch (error) { @@ -919,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"); @@ -948,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); @@ -971,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] ?? ""; @@ -979,6 +987,7 @@ alpha-mcp-slack generic 1 0 expect(proofCommand).not.toContain("base64 -d"); expect(exec).toHaveBeenCalledWith("alpha", proofCommand, undefined, { allowLocalDockerFallback: false, + runtimeSelection, }); }); @@ -1000,16 +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-rebuild-exec-unavailable.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts index 887ef25869f..97d35e727cc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -13,6 +13,7 @@ import { import { assertNoProviderCredentialCollisions, getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, } from "./mcp-bridge-provider"; import { @@ -28,6 +29,7 @@ import { assertAuthenticatedBridgeEntry, validateSandboxName } from "./mcp-bridg type ReadOnlyValidationSnapshot = { providerByServer: Map; + runtimeSelection: McpProviderInspectionRuntimeSelection; targetsByServer: Map; }; @@ -44,6 +46,7 @@ export interface ExecUnavailableMcpRebuildPreparation { scrubbedAdapterEntries: McpBridgeEntry[]; revalidateBeforeDelete: () => Promise; assertDeleteEdgeUnchanged: () => void; + runtimeSelection: McpProviderInspectionRuntimeSelection; } function assertUniqueMcpOwnership(entries: readonly McpBridgeEntry[]): void { @@ -128,15 +131,29 @@ 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 - // 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); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection( + 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. Refusing to continue on a different target.`, + ); + } + 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, providerRuntimeSelection); + } const providerByServer = new Map(); const targetsByServer = new Map(); @@ -150,7 +167,7 @@ async function inspectReadOnlyRecoveryState( targetsByServer.set(entry.server, targetFingerprint(target)); } assertNoProviderCredentialCollisions(sandboxName, entries, providerRuntimeSelection); - return { providerByServer, targetsByServer }; + return { providerByServer, runtimeSelection: providerRuntimeSelection, targetsByServer }; } function assertValidationSnapshotCurrent( @@ -163,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. Refusing to delete the still-live sandbox; retry after its target and provider state are stable.` + : `Sandbox MCP gateway authority changed after host-side rebuild preflight. Refusing to delete the still-live sandbox; retry after its gateway state is stable.`, ); } } @@ -211,7 +234,11 @@ async function revalidateBeforeDelete( expectedAgentName, expectedAdapter, ); - const currentValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); + const currentValidation = await inspectReadOnlyRecoveryState( + sandboxName, + expectedEntries, + expectedValidation.runtimeSelection, + ); assertValidationSnapshotCurrent(expectedEntries, expectedValidation, currentValidation); } @@ -225,14 +252,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 eba80c3c7cc..184eb2f0d2b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -30,6 +30,7 @@ import { assertNoRegisteredProviderCredentialCollisions, detachProvider, getMcpProviderInspectionRuntimeSelection, + type McpProviderInspectionRuntimeSelection, preflightMcpEntryTargets, waitForDetachedMcpCredential, } from "./mcp-bridge-provider"; @@ -58,6 +59,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 { @@ -82,9 +85,11 @@ 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)) { throw new McpBridgeError( @@ -97,11 +102,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", @@ -114,9 +127,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) { @@ -124,7 +141,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 }; } /** @@ -135,20 +152,23 @@ 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); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection( - getSandboxOrThrow(sandboxName), - ); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); for (const entry of entries) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } @@ -162,14 +182,17 @@ export async function prepareMcpBridgesForAbsentSandboxRebuild( 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: [], @@ -178,10 +201,14 @@ export async function prepareMcpBridgesForRebuild( }; } await preflightMcpEntryTargets(entries); - await ensureSandboxGatewaySelected(sandboxName); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); for (const entry of entries) assertGeneratedPolicyMutationSafe(sandboxName, entry); - assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); + assertMcpAdapterTeardownRuntimeCapabilities( + sandboxName, + sandbox, + entries, + providerRuntimeSelection, + ); for (const entry of entries) { assertMcpProviderRecoverable(entry, providerRuntimeSelection); } @@ -193,6 +220,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( @@ -208,13 +236,22 @@ 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) { @@ -232,13 +269,17 @@ export async function prepareMcpBridgesForRebuild( `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; @@ -246,6 +287,7 @@ export async function prepareMcpBridgesForRebuild( try { await restoreExistingMcpBridgeRuntime(sandboxName, removedPolicies, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (rollbackError) { @@ -255,7 +297,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( @@ -269,8 +318,13 @@ export async function prepareMcpBridgesForRebuild( detachedProviderEntries: detached, scrubbedAdapterEntries: scrubbedAdapters, policyHandoff, + runtimeSelection: providerRuntimeSelection, revalidateBeforeDelete: async () => { - assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); + assertMcpTeardownPolicyUnchanged( + sandboxName, + expectedTeardownPolicy, + providerRuntimeSelection, + ); }, }; } @@ -279,14 +333,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; @@ -294,6 +353,7 @@ export async function reattachMcpProvidersAfterRebuildAbort( try { await restoreExistingMcpBridgeRuntime(sandboxName, entries, { lifecyclePhase: "teardown-rollback", + runtimeSelection: providerRuntimeSelection, }); runtimeRestored = true; } catch (error) { @@ -301,7 +361,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("; ")); @@ -311,6 +378,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); @@ -323,5 +391,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 cebe0bae1f8..fe26f5bcc80 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -215,8 +215,8 @@ 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; @@ -320,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, @@ -343,6 +348,7 @@ async function removeMcpBridgeUnlocked( (candidate) => candidate.server !== server, ), managedServerNames: sandbox.mcp?.managedServerNames, + runtimeSelection: providerRuntimeSelection, }); } } catch (error) { @@ -355,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); @@ -393,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; } 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 e2df9ae4d8c..32d6550e14c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -19,6 +19,7 @@ import { getMcpProviderInspectionRuntimeSelection, refreshMcpProviderEnvironment, type McpCredentialRevisionObservation, + type McpProviderInspectionRuntimeSelection, type McpProviderInspection, observeMcpCredentialRevision, preflightMcpEntryTargets, @@ -79,7 +80,11 @@ 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") { + assertHermesMcpRuntimeIntent(sandboxName, { + runtimeSelection: providerRuntimeSelection, + }); + } console.log(` No MCP servers for sandbox '${sandboxName}'.`); return; } @@ -99,10 +104,15 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P .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(); @@ -123,9 +133,14 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P for (const entry of missingProviderEntries) { 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. @@ -143,14 +158,21 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P // credentials. The temporary policy cannot bind the provider until an // endpointless profile is attached. ensureMcpBridgeProviderProfile(providerRuntimeSelection); - applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false }); + 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, + ); } }, }); @@ -175,18 +197,26 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P ); } attachProvider(sandboxName, entry, providerRuntimeSelection); - applyGeneratedPolicy(sandboxName, entry, target); + applyGeneratedPolicy(sandboxName, entry, target, { + runtimeSelection: providerRuntimeSelection, + }); refreshMcpProviderEnvironment(entry, providerRuntimeSelection); const entryAdapter = (entry.adapter as AgentMcpAdapter | undefined) ?? adapter; - const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, { - ...(providerResult.action === "updated" - ? { previousRevision: previousCredentialRevision } - : {}), - }); + const credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + { + ...(providerResult.action === "updated" + ? { previousRevision: previousCredentialRevision } + : {}), + }, + ); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, entryAdapter, entry, + providerRuntimeSelection, adapterEnvValues, credentialRevision, { replaceExisting: true }, @@ -198,7 +228,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( @@ -207,6 +241,7 @@ export async function restoreExistingMcpBridgeRuntime( options: { lifecyclePhase?: "active-mutation" | "teardown-rollback"; applyPolicy?: boolean; + runtimeSelection?: McpProviderInspectionRuntimeSelection; } = {}, ): Promise { if (entries.length === 0) return; @@ -215,18 +250,29 @@ export async function restoreExistingMcpBridgeRuntime( if (options.lifecyclePhase !== "teardown-rollback") { assertMcpCredentialBoundaryRuntimeVersion(); } - await ensureSandboxGatewaySelected(sandboxName); const sandbox = getSandboxOrThrow(sandboxName); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + 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) { @@ -249,19 +295,27 @@ export async function restoreExistingMcpBridgeRuntime( if (options.applyPolicy !== false) { applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { bindCredential: false, + runtimeSelection: providerRuntimeSelection, }); } 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, providerRuntimeSelection); - const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry); + const credentialRevision = waitForAttachedMcpCredential( + sandboxName, + entry, + providerRuntimeSelection, + ); registerAgentAdapterAtCurrentCredentialRevision( sandboxName, adapter, entry, + providerRuntimeSelection, {}, credentialRevision, { @@ -275,6 +329,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..e8250bd9e15 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..08b998f2986 100644 --- a/src/lib/actions/sandbox/mcp-bridge-state.ts +++ b/src/lib/actions/sandbox/mcp-bridge-state.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { type AgentDefinition, type AgentMcpAdapter, loadAgent } from "../../agent/defs"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; @@ -234,10 +235,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( 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 34d31c74069..27fab62b618 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts @@ -28,18 +28,31 @@ afterEach(() => { }); describe("cross-agent MCP status boundaries", testTimeoutOptions(15_000), () => { - it("pins provider diagnostics to the recorded gateway and workspace (#10514)", () => { + 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") { @@ -77,6 +90,7 @@ registry.registerSandbox({ 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", @@ -84,7 +98,7 @@ registry.registerSandbox({ } } }, }); require("./src/lib/actions/sandbox/mcp-bridge-status.js").statusMcpBridge("alpha", "github").then( - () => process.stdout.write(JSON.stringify(providerEnvironments)), + () => process.stdout.write(JSON.stringify({ providerEnvironments, policyEnvironments })), (error) => process.stderr.write(error.stack || error.message, () => process.exit(1)), ); `; @@ -95,16 +109,32 @@ require("./src/lib/actions/sandbox/mcp-bridge-status.js").statusMcpBridge("alpha }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const environments = JSON.parse(result.stdout) as Array>; - expect(environments.length).toBeGreaterThan(0); + const payload = JSON.parse(result.stdout) as { + policyEnvironments: Array>; + providerEnvironments: Array>; + }; + expect(payload.providerEnvironments.length).toBeGreaterThan(0); expect( - environments.every( + 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", () => { @@ -147,6 +177,8 @@ processRecovery.executeSandboxCommand = () => { registry.registerSandbox({ name: "alpha", agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, mcp: { bridges: { fake: { server: "fake", agent: "openclaw", @@ -214,7 +246,12 @@ const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); process.env.HOME = ${JSON.stringify(home)}; const registry = require("./src/lib/state/registry.js"); 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 0463ecca9b5..de88ae8b60a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -25,6 +25,7 @@ import { providerMatchesCredential, providerShapeDetail, } from "./mcp-bridge-provider"; +import type { McpProviderInspectionRuntimeSelection } from "./mcp-bridge-provider-inspection"; import type { McpAttachedCredentialRevision, McpCredentialRevisionObservation, @@ -91,6 +92,7 @@ function getAdapterRegistration( sandboxName: string, adapter: AgentMcpAdapter | undefined, entry: McpBridgeEntry | undefined, + runtimeSelection: McpProviderInspectionRuntimeSelection, hermesReconciliation?: HermesMcpReconciliationResult, credentialRevision?: McpAttachedCredentialRevision, credentialObservationDetail?: string, @@ -119,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(); @@ -149,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( @@ -184,11 +188,12 @@ export async function statusMcpBridge( validateSandboxName(sandboxName); if (server !== undefined) validateMcpServerName(server); const sandbox = getSandboxOrThrow(sandboxName); - const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox); + const providerRuntimeSelection = + options.runtimeSelection ?? getMcpProviderInspectionRuntimeSelection(sandbox); const agent = getSandboxAgent(sandbox); const bridges = bridgeState(sandbox); if (Object.keys(bridges).length > 0) { - await ensureSandboxGatewaySelected(sandboxName); + await ensureSandboxGatewaySelected(sandboxName, providerRuntimeSelection); } const selectedEntry = server !== undefined && Object.hasOwn(bridges, server) ? bridges[server] : undefined; @@ -234,7 +239,10 @@ export async function statusMcpBridge( 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); } @@ -262,7 +270,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( @@ -288,7 +299,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) && @@ -351,6 +362,7 @@ export async function statusMcpBridge( sandboxName, support.adapter, entry, + providerRuntimeSelection, hermesReconciliation, credentialRevision, observationDetail, @@ -376,6 +388,7 @@ export async function statusMcpBridge( entry, support.adapter, readiness, + providerRuntimeSelection, credentialRevision, ) : undefined; @@ -394,7 +407,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 34f4c5ca6d7..f229b1f90a3 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, @@ -92,12 +93,14 @@ 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; } export async function addMcpBridge( sandboxName: string, options: McpBridgeAddOptions, -): Promise { +): Promise { return addMcpBridgeLifecycle(sandboxName, options); } @@ -115,15 +118,19 @@ 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: { runtimeSelection?: McpProviderInspectionRuntimeSelection } = {}, ): Promise { - return prepareMcpBridgesForDestroyLifecycle(sandboxName); + return prepareMcpBridgesForDestroyLifecycle(sandboxName, options); } export async function restoreMcpBridgesAfterDestroyAbort( @@ -143,33 +150,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[] } { @@ -200,12 +212,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]; @@ -321,9 +338,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/messaging-host-forward-lifecycle.ts b/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts index 96cf9088560..737750fd95c 100644 --- a/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts +++ b/src/lib/actions/sandbox/messaging-host-forward-lifecycle.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { + buildSelectedOpenShellSubprocessEnv, captureOpenshell, getOpenshellBinary, + type OpenShellRuntimeSelection, runOpenshell, } from "../../adapters/openshell/runtime"; import { CLI_NAME } from "../../cli/branding"; @@ -17,15 +19,41 @@ import { import { parseForwardList } from "../../state/sandbox-session"; import { classifyForwardHealthWithReachability, isLocalForwardReachable } from "./forward-health"; -function captureOpenShellOutput(args: string[], opts: Record = {}): string | null { - const result = captureOpenshell(args, { ...opts, ignoreError: true } as Parameters< - typeof captureOpenshell - >[1]); +function selectedOpenShellOptions( + runtimeSelection: OpenShellRuntimeSelection | undefined, + opts: Record = {}, +): Parameters[1] { + return { + ...opts, + ignoreError: true, + ...(runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}), + } as Parameters[1]; +} + +function captureOpenShellOutput( + args: string[], + opts: Record = {}, + runtimeSelection?: OpenShellRuntimeSelection, +): string | null { + const result = captureOpenshell(args, selectedOpenShellOptions(runtimeSelection, opts)); return result.status === 0 ? result.output : null; } -function getMessagingForwardHealth(sandboxName: string, port: number): true | false | "occupied" { - const output = captureOpenShellOutput(["forward", "list"], { ignoreError: true }); +function getMessagingForwardHealth( + sandboxName: string, + port: number, + runtimeSelection?: OpenShellRuntimeSelection, +): true | false | "occupied" { + const output = captureOpenShellOutput( + ["forward", "list"], + { ignoreError: true }, + runtimeSelection, + ); if (output === null) return false; const entries = parseForwardList(output); const health = classifyForwardHealthWithReachability(entries, sandboxName, String(port), () => @@ -44,10 +72,11 @@ function getMessagingForwardHealth(sandboxName: string, port: number): true | fa export function ensureMessagingHostForwardAfterRebuild( sandboxName: string, plan: SandboxMessagingPlan | null | undefined, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean { const forward = resolveMessagingHostForward(plan); if (!forward) return true; - const health = getMessagingForwardHealth(sandboxName, forward.port); + const health = getMessagingForwardHealth(sandboxName, forward.port, runtimeSelection); if (health === true) return true; if (health === "occupied") return false; return ensureMessagingHostForwardIfConfigured({ @@ -57,9 +86,18 @@ export function ensureMessagingHostForwardAfterRebuild( ensureAgentFixedForward( { runOpenshell: (args, opts = {}) => - runOpenshell(args, opts as Parameters[1]), - runCaptureOpenshell: captureOpenShellOutput, + runOpenshell( + args, + selectedOpenShellOptions(runtimeSelection, opts) as Parameters< + typeof runOpenshell + >[1], + ), + runCaptureOpenshell: (args, opts) => + captureOpenShellOutput(args, opts, runtimeSelection), openshellArgv: (args) => [getOpenshellBinary(), ...args], + ...(runtimeSelection + ? { openshellSpawnEnv: buildSelectedOpenShellSubprocessEnv(runtimeSelection) } + : {}), cliName: () => CLI_NAME, sleep: sleepSeconds, }, 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/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 ad054bad444..05582744d99 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"; @@ -62,7 +64,7 @@ import { type ManagedGatewayControlCompletion, parseManagedGatewayControlCompletion, printGatewayRestartFailure, - type RestartSandboxGatewayOptions, + type RestartSandboxGatewayOptions as BaseRestartSandboxGatewayOptions, restartSandboxGatewayWithDeps, sandboxAgentName, withUnsupportedHermesPortableGatewayRestartFence, @@ -103,13 +105,25 @@ 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 }; +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; @@ -161,6 +175,21 @@ function getSandboxHealthProbeUrl(sandboxName: string): string { return resolveSandboxHealthProbeUrl(sandboxName); } +type OpenShellRunnerOptions = NonNullable[1]>; + +function selectedOpenShellOptions( + options: OpenShellRunnerOptions, + runtimeSelection?: OpenShellRuntimeSelection, +): OpenShellRunnerOptions { + return runtimeSelection + ? { + ...options, + env: buildOpenShellRuntimeSelectionEnv(buildSubprocessEnv(), runtimeSelection), + replaceEnv: true, + } + : options; +} + /** * Run a command inside the sandbox via SSH and return { status, stdout, stderr }. * Returns null if SSH config cannot be obtained. @@ -168,13 +197,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, + }, ); } @@ -210,14 +252,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 } : {}), + }, ); } @@ -352,12 +402,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 @@ -368,7 +428,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 { @@ -706,12 +772,14 @@ 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 agent = agentRuntime.getSessionAgent(sandboxName); @@ -770,16 +838,25 @@ function recoverSandboxProcesses( const relaunch = relaunchManagedSupervisorSessionImpl(sandboxName, { quiet, deps: { - runOpenshell, + runOpenshell: (args, options) => + runOpenshell(args, selectedOpenShellOptions(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, + selectedOpenShellOptions( + { + 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), @@ -795,6 +872,7 @@ function recoverSandboxProcesses( initialManagedHealthPassed: true, requireManagedProbe: true, timeoutSeconds: gatewayRecoveryTimeoutSeconds(agent), + runtimeSelection, managedProbeImpl: (name) => confirmRecoveredSandboxGatewayManaged(name, { requestGatewaySupervisorActionImpl: (name, action) => @@ -839,7 +917,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; @@ -847,7 +931,7 @@ function recoverSandboxProcesses( export function restartSandboxGateway( sandboxName: string, - { quiet = false, deps = {} }: RestartSandboxGatewayOptions = {}, + { quiet = false, deps = {}, runtimeSelection }: RestartSandboxGatewayOptions = {}, ): GatewayRestartResult { return withUnsupportedHermesPortableGatewayRestartFence(sandboxName, () => { return withTimerBoundShieldsMutationLock(sandboxName, "gateway restart", () => @@ -858,11 +942,18 @@ export function restartSandboxGateway( getSandbox: registry.getSandbox, resolveSandboxDashboardPort, requestGatewaySupervisorAction: executeGatewaySupervisorAction, - executeSandboxExecCommand, + 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, { @@ -870,12 +961,24 @@ export function restartSandboxGateway( deps.requestGatewaySupervisorAction ?? executeGatewaySupervisorAction, }), }), - 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, }, }), @@ -989,6 +1092,7 @@ type RecreatedSandboxOpenShellReadyOptions = { nowImpl?: () => number; sleepImpl?: (seconds: number) => void; timeoutSeconds?: number; + runtimeSelection?: OpenShellRuntimeSelection; }; function recreatedSandboxOpenShellReadinessFailureDetail( @@ -1089,12 +1193,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"], + selectedOpenShellOptions( + { + 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; @@ -1259,9 +1369,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; @@ -1385,6 +1498,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( isWsl: isWslOverride, onRecoveryFailureLayer, probeTiming, + runtimeSelection, }: { quiet?: boolean; requestGatewaySupervisorAction?: typeof executeGatewaySupervisorAction; @@ -1395,6 +1509,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( isWsl?: boolean; onRecoveryFailureLayer?: (layer: GatewayRestartFailureLayer | null, detail?: string) => void; probeTiming?: ProcessRecoveryProbeTiming; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ) { const measure = (stage: "processes" | "forward", operation: () => T): T => @@ -1410,7 +1525,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 }; } @@ -1431,7 +1548,12 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( secretBoundaryReason: enforcement.reason, }; } - const mcpRefusal = processRecoveryMcpReconciliationRefusal(sandboxName, true); + const mcpRefusal = processRecoveryMcpReconciliationRefusal( + sandboxName, + true, + undefined, + runtimeSelection, + ); if (mcpRefusal) return mcpRefusal; } if (running) { @@ -1439,7 +1561,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) { @@ -1448,17 +1573,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 = [ @@ -1542,13 +1668,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 }, @@ -1596,6 +1722,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( requestGatewaySupervisorAction, requestPinnedGatewaySupervisorAction, relaunchManagedSupervisorSessionImpl, + runtimeSelection, onFailureLayer: (layer, detail) => { managedRecoveryFailureLayer = layer; managedRecoveryFailureDetail = detail; @@ -1653,6 +1780,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 @@ -1692,7 +1820,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( @@ -1726,6 +1861,7 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( beforeProbe: relaunch ? (timeoutMs) => confirmRelaunchedManagedHealth?.(timeoutMs) ?? null : undefined, + runtimeSelection, }; const readiness = waitForRecreatedSandboxOpenShellReadyImpl === waitForRecreatedSandboxOpenShellReady @@ -1776,13 +1912,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) { @@ -1799,13 +1941,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 }, @@ -1877,6 +2019,7 @@ export function checkAndRecoverSandboxProcesses( isWsl?: boolean; onRecoveryFailureLayer?: (layer: GatewayRestartFailureLayer | null, detail?: string) => void; probeTiming?: ProcessRecoveryProbeTiming; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ) { return withTimerBoundShieldsMutationLock(sandboxName, "gateway process recovery", () => diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index 4c0524c52b4..b3a2b73336e 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import type { WebSearchConfig } from "../../inference/web-search"; import type { SandboxMessagingPlan } from "../../messaging"; import { cleanupTempDir, secureTempFile } from "../../onboard/temp-files"; @@ -58,9 +59,11 @@ function bailForUnsafeOpenClawPluginProvenance(input: RebuildBackupPhaseInput): export function captureRebuildPolicySource( sandboxName: string, policySourcePath?: string, + runtimeSelection?: OpenShellRuntimeSelection, ): string | null { const policy = policyGet.getSandboxPolicy(sandboxName, { recordedGatewayOperation: "capture the live policy before sandbox replacement", + ...(runtimeSelection ? { runtimeSelection } : {}), }).yaml; if (!policy) return null; const resolvedPolicySourcePath = 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-artifact-drift.test.ts b/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts index 2822310e4a4..97ac8965734 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts @@ -34,7 +34,13 @@ describe("rebuildSandbox DCode flow: prepared artifact drift", () => { expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + undefined, + ); expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( harness.preparedDcodeBuildContext, ); @@ -61,7 +67,13 @@ describe("rebuildSandbox DCode flow: prepared artifact drift", () => { expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + undefined, + ); expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( harness.preparedDcodeBuildContext, ); diff --git a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts index a4d3c5f98fa..20d7489f6e1 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts @@ -117,9 +117,16 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { "alpha", [detached], [scrubbed], + undefined, ); expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + undefined, + ); }); }); diff --git a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts index 2ee4c632308..72099e67a88 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, @@ -71,6 +76,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; @@ -240,6 +246,7 @@ export function createDcodeRebuildOrchestrator( dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, + runtimeSelection, ) => { if (!scope.enabled) return { ok: true }; const replacement = scope.preparedReplacement; @@ -261,7 +268,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 +282,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-pre-delete-drift.test.ts b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts index 5dffdc1549a..4072b5fa59b 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts @@ -198,7 +198,13 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + undefined, + ); expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( harness.preparedDcodeBuildContext, ); @@ -225,7 +231,13 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + undefined, + ); expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( harness.preparedDcodeBuildContext, ); diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index a846b765488..f305a5dc3cb 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 { @@ -78,7 +79,8 @@ export type DcodeReplacementPreflightInput = { gatewayPort?: number; log(message: string): void; bail: DcodeRebuildPreflightBail; - checkGatewaySchema(): boolean; + checkGatewaySchema(runtimeSelection?: OpenShellRuntimeSelection): boolean; + runtimeSelection?: OpenShellRuntimeSelection; }; export type DcodeReplacementPreparationInput = DcodeReplacementPreflightInput & { @@ -172,6 +174,7 @@ export async function ensureDcodeRebuildTargetGatewaySelected( entry: RebuildSandboxEntry, log: (message: string) => void, bail: DcodeRebuildPreflightBail, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise { let gatewayName: string; try { @@ -183,6 +186,7 @@ export async function ensureDcodeRebuildTargetGatewaySelected( const recovery = await recoverNamedGatewayRuntime({ gatewayName, recoverableStates: ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"], + ...(runtimeSelection ? { runtimeSelection } : {}), }); const beforeState = recovery.before?.state ?? "unknown"; const afterState = recovery.after?.state ?? "unknown"; @@ -218,11 +222,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( @@ -583,8 +589,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 +610,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 +638,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 d1bad3d537d..aa51553fe91 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ ), listSandboxes: vi.fn(() => ({ sandboxes: [] })), prepareMcpForRebuild: vi.fn(), + resolveMcpPreparationRuntimeSelection: vi.fn(), reattachMcpAfterDeleteFailure: vi.fn(), removeSandboxRegistryEntryWithReceipt: vi.fn(() => null), waitUntil: vi.fn(), @@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => ({ runOpenshell: vi.fn( ( _args: string[], + _options?: Record, ): { status: number | null; stdout: string; @@ -69,6 +71,7 @@ vi.mock("./rebuild-flow-helpers", () => ({ vi.mock("./rebuild-mcp-phase", () => ({ prepareMcpForRebuild: mocks.prepareMcpForRebuild, reattachMcpAfterDeleteFailure: mocks.reattachMcpAfterDeleteFailure, + resolveMcpPreparationRuntimeSelection: mocks.resolveMcpPreparationRuntimeSelection, })); import { runRebuildDestroyPhase, waitForRebuildDeleteAbsence } from "./rebuild-destroy-phase"; @@ -283,8 +286,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 +321,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); @@ -314,8 +332,58 @@ 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); + }), + relockShieldsIfNeeded: vi.fn(() => true), + onDeleted: vi.fn(), + }), + ).rejects.toThrow("Rebuild delete target does not match the frozen OpenShell target"); + + expectNoSandboxDelete(mocks.runOpenshell); }); it.each([ @@ -379,6 +447,7 @@ describe("rebuild destroy phase", () => { "alpha", [{ server: "github" }], [], + undefined, ); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); expect(mocks.stopNimContainer).not.toHaveBeenCalled(); @@ -420,7 +489,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(); expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); @@ -429,11 +503,17 @@ describe("rebuild destroy phase", () => { it("retains read-only MCP ownership when sandbox deletion fails (#7062)", async () => { const revalidateBeforeDelete = vi.fn().mockResolvedValue(undefined); const entry = { server: "github" }; + const runtimeSelection = { + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/authority/tls", + }; mocks.prepareMcpForRebuild.mockResolvedValue({ entries: [entry], detachedProviderEntries: [], scrubbedAdapterEntries: [], revalidateBeforeDelete, + runtimeSelection, }); mocks.runOpenshell .mockReturnValueOnce({ status: 9, stdout: "", stderr: "delete failed" }) @@ -463,7 +543,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", + [], + [], + runtimeSelection, + ); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); expect(onDeleted).not.toHaveBeenCalled(); expect(mocks.stopNimContainer).not.toHaveBeenCalled(); @@ -472,7 +557,14 @@ describe("rebuild destroy phase", () => { expect(mocks.runOpenshell).toHaveBeenNthCalledWith( 2, ["sandbox", "get", "-g", "nemoclaw", "alpha"], - expect.any(Object), + expect.objectContaining({ + replaceEnv: true, + env: expect.objectContaining({ + OPENSHELL_GATEWAY: "nemoclaw", + OPENSHELL_WORKSPACE: "default", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + }), + }), ); }); @@ -1022,6 +1114,7 @@ describe("rebuild destroy phase", () => { "alpha", [{ providerName: "nemoclaw-mcp-alpha-github" }], [{ server: "github" }], + undefined, ); expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); expect(mocks.runOpenshell).not.toHaveBeenCalledWith( diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index 4755eb3dc24..80d10fe97aa 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime"; +import { + buildSelectedOpenShellSubprocessEnv, + 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 +45,13 @@ export interface RebuildDestroyPhaseInput { bail: RebuildBail; relockShieldsIfNeeded: (sandboxStillExists: boolean) => boolean; force?: boolean; + runtimeSelection?: OpenShellRuntimeSelection; validateAfterMcpPreparation?: ( preparation: McpRebuildPreparation, ) => Promise; - validateAtDeleteEdge?: () => RebuildDeleteValidationResult; + validateAtDeleteEdge?: ( + runtimeSelection?: OpenShellRuntimeSelection, + ) => RebuildDeleteValidationResult; cleanupDockerOrphanAfterDelete?: () => void; onDeleted: () => void; onDeleteStateAmbiguous?: () => void; @@ -79,6 +86,7 @@ interface RebuildDeleteAbsenceDeps { }; now?: () => number; sleep?: (milliseconds: number) => void; + runtimeSelection?: OpenShellRuntimeSelection; } const REBUILD_DELETE_ABSENCE_MAX_ATTEMPTS = 20; @@ -124,6 +132,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 +145,12 @@ export function waitForRebuildDeleteAbsence( includeStderr: true, includeStreams: true, timeout: timeoutMs, + ...(deps.runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(deps.runtimeSelection), + replaceEnv: true, + } + : {}), }); return probe; }); @@ -180,6 +197,7 @@ function reconcileFailedSandboxDelete( sandboxName: string, sandboxEntry: RebuildSandboxEntry, log: RebuildLog, + runtimeSelection?: OpenShellRuntimeSelection, ): PostDeleteReconciliation { let gatewayName: string; try { @@ -188,6 +206,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 +217,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}'.`); @@ -277,6 +305,7 @@ export async function runRebuildDestroyPhase( input.force === true, relockShieldsIfNeeded, bail, + ...(input.runtimeSelection ? [input.runtimeSelection] : []), ); return preparation; }, @@ -285,7 +314,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 { @@ -303,6 +334,7 @@ export async function runRebuildDestroyPhase( sandboxName, preparation.detachedProviderEntries, preparation.scrubbedAdapterEntries, + preparation.runtimeSelection, ); relockShieldsIfNeeded(true); bail( @@ -322,6 +354,25 @@ 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, + ); + relockShieldsIfNeeded(true); + bail( + mcpRecoveryFailure + ? `Rebuild delete target does not match the frozen OpenShell target; MCP provider recovery also failed: ${mcpRecoveryFailure}` + : "Rebuild delete target does not match the frozen OpenShell target.", + ); + return null; + } // Exec-unavailable recovery deliberately made no MCP mutation during // preparation. Re-prove target, policy, provider, and registry state while @@ -340,6 +391,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); relockShieldsIfNeeded(true); const detail = error instanceof Error ? error.message : String(error); @@ -359,6 +411,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); relockShieldsIfNeeded(true); bail( @@ -372,7 +425,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)}`); @@ -386,6 +439,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); relockShieldsIfNeeded(true); bail( @@ -403,13 +457,14 @@ export async function runRebuildDestroyPhase( // running sandbox is left without its MCP wiring. let sourcePresence: RebuildRecreateSourcePresence; try { - sourcePresence = recreateJournal.observeSourceForDelete(); + sourcePresence = recreateJournal.observeSourceForDelete(rebuildMcpRuntimeSelection); recreateJournal.markDeleting(); } catch (error) { const mcpRecoveryFailure = await reattachMcpAfterDeleteFailure( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); relockShieldsIfNeeded(true); const detail = error instanceof Error ? error.message : String(error); @@ -431,12 +486,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; @@ -449,6 +515,7 @@ export async function runRebuildDestroyPhase( sandboxName, rebuildDetachedMcpProviderEntries, rebuildScrubbedMcpAdapterEntries, + rebuildMcpRuntimeSelection, ); if (mcpRecoveryFailure) { console.error( @@ -484,7 +551,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.", @@ -498,7 +567,7 @@ export async function runRebuildDestroyPhase( return null; } try { - recreateJournal.confirmDeleted(); + recreateJournal.confirmDeleted(rebuildMcpRuntimeSelection); } catch (error) { console.error( " Sandbox delete was accepted, but the replacement journal could not confirm absence.", diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts index e092330e06c..ae1dd29d7d4 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts @@ -954,7 +954,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 3f1aa08a979..b5f6d69e933 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -39,6 +39,7 @@ import type { SandboxEntry } from "../../state/registry"; import { load as loadRegistry } from "../../state/registry/persistence"; import * as sandboxState from "../../state/sandbox"; import * as userManagedFilesProbe from "../../state/user-managed-files-probe"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { getReconciledSandboxGatewayState, printSandboxGatewayStateHint, @@ -580,10 +581,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 3e1a3b4719b..08eba432ab3 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -75,6 +75,11 @@ describe("rebuildSandbox flow: lifecycle", () => { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", }; + const mcpRuntimeSelection = { + gatewayName: "nemoclaw", + localTlsDir: "/tmp/nemoclaw-tls", + workspace: "default", + }; const completePolicy = [ "version: 1", "network_policies:", @@ -87,11 +92,12 @@ describe("rebuildSandbox flow: lifecycle", () => { ].join("\n"); const harness = createRebuildFlowHarness({ applyPreset: () => true, - sandboxEntry: {}, + sandboxEntry: { mcp: { bridges: { github: mcpEntry } } }, mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], policyHandoff: completePolicy, + runtimeSelection: mcpRuntimeSelection, }, onboard: (_session, options) => { innerBackupMarker = process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP; @@ -110,7 +116,10 @@ describe("rebuildSandbox flow: lifecycle", () => { "alpha", expect.objectContaining({ captureStateFile: expect.any(Function) }), ); - expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha"); + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith( + "alpha", + mcpRuntimeSelection, + ); expect(harness.prepareMcpBridgesForRebuildSpy.mock.invocationCallOrder[0]).toBeLessThan( harness.warnUnpreservedUserManagedFilesSpy.mock.invocationCallOrder[0], ); @@ -125,6 +134,7 @@ describe("rebuildSandbox flow: lifecycle", () => { recreateSandbox: true, authoritativeResumeConfig: true, autoYes: true, + runtimeSelection: mcpRuntimeSelection, }), ); expect(innerBackupMarker).toBe("1"); @@ -157,8 +167,13 @@ describe("rebuildSandbox flow: lifecycle", () => { expect(harness.session.steps.sandbox.status).toBe("pending"); expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith("alpha", harness.backupPath, { targetAgentType: "openclaw", + runtimeSelection: mcpRuntimeSelection, }); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith( + "alpha", + [mcpEntry], + mcpRuntimeSelection, + ); expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); expect(harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( "Preserving journaled source registry entry across sandbox recreation", @@ -174,16 +189,22 @@ describe("rebuildSandbox flow: lifecycle", () => { "alpha", "openclaw doctor --fix", 300_000, - { allowLocalDockerFallback: false }, + { allowLocalDockerFallback: false, runtimeSelection: mcpRuntimeSelection }, + ); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + mcpRuntimeSelection, ); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); expect(process.env.NEMOCLAW_SANDBOX_NAME).toBe(originalSandboxName); expect(harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( "rebuild completed", ); }); - it("keeps the original sandbox when the post-MCP OpenShell policy is unavailable", async () => { + it("pins delete-edge policy recapture to the frozen MCP target (#10514)", async () => { const policyDirectory = createHarnessTempDir("nemoclaw-rebuild-policy-cleanup-"); vi.spyOn(tempFiles, "secureTempFile").mockReturnValue( path.join(policyDirectory, "policy.yaml"), @@ -192,10 +213,16 @@ describe("rebuildSandbox flow: lifecycle", () => { server: "github", providerName: "nemoclaw-mcp-alpha-github", }; + const runtimeSelection = { + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/authority/tls", + }; const harness = createRebuildFlowHarness({ mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], + runtimeSelection, }, }); vi.mocked(policyGet.getSandboxPolicy) @@ -208,6 +235,10 @@ describe("rebuildSandbox flow: lifecycle", () => { ).rejects.toThrow("OpenShell policy became unavailable before sandbox deletion"); expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledOnce(); + expect(policyGet.getSandboxPolicy).toHaveBeenLastCalledWith("alpha", { + recordedGatewayOperation: "capture the live policy before sandbox replacement", + runtimeSelection, + }); expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).toHaveBeenCalledOnce(); expect(harness.onboardSpy).not.toHaveBeenCalled(); expectNoSandboxDelete(harness.runOpenshellSpy); @@ -370,7 +401,11 @@ describe("rebuildSandbox flow: lifecycle", () => { expect.objectContaining({ toolDisclosure: "direct" }), ); expect(harness.session.toolDisclosure).toBe("direct"); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith( + "alpha", + [mcpEntry], + { gatewayName: "nemoclaw", workspace: "default" }, + ); harness.registryUpdateSpy.mock.calls.forEach(([, update]) => { expect(update).not.toHaveProperty("toolDisclosure"); }); @@ -393,6 +428,7 @@ describe("rebuildSandbox flow: lifecycle", () => { expect.any(Object), false, "nemoclaw", + undefined, ); }); @@ -476,7 +512,11 @@ describe("rebuildSandbox flow: lifecycle", () => { expect(harness.session.compatibleEndpointReasoningEffort).toBe("high"); expect(process.env.NEMOCLAW_REASONING).toBe("false"); expect(process.env.NEMOCLAW_REASONING_EFFORT).toBe("low"); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith( + "alpha", + [mcpEntry], + { gatewayName: "nemoclaw", workspace: "default" }, + ); } finally { restoreEnv(); } diff --git a/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts b/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts index e6aff0e03f9..0720de0fe63 100644 --- a/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts @@ -511,6 +511,7 @@ describe("rebuildSandbox flow: recovery", () => { "alpha", [attached], undefined, + { gatewayName: "nemoclaw", workspace: "default" }, ); expect(harness.onboardSpy).not.toHaveBeenCalled(); }); @@ -554,7 +555,11 @@ describe("rebuildSandbox flow: recovery", () => { harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), ).resolves.toBeUndefined(); - expect(harness.ensureMessagingHostForwardAfterRebuildSpy).toHaveBeenCalledWith("alpha", plan); + expect(harness.ensureMessagingHostForwardAfterRebuildSpy).toHaveBeenCalledWith( + "alpha", + plan, + undefined, + ); expect( harness.ensureMessagingHostForwardAfterRebuildSpy.mock.invocationCallOrder[0], ).toBeGreaterThan(harness.onboardSpy.mock.invocationCallOrder[0]); @@ -587,7 +592,13 @@ describe("rebuildSandbox flow: recovery", () => { expect(output).toContain("State restore was incomplete"); expect(output).toContain("Mutable config permissions were not verified"); expect(output).toContain("Mutable OpenClaw config hash was not refreshed"); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.relockSpy).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + true, + "nemoclaw", + undefined, + ); expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { agentVersion: "0.2.0", }); diff --git a/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts b/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts index 4dbb7e2c7d8..410a0ffdef3 100644 --- a/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts @@ -508,6 +508,7 @@ describe("rebuildSandbox flow: target image", () => { expect.any(Object), false, "nemoclaw", + undefined, ); expect(process.env.NEMOCLAW_SANDBOX_NAME).toBe(originalSandboxName); 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 267bec2f34e..8ff7476f231 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, @@ -118,6 +119,7 @@ export type RebuildRecreateOnboardOpts = { controlUiPort: number | null; targetGatewayName: string; targetGatewayPort: number; + runtimeSelection?: OpenShellRuntimeSelection; onboardLockAlreadyHeld: true; /** Target fingerprint of the replacement journal opened before deletion. */ recreateJournalTargetIntentFingerprint?: string; diff --git a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts index 9669a62e43e..baa319c04a6 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts @@ -8,6 +8,7 @@ const phaseMocks = vi.hoisted(() => ({ clearRecoveryBackup: vi.fn(), cleanupPolicySource: vi.fn(), findRecoveryBackup: vi.fn(), + getMcpRuntimeSelection: vi.fn(), openRecreateJournal: vi.fn(), recoverCronRestore: vi.fn(), runBackup: vi.fn(), @@ -74,6 +75,7 @@ vi.mock("./rebuild-restore-phase", () => ({ vi.mock("./rebuild-post-restore-phase", async (importOriginal) => ({ ...(await importOriginal()), recoverHermesCronRestore: phaseMocks.recoverCronRestore, + getMcpPreparationRuntimeSelection: phaseMocks.getMcpRuntimeSelection, runHermesCronRestoreTransaction: phaseMocks.runCronRestoreTransaction, runRebuildPostRestorePhase: phaseMocks.runPostRestore, })); @@ -105,6 +107,11 @@ describe("Hermes accepted replacement recovery", () => { backupPath: recoveryBackupPath, timestamp: "2026-08-28T00-00-00-000Z", }); + phaseMocks.getMcpRuntimeSelection.mockReturnValue({ + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/authority/tls", + }); phaseMocks.runRestore.mockReturnValue({ restoreSucceeded: true }); phaseMocks.runPostRestore.mockResolvedValue(undefined); phaseMocks.runPreflight.mockResolvedValue({ @@ -149,6 +156,7 @@ describe("Hermes accepted replacement recovery", () => { window: { relocked: false, wasLocked: false }, staleSandboxWasLocked: false, relock: relockShields, + bindRuntimeSelection: vi.fn(), }); phaseMocks.runBackup.mockReturnValue({ backupManifest: { @@ -219,6 +227,84 @@ describe("Hermes accepted replacement recovery", () => { ); }); + it("reuses one recorded MCP target while accepting and restoring a replacement (#10514)", async () => { + const runtimeSelection = { + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/authority/tls", + }; + phaseMocks.getMcpRuntimeSelection.mockReturnValue(runtimeSelection); + phaseMocks.runPreflight.mockResolvedValue({ + ...(await phaseMocks.runPreflight.getMockImplementation()!()), + sandboxEntry: { + name: "alpha", + mcp: { bridges: { github: { server: "github" } } }, + }, + }); + phaseMocks.openRecreateJournal.mockImplementation((input) => ({ + id: "journal-1", + acceptedTarget: true, + sourceConfirmedAbsent: true, + gatewayAuthority, + targetGeneration: "generation-1", + targetIntentFingerprint: "intent-1", + runtimeSelection: input.resolveRuntimeSelection(), + completeAcceptedTarget, + markDeleting: vi.fn(), + observeSourceForDelete: vi.fn(), + confirmDeleted: vi.fn(), + })); + + await expect( + rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(phaseMocks.getMcpRuntimeSelection).toHaveBeenCalledOnce(); + expect(phaseMocks.runPostRestore).toHaveBeenCalledWith( + expect.objectContaining({ + mcpRuntimeSelection: runtimeSelection, + }), + ); + }); + + it("carries an interrupted MCP journal target into continued deletion (#10514)", async () => { + const runtimeSelection = { + gatewayName: "nemoclaw", + workspace: "default", + localTlsDir: "/authority/tls", + }; + const preflightResult = await phaseMocks.runPreflight.getMockImplementation()!(); + phaseMocks.runPreflight.mockResolvedValue({ + ...preflightResult, + sandboxEntry: { + name: "alpha", + mcp: { bridges: { github: { server: "github" } } }, + }, + }); + phaseMocks.openRecreateJournal.mockReturnValue({ + id: "journal-1", + acceptedTarget: false, + sourceConfirmedAbsent: false, + gatewayAuthority, + targetGeneration: "generation-1", + targetIntentFingerprint: "intent-1", + runtimeSelection, + completeAcceptedTarget, + markDeleting: vi.fn(), + observeSourceForDelete: vi.fn(), + confirmDeleted: vi.fn(), + }); + phaseMocks.runDestroy.mockRejectedValue(new Error("stop after selected destroy input")); + + await expect( + rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("stop after selected destroy input"); + + expect(phaseMocks.runDestroy).toHaveBeenCalledWith( + expect.objectContaining({ runtimeSelection }), + ); + }); + it("retires both the unused current policy handoff and the recovered transaction handoff", async () => { const currentManifest = { backupPath, 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 5ec7cf3ef24..8f75ed5a59a 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts @@ -6,9 +6,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ executeSandboxCommand: vi.fn(), executeSandboxExecCommand: vi.fn(), + getSandbox: vi.fn(() => ({ + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, + name: "alpha", + })), prepareAbsent: vi.fn(), prepareExecUnavailable: vi.fn(), prepareLive: vi.fn(), + runtimeSelection: { gatewayName: "nemoclaw-9090", workspace: "default" } as const, +})); + +vi.mock("../../state/registry", () => ({ + getSandbox: mocks.getSandbox, +})); + +vi.mock("./mcp-bridge-provider", () => ({ + getMcpProviderInspectionRuntimeSelection: vi.fn(() => mocks.runtimeSelection), })); vi.mock("./mcp-bridge", () => ({ @@ -60,9 +75,12 @@ describe("forced rebuild MCP preparation", () => { expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection: mocks.runtimeSelection, }); - expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":"); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection: mocks.runtimeSelection, + }); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", mocks.runtimeSelection); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); expect(mocks.prepareLive).not.toHaveBeenCalled(); expect(relock).not.toHaveBeenCalled(); @@ -79,11 +97,14 @@ describe("forced rebuild MCP preparation", () => { emptyPreparation, ); - expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":"); + expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection: mocks.runtimeSelection, + }); expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection: mocks.runtimeSelection, }); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", mocks.runtimeSelection); expect(mocks.prepareLive).not.toHaveBeenCalled(); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); expect(relock).not.toHaveBeenCalled(); @@ -106,8 +127,9 @@ describe("forced rebuild MCP preparation", () => { expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection: mocks.runtimeSelection, }); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", mocks.runtimeSelection); expect(mocks.prepareLive).not.toHaveBeenCalled(); expect(relock).not.toHaveBeenCalled(); }); @@ -129,7 +151,7 @@ describe("forced rebuild MCP preparation", () => { emptyPreparation, ); - expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha"); + expect(mocks.prepareExecUnavailable).toHaveBeenCalledWith("alpha", mocks.runtimeSelection); expect(mocks.prepareLive).not.toHaveBeenCalled(); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); expect(relock).not.toHaveBeenCalled(); @@ -146,10 +168,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", mocks.runtimeSelection); + expect(mocks.executeSandboxCommand).toHaveBeenCalledWith("alpha", ":", { + runtimeSelection: mocks.runtimeSelection, + }); expect(mocks.executeSandboxExecCommand).toHaveBeenCalledWith("alpha", ":", undefined, { allowLocalDockerFallback: false, + runtimeSelection: mocks.runtimeSelection, }); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); expect(relock).toHaveBeenCalledWith(true); @@ -188,7 +213,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", mocks.runtimeSelection); expect(mocks.prepareExecUnavailable).not.toHaveBeenCalled(); expect(mocks.prepareAbsent).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.ts index fb1a52180fb..f590d4e529a 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.ts @@ -14,20 +14,44 @@ 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); +} + +export function resolveMcpPreparationRuntimeSelection( + sandboxName: string, +): ReturnType | undefined { + const sandbox = registry.getSandbox(sandboxName); + if (!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; } @@ -38,7 +62,11 @@ export async function prepareMcpForRebuild( force: boolean, relockShieldsIfNeeded: (sandboxStillExists: boolean) => boolean, bail: RebuildBail, + frozenRuntimeSelection?: McpProviderInspectionRuntimeSelection, ): Promise { + 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 @@ -51,10 +79,14 @@ 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 && + (!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) { relockShieldsIfNeeded(true); bail( @@ -66,8 +98,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) { relockShieldsIfNeeded(!staleRecovery); bail( @@ -81,9 +117,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); @@ -150,11 +192,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 (error) { 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 e8f7f865d03..41a0c293fa2 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"; @@ -33,6 +34,7 @@ import { } from "./rebuild-flow-helpers"; import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase"; import { + getMcpPreparationRuntimeSelection, type HermesCronRestoreIdentity, HermesCronRestoreIncompleteError, printHermesCronRestoreRecoveryCommand, @@ -280,8 +282,12 @@ async function rebuildSandboxUnlocked( return false; } }; - const capturePolicyHandoff = (): boolean => { - const capturedPath = captureRebuildPolicySource(sandboxName); + const capturePolicyHandoff = (runtimeSelection?: OpenShellRuntimeSelection): boolean => { + const capturedPath = captureRebuildPolicySource( + sandboxName, + undefined, + runtimeSelection, + ); if (!capturedPath) return false; try { return publishPolicyHandoff(fs.readFileSync(capturedPath, "utf8")); @@ -368,6 +374,7 @@ async function rebuildSandboxUnlocked( bail("Authoritative rebuild gateway readiness did not produce an authority handoff."); return; } + const mcpEntries = Object.values(sandboxEntry.mcp?.bridges ?? {}); const recreateJournal = openRebuildRecreateJournal({ target: { sandboxName, @@ -377,9 +384,16 @@ async function rebuildSandboxUnlocked( expectedGatewayAuthority, agentName: rebuildAgent || "openclaw", targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), + ...(mcpEntries.length > 0 + ? { + resolveRuntimeSelection: () => + getMcpPreparationRuntimeSelection(sandboxEntry), + } + : {}), log, onAuthorityRefusal: (lines) => bail(lines.join("\n")), }); + shieldsPhase.bindRuntimeSelection(recreateJournal.runtimeSelection); recreateOptions.rebuildGatewayAuthority = recreateJournal.gatewayAuthority; const rebuildRecoveryIdentity = { sandboxName, @@ -397,6 +411,12 @@ async function rebuildSandboxUnlocked( // replacement. Retire its journal and stop before the destroy phase so a // restart converges to that sandbox instead of deleting it. if (recreateJournal.acceptedTarget) { + if (mcpEntries.length > 0 && !recreateJournal.runtimeSelection) { + bail( + "The accepted MCP replacement is missing its recorded OpenShell runtime target.", + ); + return; + } const recoveryBackup = findRebuildRecoveryBackup(rebuildRecoveryIdentity); if (!recoveryBackup) { console.error(""); @@ -457,6 +477,9 @@ async function rebuildSandboxUnlocked( targetAgentType: rebuildAgent || "openclaw", targetImageIsCustom: Boolean(fromDockerfile), backupManifest: recoveryBackup, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), log, }); await runRebuildPostRestorePhase({ @@ -465,7 +488,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, backupWasForceSkipped: false, staleRecovery: false, @@ -502,6 +528,9 @@ async function rebuildSandboxUnlocked( recreateJournal, backupManifest: backup.backupManifest, force: normalized.force, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), log, bail, relockShieldsIfNeeded, @@ -532,6 +561,7 @@ async function rebuildSandboxUnlocked( providerReconfigure.provider, log, "Delete-edge", + preparation.runtimeSelection, ) : "missing"; if (providerReconfigure && providerRegistration !== "missing") { @@ -549,9 +579,10 @@ async function rebuildSandboxUnlocked( durableConfig.dcodeAutoApprovalMode, recoveryRecreate, recreateOptions.targetGatewayPort, + preparation.runtimeSelection, ); }, - validateAtDeleteEdge: () => { + validateAtDeleteEdge: (runtimeSelection) => { const validation = revalidateManagedWorkloadRebuildBeforeDelete( sandboxName, @@ -568,7 +599,7 @@ async function rebuildSandboxUnlocked( // path only after digest-verifying the policy handoff bound to the // prepared recovery manifest, so there is no live policy to recapture. if (staleRecovery) return validation; - return capturePolicyHandoff() + return capturePolicyHandoff(runtimeSelection) ? validation : { ok: false, @@ -637,6 +668,9 @@ async function rebuildSandboxUnlocked( targetAgentType: rebuildAgent || "openclaw", targetImageIsCustom: Boolean(fromDockerfile), backupManifest: backup.backupManifest, + ...(mcpPreparation.runtimeSelection + ? { runtimeSelection: mcpPreparation.runtimeSelection } + : {}), log, }); let hermesCronRestoreIdentity: HermesCronRestoreIdentity | undefined; @@ -674,6 +708,7 @@ async function rebuildSandboxUnlocked( messagingPlan, backupManifest: backup.backupManifest, mcpEntries: mcpPreparation.entries, + mcpRuntimeSelection: mcpPreparation.runtimeSelection, restoreSucceeded: restored.restoreSucceeded, hermesCronRestoreIdentity, backupWasForceSkipped: backup.backupWasForceSkipped, 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 c8d278073bc..f18b9df6922 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -102,6 +102,7 @@ describe("rebuild post-restore phase", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); function input() { @@ -150,6 +151,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(); @@ -217,9 +259,9 @@ describe("rebuild post-restore phase", () => { }); it("stops rebuild when OpenClaw messaging config reapply fails", async () => { - vi.mocked( - rebuildMessaging.reapplyMessagingManifestAfterOpenClawDoctor, - ).mockRejectedValue(new Error("config write failed")); + vi.mocked(rebuildMessaging.reapplyMessagingManifestAfterOpenClawDoctor).mockRejectedValue( + new Error("config write failed"), + ); const args = input(); await runRebuildPostRestorePhase(args); @@ -230,9 +272,7 @@ describe("rebuild post-restore phase", () => { expect(args.bail).toHaveBeenCalledWith( "OpenClaw messaging manifest config reapply failed during rebuild.", ); - expect(args.log).toHaveBeenCalledWith( - "Messaging manifest reapply failed: config write failed", - ); + expect(args.log).toHaveBeenCalledWith("Messaging manifest reapply failed: config write failed"); const output = vi.mocked(console.error).mock.calls.flat().join("\n"); expect(output).toContain("Messaging manifest config reapply failed after doctor"); }); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 2c086a53446..f6a2ab83de1 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -28,6 +28,7 @@ import { verifyHermesGatewayAfterStateRestoreForCronGate, } from "./rebuild-hermes-post-restore"; import { + getMcpPreparationRuntimeSelection, type McpRebuildPreparation, postRestoreCompleted, printMcpRestoreRecovery, @@ -45,6 +46,7 @@ export { recoverHermesCronRestore, runHermesCronRestoreTransaction, } from "./rebuild-hermes-post-restore"; +export { getMcpPreparationRuntimeSelection } from "./rebuild-mcp-phase"; const OPENCLAW_DOCTOR_TIMEOUT_MS = 5 * 60_000; @@ -81,6 +83,7 @@ export interface RebuildPostRestorePhaseInput { messagingPlan: SandboxMessagingPlan | null; backupManifest: RebuildBackupManifest; mcpEntries: McpRebuildPreparation["entries"]; + mcpRuntimeSelection?: McpRebuildPreparation["runtimeSelection"]; restoreSucceeded: boolean; hermesCronRestoreIdentity?: HermesCronRestoreIdentity; backupWasForceSkipped: boolean; @@ -130,6 +133,7 @@ export async function runRebuildPostRestorePhase( messagingPlan, backupManifest, mcpEntries, + mcpRuntimeSelection, restoreSucceeded, hermesCronRestoreIdentity, backupWasForceSkipped, @@ -181,7 +185,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) { @@ -200,10 +207,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)}`, @@ -216,7 +228,7 @@ export async function runRebuildPostRestorePhase( log("Restoring mutable OpenClaw config permissions after post-restore config writes"); let permRepair: ReturnType | null = null; try { - permRepair = shields.repairMutableConfigPerms(sandboxName); + permRepair = shields.repairMutableConfigPerms(sandboxName, mcpRuntimeSelection); } catch (error) { mutablePermsRepairUnverified = true; console.error( @@ -248,6 +260,7 @@ export async function runRebuildPostRestorePhase( const finalizedMessagingPlan = finalizePendingMessagingRemovalsAfterRestore( effectiveMessagingPlan, log, + mcpRuntimeSelection, ); if (finalizedMessagingPlan !== effectiveMessagingPlan && finalizedMessagingPlan) { if ( @@ -275,15 +288,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; } } @@ -293,12 +313,14 @@ export async function runRebuildPostRestorePhase( targetAgentName, hermesGatewayRestartState, hermesCronRestoreIdentity, + mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}, ) : { state: verifyHermesGatewayAfterStateRestore( sandboxName, targetAgentName, hermesGatewayRestartState, + mcpRuntimeSelection ? { runtimeSelection: mcpRuntimeSelection } : {}, ), replacementIdentity: undefined, }; @@ -374,14 +396,20 @@ export async function runRebuildPostRestorePhase( bail("Failed to re-apply shields lockdown."); return; } - 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 a4299c89f25..348610523d9 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -231,8 +231,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. 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..0e185a07eb3 100644 --- a/src/lib/actions/sandbox/rebuild-provider-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-provider-preflight.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { runOpenshell } from "../../adapters/openshell/runtime"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../../adapters/openshell/runtime-selection"; import { RD as _RD, R } from "../../cli/terminal-style"; import { hasBedrockRuntimeAwsAuthEnv, @@ -85,10 +89,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 1055485d79c..cee07ab07d2 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts @@ -528,6 +528,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 2a1ddf41c0f..d5e9e0297e3 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.ts @@ -20,6 +20,7 @@ import { type SandboxRecreateObserver, type SandboxRecreateTarget, } from "../../onboard/sandbox-recreate-probe"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { advanceSandboxRecreateTransaction, beginSandboxRecreateTransaction, @@ -258,9 +259,10 @@ export interface RebuildRecreateJournal { readonly gatewayAuthority: CheckpointGatewayAuthority; readonly targetGeneration: string; readonly targetIntentFingerprint: string; + readonly runtimeSelection?: OpenShellRuntimeSelection; markDeleting(): void; - observeSourceForDelete(): RebuildRecreateSourcePresence; - confirmDeleted(): void; + observeSourceForDelete(runtimeSelection?: OpenShellRuntimeSelection): RebuildRecreateSourcePresence; + confirmDeleted(runtimeSelection?: OpenShellRuntimeSelection): void; completeAcceptedTarget(): void; } @@ -326,6 +328,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). @@ -337,7 +341,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 @@ -366,8 +375,11 @@ export function openRebuildRecreateJournal( } const gatewayAuthority = checkpointGatewayAuthority(authority); const sourceEntry = registry.getSandbox(target.sandboxName); - const observation = observe(target); const active = onboardSession.loadSession()?.checkpoint?.sandboxRecreate ?? null; + const runtimeSelection = input.resolveRuntimeSelection + ? input.resolveRuntimeSelection() + : input.runtimeSelection; + const observation = observeTarget(runtimeSelection); const recovery = active ? planSandboxRecreateRecovery(active, observation, sourceEntry) : { action: "continue_delete" as const }; @@ -426,12 +438,13 @@ export function openRebuildRecreateJournal( gatewayAuthority, targetGeneration: transaction.targetGeneration, targetIntentFingerprint: transaction.targetIntentFingerprint, + ...(runtimeSelection ? { runtimeSelection } : {}), markDeleting: () => { if (sandboxRecreatePhaseReached(phase, "deleted")) return; advance("deleting"); }, - observeSourceForDelete: () => { - const current = observe(target); + observeSourceForDelete: (runtimeSelection) => { + const current = observeTarget(runtimeSelection); if (current.state === "missing") return "missing"; if ( !transaction.sourceLiveIdentityFingerprint || @@ -443,8 +456,8 @@ export function openRebuildRecreateJournal( } return "source"; }, - confirmDeleted: () => { - if (observe(target).state !== "missing") { + confirmDeleted: (runtimeSelection) => { + 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 b5c9d674337..f0ce6fd9709 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-phase.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-phase.ts @@ -282,6 +282,9 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): try { await rebuildOnboardDependencies.onboard({ ...recreateOptions, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), rebuildGatewayAuthority, rebuildPolicySourcePath, ...(rebuildsHermesSandbox && backupManifest?.preservedEnv diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index aabc79a066d..3c6cbbed27b 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 0f866edb651..c7889b3d942 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"; @@ -171,6 +172,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-shields-finally.test.ts b/src/lib/actions/sandbox/rebuild-shields-finally.test.ts index 7ee8bdfc421..6c0bf6cb6fb 100644 --- a/src/lib/actions/sandbox/rebuild-shields-finally.test.ts +++ b/src/lib/actions/sandbox/rebuild-shields-finally.test.ts @@ -76,6 +76,7 @@ describe("rebuild shields relock guard", () => { rebuildWindow.relocked = true; return true; }); + const bindRuntimeSelection = vi.fn(); beforeEach(() => { vi.clearAllMocks(); @@ -107,6 +108,7 @@ describe("rebuild shields relock guard", () => { window: rebuildWindow, staleSandboxWasLocked: false, relock: relockShields, + bindRuntimeSelection, }); phaseMocks.runBackup.mockImplementation(() => { throw new Error("unexpected backup exception"); diff --git a/src/lib/actions/sandbox/rebuild-shields-phase.test.ts b/src/lib/actions/sandbox/rebuild-shields-phase.test.ts index 2b362610ae3..79ff90495ab 100644 --- a/src/lib/actions/sandbox/rebuild-shields-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-shields-phase.test.ts @@ -45,8 +45,20 @@ describe("rebuild Shields phase", () => { expect(phaseMocks.openWindow).toHaveBeenCalledWith("alpha", false); expect(phase).toMatchObject({ window, staleSandboxWasLocked: false }); + const runtimeSelection = { + gatewayName: "recorded-gateway", + localTlsDir: "/authority/tls", + workspace: "default", + }; + phase?.bindRuntimeSelection(runtimeSelection); expect(phase?.relock(true)).toBe(true); - expect(phaseMocks.relockWindow).toHaveBeenCalledWith("alpha", window, true, "nemoclaw"); + expect(phaseMocks.relockWindow).toHaveBeenCalledWith( + "alpha", + window, + true, + "nemoclaw", + runtimeSelection, + ); expect(releaseOnboardLock).not.toHaveBeenCalled(); expect(bail).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/sandbox/rebuild-shields-phase.ts b/src/lib/actions/sandbox/rebuild-shields-phase.ts index c96c54c07c0..cd3b7cfbb72 100644 --- a/src/lib/actions/sandbox/rebuild-shields-phase.ts +++ b/src/lib/actions/sandbox/rebuild-shields-phase.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 type { RebuildBail } from "./rebuild-credential-preflight"; import { openRebuildShieldsWindowForState } from "./rebuild-flow-helpers"; import { type RebuildShieldsWindow, relockRebuildShieldsWindow } from "./rebuild-shields"; @@ -10,6 +11,7 @@ export interface RebuildShieldsPhaseResult { window: RebuildShieldsWindow; staleSandboxWasLocked: boolean; relock: (sandboxStillExists: boolean) => boolean; + bindRuntimeSelection: (runtimeSelection?: OpenShellRuntimeSelection) => void; } /** @@ -40,10 +42,20 @@ export function runRebuildShieldsPhase( bail("Failed to auto-unlock shields."); return null; } + let runtimeSelection: OpenShellRuntimeSelection | undefined; return { window, staleSandboxWasLocked, + bindRuntimeSelection: (selection) => { + runtimeSelection = selection; + }, relock: (sandboxStillExists: boolean) => - relockRebuildShieldsWindow(sandboxName, window, sandboxStillExists, CLI_NAME), + relockRebuildShieldsWindow( + sandboxName, + window, + sandboxStillExists, + CLI_NAME, + runtimeSelection, + ), }; } diff --git a/src/lib/actions/sandbox/rebuild-shields.ts b/src/lib/actions/sandbox/rebuild-shields.ts index e789938102d..421efc185d4 100644 --- a/src/lib/actions/sandbox/rebuild-shields.ts +++ b/src/lib/actions/sandbox/rebuild-shields.ts @@ -6,10 +6,15 @@ import { openBackupShieldsWindow, relockBackupShieldsWindow, } from "./backup-shields-window"; +import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime"; export type RebuildShieldsWindow = BackupShieldsWindow; -function rebuildShieldsWindowOptions(sandboxName: string, cliName: string) { +function rebuildShieldsWindowOptions( + sandboxName: string, + cliName: string, + runtimeSelection?: OpenShellRuntimeSelection, +) { return { operation: "rebuild backup", reason: "auto-unlock for rebuild", @@ -24,6 +29,7 @@ function rebuildShieldsWindowOptions(sandboxName: string, cliName: string) { // Only the replacement flow may use this descriptor-safe compatibility // transition; ordinary backup-all keeps the strict current protocol. allowLegacyHermesProtocol: true, + ...(runtimeSelection ? { runtimeSelection } : {}), }; } @@ -49,11 +55,12 @@ export function relockRebuildShieldsWindow( window: RebuildShieldsWindow, sandboxStillExists: boolean, cliName: string, + runtimeSelection?: OpenShellRuntimeSelection, ): boolean { return relockBackupShieldsWindow( sandboxName, window, sandboxStillExists, - rebuildShieldsWindowOptions(sandboxName, cliName), + rebuildShieldsWindowOptions(sandboxName, cliName, runtimeSelection), ); } 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/client.ts b/src/lib/adapters/openshell/client.ts index 80e83e9003c..a3c11de490c 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 "./runtime-selection"; +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..13e426d0cf3 100644 --- a/src/lib/adapters/openshell/command-argv.ts +++ b/src/lib/adapters/openshell/command-argv.ts @@ -4,6 +4,8 @@ // Namespace access keeps the resolver replaceable in focused command tests. import * as openshellResolveModule from "./resolve"; +export { buildSelectedOpenShellSubprocessEnv } from "./runtime-selection"; + export function resolveOpenshellBinary(): string { return openshellResolveModule.resolveOpenshell() ?? "openshell"; } 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..063474fb10d 100644 --- a/src/lib/adapters/openshell/gateway-drift.ts +++ b/src/lib/adapters/openshell/gateway-drift.ts @@ -30,6 +30,10 @@ import { } from "./client"; import { resolveOpenshell } from "./resolve"; import { captureOpenshell, getInstalledOpenshellVersionOrNull } from "./runtime"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "./runtime-selection"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts"; export type { GatewayReuseState, ManagedGatewayEndpointBinding }; @@ -99,6 +103,7 @@ export function isHostProcessGatewayDrift( export type GatewayDriftOptions = { gatewayName?: string; deps?: GatewayDriftDeps; + runtimeSelection?: OpenShellRuntimeSelection; timeoutMs?: number; }; @@ -215,19 +220,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 +275,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 +289,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 +433,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 +453,7 @@ export function getGatewayHostProcessDrift({ if (clusterImage) { const clusterActive = deps.isGatewayClusterActive?.(gatewayName) ?? - isGatewayClusterActiveForGateway(gatewayName, { timeoutMs }); + isGatewayClusterActiveForGateway(gatewayName, { runtimeSelection, timeoutMs }); if (clusterActive) { return null; } @@ -466,6 +488,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 +501,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 +526,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 +555,7 @@ export function detectOpenShellStateRpcResultIssue( { gatewayName = DEFAULT_GATEWAY_NAME, deps = {}, + runtimeSelection, timeoutMs = OPENSHELL_PROBE_TIMEOUT_MS, }: GatewayDriftOptions = {}, ): OpenShellStateRpcIssue | null { @@ -532,8 +567,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/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 9bf928772fd..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, })); diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index c39d1ff4b53..694eed13c85 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -3,19 +3,23 @@ import type { StdioOptions } from "node:child_process"; -import { buildSubprocessEnv } from "../../subprocess-env"; -import { OPENSHELL_OPERATION_TIMEOUT_MS, runOpenshell } from "./runtime"; +import { + buildOpenShellCommandEnv, + 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?: { - gatewayName: string; - localTlsDir?: string; - workspace: string; - }; + runtimeSelection?: OpenShellRuntimeSelection; stdio?: StdioOptions; timeout?: number; }; @@ -37,17 +41,7 @@ export function runOpenshellProviderCommand(args: string[], opts?: ProviderComma (entry): entry is [string, string] => entry[1] !== undefined, ), ); - const env = buildSubprocessEnv(explicitEnv); - if (runtimeSelection) { - 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; - } - } + const env = buildOpenShellCommandEnv(runtimeSelection, explicitEnv); const providerOpts = { ...runtimeOptions, env, diff --git a/src/lib/adapters/openshell/runtime-selection.ts b/src/lib/adapters/openshell/runtime-selection.ts new file mode 100644 index 00000000000..8c9af1ce60a --- /dev/null +++ b/src/lib/adapters/openshell/runtime-selection.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { buildSubprocessEnv } from "../../subprocess-env"; + +export type OpenShellRuntimeSelection = { + gatewayName: string; + localTlsDir?: string; + workspace: string; +}; + +/** Replace ambient OpenShell selectors with one authority-derived runtime target. */ +export function buildOpenShellRuntimeSelectionEnv( + baseEnv: Record, + runtimeSelection: OpenShellRuntimeSelection, +): Record { + const env = { ...baseEnv }; + 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; + } + return env; +} + +/** Build the standard subprocess environment for one selected OpenShell target. */ +export function buildSelectedOpenShellSubprocessEnv( + runtimeSelection: OpenShellRuntimeSelection, + extra?: Record, +): Record { + return buildOpenShellRuntimeSelectionEnv(buildSubprocessEnv(extra), runtimeSelection); +} + +/** Build a subprocess environment with optional explicit OpenShell target selection. */ +export function buildOpenShellCommandEnv( + runtimeSelection?: OpenShellRuntimeSelection, + extra?: Record, +): Record { + return runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(runtimeSelection, extra) + : buildSubprocessEnv(extra); +} diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index c24e656996b..f6536173316 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -17,6 +17,13 @@ import { OPENSHELL_OPERATION_TIMEOUT_MS, OPENSHELL_PROBE_TIMEOUT_MS } from "./ti type CommandArgs = string[]; +export { + buildOpenShellCommandEnv, + buildOpenShellRuntimeSelectionEnv, + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "./runtime-selection"; + export { buildOpenShellSubprocessEnv, OPENSHELL_OPERATION_TIMEOUT_MS }; export { classifyManagedGatewayEndpointBinding } from "./client"; export { runCaptureEx } from "../../runner"; @@ -25,6 +32,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 +125,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..1aa7af21e55 100644 --- a/src/lib/gateway-runtime-action.ts +++ b/src/lib/gateway-runtime-action.ts @@ -23,6 +23,7 @@ export { resolveGatewayName, resolveSandboxGatewayName }; type StartGatewayForRecoveryOptions = { gatewayName?: string; gatewayPort?: number; + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection; }; type LegacyOnboardModule = { @@ -81,6 +82,16 @@ export type NamedGatewayLifecycleState = { recoveryBlocked?: boolean; }; +function selectedOpenShellRuntimeOptions( + runtimeSelection?: openshellRuntime.OpenShellRuntimeSelection, +) { + if (!runtimeSelection) return {}; + return { + env: openshellRuntime.buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + }; +} + /** * Classify the lifecycle state of the named gateway (healthy_named, * named_unreachable, named_unhealthy, connected_other, or missing_named) from @@ -90,7 +101,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,7 +115,9 @@ 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 runtimeOptions = selectedOpenShellRuntimeOptions(opts.runtimeSelection); const status = gatewayRuntimeDependencies.captureOpenshell(["status"], { + ...runtimeOptions, timeout: OPENSHELL_PROBE_TIMEOUT_MS, ignoreError, includeStderr: ignoreError, @@ -109,6 +125,7 @@ export function getNamedGatewayLifecycleState( const gatewayInfo = gatewayRuntimeDependencies.captureOpenshell( ["gateway", "info", "-g", gatewayName], { + ...runtimeOptions, timeout: OPENSHELL_PROBE_TIMEOUT_MS, ignoreError, includeStderr: ignoreError, @@ -170,11 +187,21 @@ 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 runtimeOptions = selectedOpenShellRuntimeOptions(options.runtimeSelection); + 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 }; } @@ -195,11 +222,12 @@ export async function recoverNamedGatewayRuntime(options: RecoverNamedGatewayRun } gatewayRuntimeDependencies.runOpenshell(["gateway", "select", gatewayName], { + ...runtimeOptions, ignoreError: true, stdio: "ignore", timeout: OPENSHELL_OPERATION_TIMEOUT_MS, }); - let after = getNamedGatewayLifecycleState(gatewayName); + let after = getNamedGatewayLifecycleState(gatewayName, lifecycleOptions); if (after.recoveryBlocked) { return { recovered: false, before, after, attempted: true }; } @@ -217,17 +245,19 @@ 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], { + ...runtimeOptions, ignoreError: true, stdio: "ignore", timeout: OPENSHELL_OPERATION_TIMEOUT_MS, }); - after = getNamedGatewayLifecycleState(gatewayName); + 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/agent-fixed-forward.ts b/src/lib/onboard/agent-fixed-forward.ts index 760a263a53d..d735c1adf2d 100644 --- a/src/lib/onboard/agent-fixed-forward.ts +++ b/src/lib/onboard/agent-fixed-forward.ts @@ -15,6 +15,7 @@ export interface AgentFixedForwardDeps { runOpenshell(args: string[], opts?: Record): CommandResult; runCaptureOpenshell(args: string[], opts?: Record): string | null; openshellArgv(args: string[]): string[]; + openshellSpawnEnv?: NodeJS.ProcessEnv; cliName(): string; sleep(seconds: number): void; } @@ -42,6 +43,7 @@ export function ensureAgentFixedForward( stopForwardForSandbox(port); const startForward = buildDetachedForwardStartSpawn( deps.openshellArgv(["forward", "start", "--background", forwardTarget, sandboxName]), + deps.openshellSpawnEnv, ); const { ok, diagnostic } = runDetachedForwardStartWithRetries( (stdio) => { diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index e8e9bebf7ae..c5af67cd9c6 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, @@ -153,6 +154,48 @@ describe("authoritative rebuild gateway binding", () => { }); }); +describe("authoritative rebuild OpenShell runtime selection", () => { + it("replaces hostile ambient selectors for the 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 a59756802ce..1a5a5b709e0 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { findDashboardForwardOwner } from "./dashboard-port"; +import { + buildOpenShellRuntimeSelectionEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection"; import { resolveGatewayName } from "./gateway-binding"; import type { InferenceRouteState } from "./inference-route"; import type { PortProbeResult } from "./preflight"; @@ -35,6 +39,62 @@ export type AuthoritativeGatewayOptions = Pick< "authoritativeResumeConfig" | "targetGatewayName" | "targetGatewayPort" | "onboardLockAlreadyHeld" >; +type AuthoritativeRuntimeSelectionOptions = AuthoritativeGatewayOptions & + Pick; + +/** 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}'.`, + ); + } + + const previous = Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => + entry[0].startsWith("OPENSHELL_") && entry[1] !== undefined, + ), + ); + const baseEnv = Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + const selected = buildOpenShellRuntimeSelectionEnv(baseEnv, runtimeSelection); + for (const name of Object.keys(env)) { + if (name.startsWith("OPENSHELL_")) delete env[name]; + } + for (const [name, value] of Object.entries(selected)) { + if (name.startsWith("OPENSHELL_")) env[name] = value; + } + + 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); + }; +} + export type AuthoritativeRebuildPreflightOptions = Pick< OnboardOptions, "sandboxGpu" | "sandboxGpuDevice" | "noGpu" | "controlUiPort" | "allowDeferredN1xManagedVllm" 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/entry-options.ts b/src/lib/onboard/entry-options.ts index 6d2c98a7965..51dcce1019b 100644 --- a/src/lib/onboard/entry-options.ts +++ b/src/lib/onboard/entry-options.ts @@ -3,6 +3,7 @@ import { isNonInteractiveEnv } from "../core/non-interactive"; import { getNameValidationGuidance } from "../name-validation"; +import { beginAuthoritativeRebuildRuntimeSelectionScope } from "./authoritative-rebuild-target"; import { cliDisplayName } from "./branding"; import { canonicalPlaceholderKeys, @@ -10,6 +11,7 @@ import { parseExtraPlaceholderKeys, } from "./extra-placeholder-keys"; import { RESERVED_SANDBOX_NAMES } from "./sandbox-agent"; +import type { OnboardOptions } from "./types"; import { requireStationExpressResumeIntent, type StationExpressSessionLike, @@ -73,11 +75,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", @@ -255,7 +265,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/forward-start.ts b/src/lib/onboard/forward-start.ts index 85ef519a4a7..7b19aa16ffd 100644 --- a/src/lib/onboard/forward-start.ts +++ b/src/lib/onboard/forward-start.ts @@ -209,6 +209,7 @@ const SANDBOX_READY_MAX_RETRIES = 12; */ export function buildDetachedForwardStartSpawn( argv: readonly string[], + env?: NodeJS.ProcessEnv, ): DetachedForwardSpawnRunner { return ({ stdout, stderr }) => { // Preflight: the helper polls synchronously, so a Node `error` event @@ -225,6 +226,7 @@ export function buildDetachedForwardStartSpawn( const child = spawnChild(argv[0], argv.slice(1), { stdio: ["ignore", stdout, stderr], detached: true, + ...(env ? { env } : {}), }); // Swallow any belated `error` event so a race between accessSync and // execve does not crash the process via an unhandled emitter. 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..32e48d46ede 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 { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection"; 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,18 @@ 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 = runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}; + deps.runOpenshell(["gateway", "select", gatewayName], { + ...runtimeOptions, + ignoreError: true, + }); const recoveryWait = getGatewayHealthWaitConfig( 0, @@ -191,11 +215,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 +263,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 +285,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 +304,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..d2a820d9f0d 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 { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection"; 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,26 @@ 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 = runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}; + 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 +298,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 +317,14 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH return snapshot; } + const runtimeOptions = runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}; const selectResult = deps.runOpenshell(["gateway", "select", gatewayName], { + ...runtimeOptions, ignoreError: true, suppressOutput: true, }); @@ -295,7 +332,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..449f8429be1 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/runtime-selection"; 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..057c8430e41 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 { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../../adapters/openshell/runtime-selection"; + type RunResult = ReturnType; export interface GatewayRegistrationDeps { @@ -14,32 +19,73 @@ 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 = runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}; + 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 +97,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 +110,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 +118,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..b2bf54f5b2c 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 { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../../adapters/openshell/runtime-selection"; 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,16 @@ 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 }); + const runtimeOptions = runtimeSelection + ? { + env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), + replaceEnv: true, + } + : {}; + deps.runOpenshell(["gateway", "select", deps.gatewayName()], { + ...runtimeOptions, + ignoreError: true, + }); process.env.OPENSHELL_GATEWAY = deps.gatewayName(); return; } diff --git a/src/lib/onboard/sandbox-recreate-probe.ts b/src/lib/onboard/sandbox-recreate-probe.ts index 1290ef64fb1..bda307d976f 100644 --- a/src/lib/onboard/sandbox-recreate-probe.ts +++ b/src/lib/onboard/sandbox-recreate-probe.ts @@ -8,6 +8,10 @@ import { stripAnsi, } from "../adapters/openshell/client"; import { captureOpenshell } from "../adapters/openshell/runtime"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { parseSandboxPhase } from "../state/gateway"; import { @@ -97,12 +101,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 ea2a1f450d7..f14e22dabc3 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -138,6 +138,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/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..c75ac69ee8e 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, @@ -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/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/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 e36fa511abe..1f1abdfd1dc 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 { @@ -149,14 +152,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); } @@ -1087,10 +1110,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; } @@ -1358,12 +1384,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 ); @@ -1372,8 +1407,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; @@ -1385,7 +1423,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; } @@ -1394,6 +1432,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, @@ -1426,9 +1473,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, @@ -1440,7 +1488,9 @@ function runHermesProviderProtectionTransition( ); } runHermesRuntimeProviderStateMutation({ - environment: process.env, + environment: runtimeSelection + ? buildSelectedOpenShellSubprocessEnv(runtimeSelection) + : process.env, sandbox, sandboxName, configTarget, @@ -1450,7 +1500,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 @@ -1908,8 +1958,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) @@ -2179,11 +2234,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; } @@ -2235,6 +2291,7 @@ function withExpiredAutoRestoreDeadlineFence( operation: (allowInlineRecovery: boolean) => T, fallbackTakeoverToken?: string, assertCommandAvailable?: () => void, + runtimeSelection?: OpenShellRuntimeSelection, ): T { const completedMarker = inspectCompletedAbandonedAutoRestoreMarker(sandboxName); if (completedMarker) { @@ -2281,7 +2338,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)); }; @@ -2299,7 +2356,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. @@ -2395,13 +2452,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, ); } @@ -3371,11 +3438,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; @@ -3385,7 +3459,13 @@ function unlockAgentConfigUnderMutationLock( // an invalid same-posture provider plan. } } - runHermesProviderProtectionTransition(sandboxName, target, "mutable", "locked"); + runHermesProviderProtectionTransition( + sandboxName, + target, + "mutable", + "locked", + runtimeSelection, + ); verifyHermesProviderMutablePosture(sandboxName, target); return; } @@ -3663,6 +3743,7 @@ function unlockAgentConfigWithoutHostLock( rollbackLocked = getShieldsPosture(sandboxName, false).locked, allowLegacyHermesProtocol = false, cachedProtocol?: HermesShieldsProtocol, + runtimeSelection?: OpenShellRuntimeSelection, ): void { const target = ensureConfigHashSensitiveFile(rawTarget); const protocol = resolveHermesShieldsProtocol( @@ -3671,7 +3752,13 @@ function unlockAgentConfigWithoutHostLock( allowLegacyHermesProtocol, cachedProtocol, ); - return unlockAgentConfigUnderMutationLock(sandboxName, target, rollbackLocked, protocol); + return unlockAgentConfigUnderMutationLock( + sandboxName, + target, + rollbackLocked, + protocol, + runtimeSelection, + ); } function unlockAgentConfig( @@ -3680,6 +3767,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; @@ -3689,6 +3777,7 @@ function unlockAgentConfig( effectiveRollbackLocked, allowLegacyHermesProtocol, cachedProtocol, + runtimeSelection, ); }); } @@ -3726,7 +3815,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, @@ -3736,11 +3828,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, ); } @@ -3839,11 +3934,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 { @@ -3851,7 +3953,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( @@ -4158,6 +4266,7 @@ function synchronizeAutoRestoreTransition( expiredTimerRecovery?: boolean; retainTransition?: boolean; assertTakeoverAuthority?: () => void; + runtimeSelection?: OpenShellRuntimeSelection; } = {}, ): void { const transition = waitForShieldsDownForwardCommit( @@ -4186,6 +4295,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) { @@ -4248,7 +4358,10 @@ function prepareAutoRestoreTransitionTakeover( ); } -function synchronizeAutoRestoreWithShieldsDown(sandboxName: string): void { +function synchronizeAutoRestoreWithShieldsDown( + sandboxName: string, + runtimeSelection?: OpenShellRuntimeSelection, +): void { const timerMarker = readTimerMarker(sandboxName); if ( !timerMarker || @@ -4265,6 +4378,7 @@ function synchronizeAutoRestoreWithShieldsDown(sandboxName: string): void { { retainTransition: true, assertTakeoverAuthority: () => assertTimerMarkerGeneration(sandboxName, timerMarker), + runtimeSelection, }, ); } @@ -4296,6 +4410,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( @@ -4304,8 +4419,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( @@ -4314,6 +4435,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; @@ -4323,6 +4445,7 @@ function lockAgentConfig( effectiveRollbackLocked, allowLegacyHermesProtocol, cachedProtocol, + runtimeSelection, ); }); } @@ -4352,6 +4475,7 @@ interface ShieldsPolicySnapshotRestoreOptions { expiredTimerRecovery?: boolean; buildPolicySet?: typeof buildPolicySetCommand; runPolicySet?: typeof run; + runtimeSelection?: OpenShellRuntimeSelection; } type ShieldsPolicySnapshotRestoreResult = ReturnType; @@ -4426,8 +4550,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; @@ -4459,7 +4600,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); @@ -4477,11 +4618,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}`); @@ -4498,7 +4642,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) { @@ -4521,7 +4665,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) { @@ -4629,7 +4773,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) { @@ -4648,6 +4800,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 }; @@ -4685,6 +4838,7 @@ function recoverExpiredAutoRestoreInline( expiredTimerRecovery: true, retainTransition: true, assertTakeoverAuthority: () => assertTimerMarkerGeneration(sandboxName, marker), + runtimeSelection, }); } catch (error) { if (isDurableContainmentFailure(error)) throw error; @@ -4713,8 +4867,9 @@ function recoverExpiredAutoRestoreInline( ? { transitionProcessToken: recoveryProcessToken, ...(marker ? { deadlineAuthoritative: true, expiredTimerRecovery: true } : {}), + runtimeSelection, } - : {}, + : { runtimeSelection }, ); const nowIso = new Date().toISOString(); if (!activation.ok) { @@ -4778,6 +4933,7 @@ function recoverExpiredAutoRestoreInline( function recoverExpiredAutoRestoreGate( sandboxName: string, allowInlineRecovery = true, + runtimeSelection?: OpenShellRuntimeSelection, ): LoadedShieldsState { const state = loadShieldsState(sandboxName); if (!allowInlineRecovery) return state; @@ -4785,7 +4941,7 @@ function recoverExpiredAutoRestoreGate( return state; } - const recovery = recoverExpiredAutoRestoreInline(sandboxName, state); + const recovery = recoverExpiredAutoRestoreInline(sandboxName, state, runtimeSelection); if (!recovery.restored) return state; return loadShieldsState(sandboxName); } @@ -4811,6 +4967,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 = { @@ -4887,11 +5044,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); @@ -4900,9 +5060,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"); @@ -4963,11 +5124,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( @@ -4993,6 +5155,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( @@ -5003,6 +5166,7 @@ function failRecoveredHermesShieldsDown( state, allowLegacyHermesProtocol, "provider-state-mutation-v2", + runtimeSelection, ); if (completion.transition && rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, completion.transition.processToken); @@ -5038,6 +5202,7 @@ function startFreshShieldsDownTimer(input: { allowLegacyHermesProtocol: boolean; deferAutoRestoreWhileOwnerAlive: boolean; policyFile: string; + runtimeSelection?: OpenShellRuntimeSelection; }): FreshShieldsDownTimerStart { const { sandboxName, @@ -5049,6 +5214,7 @@ function startFreshShieldsDownTimer(input: { allowLegacyHermesProtocol, deferAutoRestoreWhileOwnerAlive, policyFile, + runtimeSelection, } = input; const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); const timerScript = path.join(__dirname, "timer.ts"); @@ -5099,6 +5265,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"); @@ -5179,13 +5352,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) { @@ -5196,11 +5370,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, @@ -5210,6 +5385,7 @@ function completeInterruptedShieldsDown( opts.allowLegacyHermesProtocol === true, error, opts.throwOnError, + opts.runtimeSelection, ); } if (!completion.alreadyCommitted) { @@ -5262,6 +5438,7 @@ function shieldsDownWithoutHostLock( retainedProviderTarget, "locked", "locked", + opts.runtimeSelection, ); } console.error(" Shields state is corrupt; refusing to unlock."); @@ -5272,7 +5449,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); @@ -5292,7 +5475,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.", ); @@ -5311,7 +5494,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 @@ -5357,7 +5545,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 = ""; } @@ -5461,6 +5660,7 @@ function shieldsDownWithoutHostLock( allowLegacyHermesProtocol: opts.allowLegacyHermesProtocol === true, deferAutoRestoreWhileOwnerAlive: opts.deferAutoRestoreWhileOwnerAlive === true, policyFile, + runtimeSelection: opts.runtimeSelection, }); } catch (error) { cleanupRuntimePolicyFile(); @@ -5513,6 +5713,7 @@ function shieldsDownWithoutHostLock( state, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); if (rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); @@ -5539,9 +5740,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(); @@ -5636,11 +5835,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 = @@ -5660,6 +5863,7 @@ function shieldsDownWithoutHostLock( state, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); transition = persistIncompleteShieldsDownPosture( sandboxName, @@ -5719,6 +5923,7 @@ function shieldsDownWithoutHostLock( state, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ); if (rollback.timerAuthorityRevoked) { clearShieldsDownTransition(sandboxName, transition.processToken); @@ -5779,6 +5984,7 @@ function shieldsDown( () => shieldsDownWithoutHostLock(sandboxName, effectiveOpts), processToken, opts.assertCommandAvailable, + opts.runtimeSelection, ); } catch (error) { return completeDeferredShieldsExit(error, opts.throwOnError === true); @@ -5797,13 +6003,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( @@ -5847,7 +6057,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, @@ -5955,6 +6171,7 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) true, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ), { confirm: hermesProviderLockConfirmation(sandboxName, target, protocol) }, ); @@ -6021,6 +6238,7 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) opts.allowLegacyHermesProtocol === true, target, protocol, + { runtimeSelection: opts.runtimeSelection }, ); if (!activation.ok) { const configLocked = @@ -6063,6 +6281,7 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) false, opts.allowLegacyHermesProtocol === true, protocol, + opts.runtimeSelection, ), { confirm: hermesProviderLockConfirmation(sandboxName, target, protocol) }, ); @@ -6138,6 +6357,7 @@ function shieldsUp(sandboxName: string, opts: ShieldsUpOpts = {}): void { () => shieldsUpWithoutHostLock(sandboxName, opts), undefined, opts.assertCommandAvailable, + opts.runtimeSelection, ); } catch (error) { return completeDeferredShieldsExit(error, opts.throwOnError === true); @@ -6425,8 +6645,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 d83a692c320..1d503cf24ac 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, createFailingCapabilityProbeResponse, createHermesShieldsProviderConsumerHarness, createRetainedUnlockSimulation, createTimerAuthorizationSender, createTransitionFailureForPosture, + commandFromCall, + 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; @@ -865,7 +748,8 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "crash retry", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( @@ -893,7 +777,8 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "dead-provider-owner", processToken, sandboxName: sandbox.name, - snapshotPath, snapshotPolicy, + snapshotPath, + snapshotPolicy, forwardPolicy, }), ); @@ -966,88 +851,6 @@ describe("legacy Hermes shields compatibility", () => { }, ); - it("completes timed DOWN bookkeeping after provider release removed the durable claim", () => { - const statePaths = requireSource("../state/paths.js") as typeof import("../state/paths"); - const stateDir = statePaths.resolveNemoclawStateDir(); - const processToken = "e".repeat(32); - const snapshotPath = path.join(stateDir, "shields-policy-after-provider-release.yaml"); - const transitionPath = path.join( - stateDir, - `shields-transition-${sandbox.name}-${processToken}.json`, - ); - fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); - const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); - const forwardPolicy = writeBoundForwardPolicy(stateDir, sandbox.name, processToken); - fs.writeFileSync( - path.join(stateDir, `shields-${sandbox.name}.json`), - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: "2026-08-09T00:05:00.000Z", - shieldsDownTimeout: 300, - shieldsDownReason: "post-release crash", - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, - }), - ); - fs.writeFileSync( - path.join(stateDir, `shields-timer-${sandbox.name}.json`), - JSON.stringify({ - pid: 4343, - sandboxName: sandbox.name, - snapshotPath, - restoreAt: new Date(Date.now() + 60_000).toISOString(), - processToken, - timerProcessStartIdentity: "live-timer-start", - allowLegacyHermesProtocol: false, - agentName: "hermes", - configPath: target.configPath, - configDir: target.configDir, - }), - ); - writeTimerAuthorizationProof(requireSource, sandbox.name); - fs.writeFileSync( - transitionPath, - JSON.stringify({ - version: 1, - phase: "preparing", - ownerPid: 4343, - ownerStartIdentity: "dead-post-release-owner", - processToken, - sandboxName: sandbox.name, - snapshotPath, snapshotPolicy, - forwardPolicy, - }), - ); - lifecycleGateSpy.mockReturnValue(false); - transitionSpy.mockReturnValue(null); - routeSpy.mockImplementation(() => { - expect(JSON.parse(fs.readFileSync(transitionPath, "utf-8")).phase).toBe("preparing"); - return { ok: true, attempts: 1, httpStatus: 200 }; - }); - auditSpy.mockImplementation(() => { - expect(JSON.parse(fs.readFileSync(transitionPath, "utf-8")).phase).toBe("active"); - }); - - shields.shieldsDown(sandbox.name, { throwOnError: true }); - - expect(supportSpy).toHaveBeenCalledTimes(1); - expect(transitionSpy).toHaveBeenCalledTimes(1); - expect(transitionSpy).toHaveBeenCalledWith( - expect.objectContaining({ target: "mutable", rollback: "mutable" }), - ); - expect(routeSpy).toHaveBeenCalledTimes(1); - expect(auditSpy).toHaveBeenCalledWith({ - action: "shields_down", - sandbox: sandbox.name, - timestamp: "2026-08-09T00:05:00.000Z", - timeout_seconds: 300, - reason: "post-release crash", - policy_applied: "permissive", - policy_snapshot: snapshotPath, - }); - expect(JSON.parse(fs.readFileSync(transitionPath, "utf-8")).phase).toBe("active"); - }); - it.each(forwardPolicyFailureFixtures)( "fails closed when the recovered forward policy is %s", (_failureMode, arrangeFailure, expectedError, assertSideEffects) => { @@ -1070,7 +873,8 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "invalid forward policy", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, }), ); const timerPath = path.join(stateDir, `shields-timer-${sandbox.name}.json`); @@ -1099,7 +903,8 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "dead-forward-owner", processToken, sandboxName: sandbox.name, - snapshotPath, snapshotPolicy, + snapshotPath, + snapshotPolicy, forwardPolicy, }), ); @@ -1287,7 +1092,8 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "timed mutable status", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, updatedAt: new Date().toISOString(), }), ); @@ -1316,7 +1122,8 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "timed-status-owner", processToken, sandboxName: sandbox.name, - snapshotPath, snapshotPolicy, + snapshotPath, + snapshotPolicy, forwardPolicy, }), ); @@ -1497,3 +1304,183 @@ describe("legacy Hermes shields compatibility", () => { }); }); } + +describe("Hermes Shields OpenShell runtime selection", () => { + let harness: ReturnType; + let auditSpy: MockInstance; + let lifecycleGateSpy: MockInstance; + let routeSpy: MockInstance; + let runCaptureSpy: MockInstance; + let runSpy: MockInstance; + let shields: typeof import("./index"); + let supportSpy: MockInstance; + let transitionSpy: MockInstance; + + beforeEach(() => { + harness = createHermesShieldsProviderConsumerHarness(requireSource); + ({ + auditSpy, + lifecycleGateSpy, + routeSpy, + runCaptureSpy, + runSpy, + shields, + supportSpy, + transitionSpy, + } = harness); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it("pins provider phase probes and operations 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", + }; + + shields.unlockAgentConfig(sandbox.name, target, true, false, undefined, runtimeSelection); + + const phaseCalls = runCaptureSpy.mock.calls.filter( + ([command]) => + Array.isArray(command) && command.includes("sandbox") && command.includes("list"), + ); + const selectedCall = { + gateway: runtimeSelection.gatewayName, + workspace: runtimeSelection.workspace, + localTlsDir: runtimeSelection.localTlsDir, + replaceEnv: true, + }; + expect(phaseCalls.length).toBeGreaterThan(0); + expect( + phaseCalls.map(([, options]) => ({ + gateway: options.env.OPENSHELL_GATEWAY, + workspace: options.env.OPENSHELL_WORKSPACE, + localTlsDir: options.env.OPENSHELL_LOCAL_TLS_DIR, + replaceEnv: options.replaceEnv, + })), + ).toEqual(Array(phaseCalls.length).fill(selectedCall)); + expect(transitionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ + OPENSHELL_GATEWAY: runtimeSelection.gatewayName, + OPENSHELL_WORKSPACE: runtimeSelection.workspace, + OPENSHELL_LOCAL_TLS_DIR: runtimeSelection.localTlsDir, + }), + }), + ); + }); + + it("keeps post-release DOWN bookkeeping on 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", + }; + const statePaths = requireSource("../state/paths.js") as typeof import("../state/paths"); + const stateDir = statePaths.resolveNemoclawStateDir(); + const processToken = "e".repeat(32); + const snapshotPath = path.join(stateDir, "shields-policy-after-provider-release.yaml"); + const transitionPath = path.join( + stateDir, + `shields-transition-${sandbox.name}-${processToken}.json`, + ); + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + const forwardPolicy = writeBoundForwardPolicy(stateDir, sandbox.name, processToken); + fs.writeFileSync( + path.join(stateDir, `shields-${sandbox.name}.json`), + JSON.stringify({ + shieldsDown: true, + shieldsDownAt: "2026-08-09T00:05:00.000Z", + shieldsDownTimeout: 300, + shieldsDownReason: "post-release crash", + shieldsDownPolicy: "permissive", + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, + }), + ); + fs.writeFileSync( + path.join(stateDir, `shields-timer-${sandbox.name}.json`), + JSON.stringify({ + pid: 4343, + sandboxName: sandbox.name, + snapshotPath, + restoreAt: new Date(Date.now() + 60_000).toISOString(), + processToken, + timerProcessStartIdentity: "live-timer-start", + allowLegacyHermesProtocol: false, + agentName: "hermes", + configPath: target.configPath, + configDir: target.configDir, + }), + ); + writeTimerAuthorizationProof(requireSource, sandbox.name); + fs.writeFileSync( + transitionPath, + JSON.stringify({ + version: 1, + phase: "preparing", + ownerPid: 4343, + ownerStartIdentity: "dead-post-release-owner", + processToken, + sandboxName: sandbox.name, + snapshotPath, + snapshotPolicy, + forwardPolicy, + }), + ); + lifecycleGateSpy.mockReturnValue(false); + transitionSpy.mockReturnValue(null); + routeSpy.mockImplementation((_sandboxName, options) => { + expect(JSON.parse(fs.readFileSync(transitionPath, "utf-8")).phase).toBe("preparing"); + options.run(["openshell", "sandbox", "exec", sandbox.name, "--", "true"], { + ignoreError: true, + suppressOutput: true, + timeout: 1000, + }); + return { ok: true, attempts: 1, httpStatus: 200 }; + }); + auditSpy.mockImplementation(() => { + expect(JSON.parse(fs.readFileSync(transitionPath, "utf-8")).phase).toBe("active"); + }); + + shields.shieldsDown(sandbox.name, { runtimeSelection, throwOnError: true }); + + expect(supportSpy).toHaveBeenCalledTimes(1); + expect(transitionSpy).toHaveBeenCalledTimes(1); + expect(transitionSpy).toHaveBeenCalledWith( + expect.objectContaining({ target: "mutable", rollback: "mutable" }), + ); + expect(routeSpy).toHaveBeenCalledTimes(1); + expect(runSpy).toHaveBeenCalledWith( + ["openshell", "sandbox", "exec", sandbox.name, "--", "true"], + expect.objectContaining({ + env: expect.objectContaining({ + OPENSHELL_GATEWAY: runtimeSelection.gatewayName, + OPENSHELL_WORKSPACE: runtimeSelection.workspace, + OPENSHELL_LOCAL_TLS_DIR: runtimeSelection.localTlsDir, + }), + replaceEnv: true, + }), + ); + expect(auditSpy).toHaveBeenCalledWith({ + action: "shields_down", + sandbox: sandbox.name, + timestamp: "2026-08-09T00:05:00.000Z", + timeout_seconds: 300, + reason: "post-release crash", + policy_applied: "permissive", + policy_snapshot: snapshotPath, + }); + expect(JSON.parse(fs.readFileSync(transitionPath, "utf-8")).phase).toBe("active"); + }); +}); 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/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"), @@ -202,45 +230,94 @@ function expectSuccessfulRestore(result: ReturnType): } describe("recreated OpenClaw state restore", () => { + 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-session.test.ts b/src/lib/state/sandbox-session.test.ts index 93c76143f8d..1e5cf7002ac 100644 --- a/src/lib/state/sandbox-session.test.ts +++ b/src/lib/state/sandbox-session.test.ts @@ -1,9 +1,10 @@ // 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 { classifySessionState, + createSystemDeps, type ForwardEntry, getActiveSandboxSessions, getForwardsForSandbox, @@ -13,6 +14,10 @@ import { type SessionDetectionDeps, } from "./sandbox-session"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("parseForwardList", () => { it("returns empty array for empty/null input", () => { expect(parseForwardList("")).toEqual([]); @@ -299,6 +304,54 @@ describe("classifySessionState", () => { }); 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 = { getForwardList: () => null, diff --git a/src/lib/state/sandbox-session.ts b/src/lib/state/sandbox-session.ts index 6d5226de67d..74c5463ff97 100644 --- a/src/lib/state/sandbox-session.ts +++ b/src/lib/state/sandbox-session.ts @@ -14,6 +14,10 @@ */ import { spawnSync } from "node:child_process"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection"; import { createOpenshellSandboxIdReader } from "../adapters/openshell/sandbox-identity"; import { openshellSandboxSshHost } from "../adapters/openshell/sandbox-ssh-host"; @@ -300,9 +304,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, @@ -323,12 +327,23 @@ function querySshProcesses(): string | null { * Create the default system deps for session detection. * Uses `openshell forward list` and `ps` (cross-platform) 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 { getForwardList: (): string | null => { try { - const result = spawnSync(openshellBinary, ["forward", "list"], { + const result = runCommand(openshellBinary, ["forward", "list"], { encoding: "utf-8", + ...(selectedEnv ? { env: selectedEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 5000, }); @@ -338,10 +353,14 @@ export function createSystemDeps(openshellBinary: string): SessionDetectionDeps return null; } }, - 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/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index b5092b68a7b..702001c7aba 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -35,7 +35,9 @@ import { isDeepStrictEqual } from "node:util"; import { spawnSync } from "child_process"; import { + buildSelectedOpenShellSubprocessEnv, captureSandboxSshConfigCommand, + type OpenShellRuntimeSelection, resolveOpenshellSandboxSshHost, } from "../adapters/openshell/client.js"; import { resolveOpenshell } from "../adapters/openshell/resolve.js"; @@ -268,6 +270,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 { @@ -275,6 +279,7 @@ interface InternalRestoreOptions { allowCustomImageWholeStateFileRestore?: true; discoverFreshOpenClawImagePluginInstalls?: true; freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; + runtimeSelection?: OpenShellRuntimeSelection; authority?: SnapshotRestoreAuthority; validateBeforeMutation?: () => void; } @@ -692,11 +697,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, }); @@ -704,6 +717,18 @@ export function getSshConfig(sandboxName: string): string | null { 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) { @@ -2058,6 +2083,7 @@ export function restoreRecreatedSandboxState( ? { discoverFreshOpenClawImagePluginInstalls: true } : {}), freshOpenClawImagePluginInstalls: options.freshOpenClawImagePluginInstalls, + ...(options.runtimeSelection ? { runtimeSelection: options.runtimeSelection } : {}), ...(options.authority ? { authority: options.authority } : {}), ...(options.validateBeforeMutation ? { validateBeforeMutation: options.validateBeforeMutation } @@ -2071,6 +2097,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"); @@ -2244,7 +2273,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) { @@ -2267,7 +2301,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 { @@ -2373,6 +2410,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, }); @@ -2396,6 +2434,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, @@ -2411,6 +2450,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, }); @@ -2434,6 +2474,7 @@ function restoreSandboxStateInternal( "ssh", [...sshArgs(configFile, sandboxName), usabilityCmd], { + ...(selectedSshEnv ? { env: selectedSshEnv } : {}), stdio: ["ignore", "pipe", "pipe"], timeout: 30000, }, @@ -2470,6 +2511,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 5786eaa3ae8..9d56d99c0f3 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", @@ -71,12 +83,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(); @@ -100,6 +114,7 @@ describe("probeUserManagedFiles", () => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); for (const dir of tempSshFiles) { try { @@ -114,10 +129,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, @@ -146,6 +163,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"])); @@ -256,3 +311,55 @@ 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"); + const runtimeSelection = { + gatewayName: "recorded-gateway", + workspace: "default", + localTlsDir: "/authority/tls", + }; + 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..ee88ba27670 100644 --- a/src/lib/state/user-managed-files-probe.ts +++ b/src/lib/state/user-managed-files-probe.ts @@ -4,6 +4,10 @@ import { spawnSync } from "child_process"; import { loadAgent } from "../agent/defs.js"; +import { + buildSelectedOpenShellSubprocessEnv, + type OpenShellRuntimeSelection, +} from "../adapters/openshell/runtime-selection.js"; import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; @@ -23,7 +27,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 +41,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 +73,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/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 57edcf44f60..c6b182cb18d 100644 --- a/test/agents/hermes/hermes-mcp-startup-probe.test.ts +++ b/test/agents/hermes/hermes-mcp-startup-probe.test.ts @@ -46,6 +46,10 @@ 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 }> = []; @@ -82,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); } 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({ diff --git a/test/helpers/destroy-flow-test-assertions.ts b/test/helpers/destroy-flow-test-assertions.ts index 3041e16674f..d6795ea6e6c 100644 --- a/test/helpers/destroy-flow-test-assertions.ts +++ b/test/helpers/destroy-flow-test-assertions.ts @@ -53,7 +53,7 @@ export function expectSuccessfulLiveDestroy(harness: DestroyHarness, exitSpy: Mo expect(harness.selectGatewaySpy).toHaveBeenCalledWith( "alpha", "nemoclaw-19080", - harness.runOpenshellSpy, + expect.any(Function), ); expect(harness.gatewayPinsAtSandboxList).toEqual(["nemoclaw-19080"]); expect(harness.runOpenshellSpy).toHaveBeenCalledWith( @@ -94,9 +94,17 @@ export function expectShieldsUpRefusalBeforeMutation(harness: DestroyHarness): v expect(harness.selectGatewaySpy).toHaveBeenCalledWith( "alpha", "nemoclaw-19080", - harness.runOpenshellSpy, + expect.any(Function), ); expect(harness.prepareMcpBridgesForDestroySpy).not.toHaveBeenCalled(); + expect(harness.isShieldsDownSpy).toHaveBeenCalledWith( + "alpha", + false, + expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), + ); expect(harness.runOpenshellSpy).toHaveBeenCalledWith( ["sandbox", "list", "-o", "json"], expect.objectContaining({ ignoreError: true }), @@ -173,6 +181,15 @@ export function expectFailedHardeningMcpRestore(harness: DestroyHarness): void { // shields-down rollback window it cannot close again. expect(harness.events).not.toContain("unlock"); expect(harness.shieldsDownSpy).not.toHaveBeenCalled(); + expect(harness.shieldsUpSpy).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), + }), + ); expect(harness.restoreMcpBridgesAfterDestroyAbortSpy).toHaveBeenCalledWith( "alpha", expect.objectContaining({ entries: [{ server: "github" }] }), @@ -182,10 +199,19 @@ export function expectFailedHardeningMcpRestore(harness: DestroyHarness): void { } export function expectMcpFinalizeAfterDelete(harness: DestroyHarness): void { - expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha"); + expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { + 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( @@ -222,9 +248,22 @@ export function expectMcpRestoreAfterDeleteFailure(harness: DestroyHarness): voi deferAutoRestoreWhileOwnerAlive: true, processToken: "a".repeat(32), throwOnError: true, + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), }), ); expect(harness.shieldsDownSpy.mock.calls[0]?.[1]).not.toHaveProperty("skipTimer"); + expect(harness.shieldsUpSpy).toHaveBeenLastCalledWith( + "alpha", + expect.objectContaining({ + runtimeSelection: expect.objectContaining({ + gatewayName: "nemoclaw-19080", + workspace: "default", + }), + }), + ); } export function expectFailedMcpRestorePreservesDestroyFailure(harness: DestroyHarness): void { @@ -259,7 +298,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( @@ -277,6 +320,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 206183c9537..a16abe2b965 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -62,7 +62,9 @@ export type DestroyHarness = { setRegistryEntryPresent: (present: boolean) => void; setRetainedRecoveryRecords: (records: RetainedSandboxRecoveryRecord[]) => void; setSandboxPresent: (present: boolean) => void; + isShieldsDownSpy: MockInstance; shieldsDownSpy: MockInstance; + shieldsUpSpy: MockInstance; stopAllSpy: MockInstance; stopModelRouterForDestroyedSandboxSpy: MockInstance; stopNimByNameSpy: MockInstance; @@ -106,6 +108,11 @@ type DestroyHarnessOptions = { preparedManagedLlamaCppRuntimeCleanup?: PreparedManagedLlamaCppRuntimeCleanup | null; mcpAddState?: "prepared"; mcpServers?: string[]; + mcpRuntimeSelection?: { + gatewayName: string; + localTlsDir?: string; + workspace: string; + }; openshellDriver?: string; portableCommandError?: string; portableDestroyAuthority?: boolean; @@ -240,6 +247,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr const shields = requireSource("../../shields/index.js"); const timerControl = requireSource("../../shields/timer-control.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", @@ -596,7 +604,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr } : null, ); - vi.spyOn(shields, "shieldsUp").mockImplementation(() => { + const shieldsUpSpy = vi.spyOn(shields, "shieldsUp").mockImplementation(() => { events.push("harden"); options.shieldsUpError === undefined ? undefined @@ -604,7 +612,9 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr throw options.shieldsUpError; })(); }); - vi.spyOn(shields, "isShieldsDown").mockReturnValue(options.shieldsDown ?? true); + const isShieldsDownSpy = vi + .spyOn(shields, "isShieldsDown") + .mockReturnValue(options.shieldsDown ?? true); const shieldsDownSpy = vi.spyOn(shields, "shieldsDown").mockImplementation(() => { events.push("unlock"); }); @@ -613,12 +623,22 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr return { warnings: [] }; }); const preparedServers = options.mcpAddState === "prepared" ? [] : (options.mcpServers ?? []); + const resolvedMcpRuntimeSelection = options.mcpRuntimeSelection ?? { + gatewayName: "nemoclaw-19080", + workspace: "default", + }; + 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, + ...(options.mcpServers?.length + ? { runtimeSelection: resolvedMcpRuntimeSelection } + : {}), }; const gatewayPinsAtMcpPrepare: Array = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -708,7 +728,9 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr setSandboxPresent: (present: boolean) => { sandboxPresent = present; }, + isShieldsDownSpy, shieldsDownSpy, + shieldsUpSpy, stopAllSpy, stopModelRouterForDestroyedSandboxSpy, stopNimByNameSpy, diff --git a/test/helpers/hermes-shields-provider-consumer-harness.ts b/test/helpers/hermes-shields-provider-consumer-harness.ts index cca19feb6fe..cd8cb9ea4d2 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", diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index 5a3d13c6172..befc703199e 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -130,6 +130,11 @@ export type RebuildFlowOverrides = { detachedProviderEntries: Array>; scrubbedAdapterEntries: Array>; policyHandoff?: string; + runtimeSelection?: { + gatewayName: string; + localTlsDir?: string; + workspace: string; + }; revalidateBeforeDelete?: () => Promise; assertDeleteEdgeUnchanged?: () => void; }; diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index 2b1fdb774c4..c8806a5712e 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -21,6 +21,7 @@ import { listHarnessRebuildBackups, loadRebuildSandbox, mcpBridge, + mcpBridgeProvider, messaging, messagingHostForwardLifecycle, nim, @@ -666,10 +667,20 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const ensureMessagingHostForwardAfterRebuildSpy = vi .spyOn(messagingHostForwardLifecycle, "ensureMessagingHostForwardAfterRebuild") .mockReturnValue(true); + const mcpRuntimeSelection = overrides.mcpPreparation?.runtimeSelection ?? { + gatewayName: "nemoclaw", + workspace: "default", + }; + vi.spyOn(mcpBridgeProvider, "getMcpProviderInspectionRuntimeSelection").mockReturnValue( + mcpRuntimeSelection, + ); + const mcpPreparation = overrides.mcpPreparation + ? { ...overrides.mcpPreparation, runtimeSelection: mcpRuntimeSelection } + : undefined; const prepareMcpBridgesForRebuildSpy = vi .spyOn(mcpBridge, "prepareMcpBridgesForRebuild") .mockResolvedValue( - overrides.mcpPreparation ?? { + mcpPreparation ?? { entries: [], detachedProviderEntries: [], }, @@ -677,7 +688,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const prepareMcpBridgesForAbsentSandboxRebuildSpy = vi .spyOn(mcpBridge, "prepareMcpBridgesForAbsentSandboxRebuild") .mockResolvedValue( - overrides.mcpPreparation ?? { + mcpPreparation ?? { entries: [], detachedProviderEntries: [], scrubbedAdapterEntries: [], diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 3c045b64f09..d51291b9232 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -40,6 +40,7 @@ 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 messaging = requireDist("../../messaging/index.js"); export const messagingHostForwardLifecycle = requireDist("./messaging-host-forward-lifecycle.js"); export const nim = requireDist("../../inference/nim.js"); diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index fe1d5a9917e..c98c4ce8f25 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -97,6 +97,11 @@ export type RebuildFlowOverrides = { detachedProviderEntries: Array>; scrubbedAdapterEntries?: Array>; policyHandoff?: string; + runtimeSelection?: { + gatewayName: string; + localTlsDir?: string; + workspace: string; + }; revalidateBeforeDelete?: () => Promise; assertDeleteEdgeUnchanged?: () => void; }; 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-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index 8bd10be0537..904fc6d9a65 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(), @@ -87,6 +88,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 +653,25 @@ 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).toMatch(/changed its MCP gateway authority.*different target/i); + 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", { @@ -990,8 +1015,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), From b980933dbba95bd507dfe9369bd0050b2c2db95f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 15:49:08 -0700 Subject: [PATCH 08/13] fix(rebuild): bind authoritative preflight target Signed-off-by: Apurv Kumaria --- .../rebuild-hermes-accepted-target.test.ts | 20 ++-- src/lib/actions/sandbox/rebuild-pipeline.ts | 14 +-- ...eflight-target-phase-orchestration.test.ts | 37 ++++++- .../sandbox/rebuild-preflight-target-phase.ts | 11 +++ .../sandbox/rebuild-target-runtime.test.ts | 7 ++ .../authoritative-rebuild-target.test.ts | 99 ++++++++++++++++--- .../onboard/authoritative-rebuild-target.ts | 81 +++++++++------ .../state/user-managed-files-probe.test.ts | 5 - 8 files changed, 207 insertions(+), 67 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts index 19a9ba79e87..0b7407e7e03 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts @@ -8,7 +8,6 @@ const phaseMocks = vi.hoisted(() => ({ clearRecoveryBackup: vi.fn(), cleanupPolicySource: vi.fn(), findRecoveryBackup: vi.fn(), - getMcpRuntimeSelection: vi.fn(), openRecreateJournal: vi.fn(), recoverCronRestore: vi.fn(), runBackup: vi.fn(), @@ -75,7 +74,6 @@ vi.mock("./rebuild-restore-phase", () => ({ vi.mock("./rebuild-post-restore-phase", async (importOriginal) => ({ ...(await importOriginal()), recoverHermesCronRestore: phaseMocks.recoverCronRestore, - getMcpPreparationRuntimeSelection: phaseMocks.getMcpRuntimeSelection, runHermesCronRestoreTransaction: phaseMocks.runCronRestoreTransaction, runRebuildPostRestorePhase: phaseMocks.runPostRestore, })); @@ -107,11 +105,6 @@ describe("Hermes accepted replacement recovery", () => { backupPath: recoveryBackupPath, timestamp: "2026-08-28T00-00-00-000Z", }); - phaseMocks.getMcpRuntimeSelection.mockReturnValue({ - gatewayName: "nemoclaw", - workspace: "default", - localTlsDir: "/authority/tls", - }); phaseMocks.runRestore.mockReturnValue({ restoreSucceeded: true }); phaseMocks.runPostRestore.mockResolvedValue(undefined); phaseMocks.runPreflight.mockResolvedValue({ @@ -232,13 +225,17 @@ describe("Hermes accepted replacement recovery", () => { workspace: "default", localTlsDir: "/authority/tls", }; - phaseMocks.getMcpRuntimeSelection.mockReturnValue(runtimeSelection); + const preflightResult = await phaseMocks.runPreflight.getMockImplementation()!(); phaseMocks.runPreflight.mockResolvedValue({ - ...(await phaseMocks.runPreflight.getMockImplementation()!()), + ...preflightResult, sandboxEntry: { name: "alpha", mcp: { bridges: { github: { server: "github" } } }, }, + recreateOptions: { + ...preflightResult.recreateOptions, + runtimeSelection, + }, }); phaseMocks.openRecreateJournal.mockImplementation((input) => ({ id: "journal-1", @@ -258,7 +255,6 @@ describe("Hermes accepted replacement recovery", () => { rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), ).resolves.toBeUndefined(); - expect(phaseMocks.getMcpRuntimeSelection).toHaveBeenCalledOnce(); expect(phaseMocks.runPostRestore).toHaveBeenCalledWith( expect.objectContaining({ mcpRuntimeSelection: runtimeSelection, @@ -279,6 +275,10 @@ describe("Hermes accepted replacement recovery", () => { name: "alpha", mcp: { bridges: { github: { server: "github" } } }, }, + recreateOptions: { + ...preflightResult.recreateOptions, + runtimeSelection, + }, }); phaseMocks.openRecreateJournal.mockReturnValue({ id: "journal-1", diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index b04ff02170e..a5f78c91a78 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -34,7 +34,6 @@ import { } from "./rebuild-flow-helpers"; import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase"; import { - getMcpPreparationRuntimeSelection, type HermesCronRestoreIdentity, HermesCronRestoreIncompleteError, printHermesCronRestoreRecoveryCommand, @@ -374,6 +373,12 @@ async function rebuildSandboxUnlocked( return; } const mcpEntries = Object.values(sandboxEntry.mcp?.bridges ?? {}); + const mcpRuntimeSelection = + mcpEntries.length > 0 ? recreateOptions.runtimeSelection : undefined; + if (mcpEntries.length > 0 && !mcpRuntimeSelection) { + bail("MCP rebuild preflight did not retain its recorded OpenShell runtime target."); + return; + } const recreateJournal = openRebuildRecreateJournal({ target: { sandboxName, @@ -383,11 +388,8 @@ async function rebuildSandboxUnlocked( expectedGatewayAuthority, agentName: rebuildAgent || "openclaw", targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), - ...(mcpEntries.length > 0 - ? { - resolveRuntimeSelection: () => - getMcpPreparationRuntimeSelection(sandboxEntry), - } + ...(mcpRuntimeSelection + ? { resolveRuntimeSelection: () => mcpRuntimeSelection } : {}), log, onAuthorityRefusal: (lines) => bail(lines.join("\n")), 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..1af98059417 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,10 @@ const mocks = vi.hoisted(() => ({ stageManagedWorkloadRebuildProfile: vi.fn(), })); +vi.mock("./rebuild-mcp-phase", () => ({ + getMcpPreparationRuntimeSelection: mocks.getMcpPreparationRuntimeSelection, +})); + vi.mock("../../onboard/workload/rebuild", async (importOriginal) => ({ ...(await importOriginal()), prepareManagedWorkloadRebuildHandoff: mocks.prepareManagedWorkloadRebuildHandoff, @@ -49,16 +54,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 +114,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 +212,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.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts index 0c415187ce8..ff1bb881f6e 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts @@ -40,6 +40,7 @@ import { type RebuildSandboxEntry, } from "./rebuild-flow-helpers"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; +import { getMcpPreparationRuntimeSelection } from "./rebuild-mcp-phase"; import { preflightRebuildMessagingConflicts } from "./rebuild-messaging-conflict-preflight"; import { stageRebuildMessagingPlanOrBail } from "./rebuild-messaging-phase"; import { @@ -198,6 +199,16 @@ export async function prepareRebuildTargetPreflights(args: { bail, ); if (!recreateOptions) return null; + if (Object.keys(sandboxEntry.mcp?.bridges ?? {}).length > 0) { + try { + recreateOptions.runtimeSelection = getMcpPreparationRuntimeSelection(sandboxEntry); + } catch (error) { + bail( + `Could not bind MCP rebuild preflight to the recorded OpenShell target: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } let managedWorkloadRebuildCatalog: Awaited< ReturnType > = null; diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts index e03cc004781..fb88aa51714 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.test.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.test.ts @@ -198,10 +198,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); @@ -221,6 +227,7 @@ describe("authoritative rebuild readiness", () => { allowDeferredN1xManagedVllm: true, provider: "vllm-local", model: "test-model", + runtimeSelection, sandboxName: "alpha", }), ); diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index c5af67cd9c6..3586f5e8370 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -334,28 +334,83 @@ describe("authoritative rebuild target preflight", () => { expect(targetDeps.inferenceRouteState).not.toHaveBeenCalled(); }); - it("pins the requested gateway for route and forward checks, then restores it", async () => { - process.env.OPENSHELL_GATEWAY = "before"; - const seen: string[] = []; - const checkPort = vi.fn(); + it("replaces hostile selectors for every preflight check, then restores them (#10514)", async () => { + const env: NodeJS.ProcessEnv = { + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "hostile-gateway", + OPENSHELL_GATEWAY_AUTH_TOKEN: "hostile-auth-token", + OPENSHELL_GATEWAY_ENDPOINT: "https://hostile.invalid", + OPENSHELL_LOCAL_TLS_DIR: "/hostile/tls", + OPENSHELL_TOKEN: "hostile-token", + OPENSHELL_WORKSPACE: "hostile-workspace", + }; + const previous = { ...env }; + const seen: NodeJS.ProcessEnv[] = []; + const record = (): void => { + seen.push({ ...env }); + }; await preflightAuthoritativeRebuildTarget( - target, + { + ...target, + runtimeSelection: { + gatewayName: "nemoclaw-12345", + localTlsDir: "/authority/tls", + workspace: "default", + }, + }, deps({ + env, + resolveBaselinePolicy: vi.fn(() => { + record(); + return {}; + }), + bindGatewayAuthority: vi.fn(record), + runFatalRuntimePreflight: vi.fn(record), + ensureOpenshell: vi.fn(record), + assertGatewayReadiness: vi.fn(record), inferenceRouteState: vi.fn((): InferenceRouteState => { - seen.push(`route:${process.env.OPENSHELL_GATEWAY}`); + record(); return "matched"; }), captureForwardList: vi.fn(() => { - seen.push(`forward:${process.env.OPENSHELL_GATEWAY}`); + record(); return "alpha 127.0.0.1 18789 42 active"; }), - checkPort, }), ); - expect(seen).toEqual(["route:nemoclaw-12345", "forward:nemoclaw-12345"]); - expect(checkPort).not.toHaveBeenCalled(); - expect(process.env.OPENSHELL_GATEWAY).toBe("before"); + const selectedEnv = { + PATH: "/usr/bin", + OPENSHELL_GATEWAY: "nemoclaw-12345", + OPENSHELL_LOCAL_TLS_DIR: "/authority/tls", + OPENSHELL_WORKSPACE: "default", + }; + expect(seen).toEqual([ + selectedEnv, + selectedEnv, + selectedEnv, + selectedEnv, + selectedEnv, + selectedEnv, + selectedEnv, + ]); + expect(env).toEqual(previous); + }); + + it("rejects a runtime selection for another authoritative gateway (#10514)", async () => { + const env: NodeJS.ProcessEnv = { OPENSHELL_GATEWAY: "before" }; + const previous = { ...env }; + + await expect( + preflightAuthoritativeRebuildTarget( + { + ...target, + runtimeSelection: { gatewayName: "nemoclaw-9999", workspace: "default" }, + }, + deps({ env }), + ), + ).rejects.toThrow("does not match authoritative gateway 'nemoclaw-12345'"); + expect(env).toEqual(previous); }); it("rejects an exact provider/model route mismatch", async () => { @@ -415,19 +470,33 @@ describe("authoritative rebuild target preflight", () => { ).rejects.toThrow("occupied by node (PID 99)"); }); - it("restores gateway scope when a fatal runtime check throws", async () => { - process.env.OPENSHELL_GATEWAY = "before"; + it("restores the complete runtime scope when a fatal check throws (#10514)", async () => { + const env: NodeJS.ProcessEnv = { + OPENSHELL_GATEWAY: "before", + OPENSHELL_GATEWAY_ENDPOINT: "https://before.invalid", + OPENSHELL_TOKEN: "before-token", + OPENSHELL_WORKSPACE: "before-workspace", + }; + const previous = { ...env }; await expect( preflightAuthoritativeRebuildTarget( - target, + { + ...target, + runtimeSelection: { + gatewayName: "nemoclaw-12345", + localTlsDir: "/authority/tls", + workspace: "default", + }, + }, deps({ + env, runFatalRuntimePreflight: vi.fn(() => { throw new Error("fatal runtime gate"); }), }), ), ).rejects.toThrow("fatal runtime gate"); - expect(process.env.OPENSHELL_GATEWAY).toBe("before"); + expect(env).toEqual(previous); }); it("awaits async runtime readiness before OpenShell and route checks", async () => { diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index 1a5a5b709e0..d1bb8c039c1 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -42,31 +42,10 @@ export type AuthoritativeGatewayOptions = Pick< type AuthoritativeRuntimeSelectionOptions = AuthoritativeGatewayOptions & Pick; -/** Keep every OpenShell child in an inner rebuild onboard on its frozen target. */ -export function beginAuthoritativeRebuildRuntimeSelectionScope( - opts: AuthoritativeRuntimeSelectionOptions, - env: NodeJS.ProcessEnv = process.env, +function beginOpenShellRuntimeSelectionEnvScope( + runtimeSelection: OpenShellRuntimeSelection, + env: NodeJS.ProcessEnv, ): () => 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}'.`, - ); - } - const previous = Object.fromEntries( Object.entries(env).filter( (entry): entry is [string, string] => @@ -95,9 +74,41 @@ export function beginAuthoritativeRebuildRuntimeSelectionScope( }; } +/** 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. */ @@ -163,6 +174,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. */ @@ -283,11 +295,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}'.`); @@ -328,7 +352,6 @@ export async function preflightAuthoritativeRebuildTarget( fail(`Dashboard port ${target.controlUiPort} is occupied by ${blocker}.`); } } finally { - if (previousGateway === undefined) delete env.OPENSHELL_GATEWAY; - else env.OPENSHELL_GATEWAY = previousGateway; + restoreRuntimeSelection(); } } diff --git a/src/lib/state/user-managed-files-probe.test.ts b/src/lib/state/user-managed-files-probe.test.ts index 9d56d99c0f3..2b13d1cee78 100644 --- a/src/lib/state/user-managed-files-probe.test.ts +++ b/src/lib/state/user-managed-files-probe.test.ts @@ -323,11 +323,6 @@ describe("getSshConfig frozen target", () => { 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", - }; vi.spyOn(loadOpenShellResolve(), "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); const capture = vi .spyOn(loadOpenShellClient(), "captureSandboxSshConfigCommand") From f1e6024dc42f0c20ad0296ba2837a1c9aa3bf4c8 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 16:28:32 -0700 Subject: [PATCH 09/13] test(ci): isolate runtime target fixtures Signed-off-by: Apurv Kumaria --- .../destroy-flow-runtime-selection.test.ts | 2 +- .../sandbox/rebuild-dcode-mutation-edge.test.ts | 17 ++++++++++++++--- test/helpers/rebuild-flow-dcode-harness.ts | 9 +++++++++ test/helpers/rebuild-flow-harness.ts | 1 + 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts b/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts index 6271cd52226..cdd682295bd 100644 --- a/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts +++ b/src/lib/actions/sandbox/destroy-flow-runtime-selection.test.ts @@ -31,7 +31,7 @@ describe("destroySandbox OpenShell runtime selection", () => { mcpRuntimeSelection: runtimeSelection, }); - await harness.destroySandbox("alpha", { yes: true }); + await harness.destroySandbox("alpha", { yes: true, cleanupGateway: true }); expectMcpFinalizeAfterDelete(harness); expect(harness.prepareMcpBridgesForDestroySpy).toHaveBeenCalledWith("alpha", { diff --git a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts index 20d7489f6e1..5c9e9f73c59 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts @@ -17,6 +17,7 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { it("finishes DCode preparation and recheck before backup, delete, and recreate (#6195)", async () => { const mcpEntry = { server: "search", providerName: "mcp-search" }; + const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", sandboxEntry: makeDcodeSandboxEntry(), @@ -25,6 +26,7 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { entries: [mcpEntry], detachedProviderEntries: [], scrubbedAdapterEntries: [], + runtimeSelection, }, }); configureDcodeSession(harness); @@ -90,11 +92,16 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( harness.preparedDcodeBuildContext, ); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith( + "alpha", + [mcpEntry], + runtimeSelection, + ); }); it("rolls back managed MCP mutation when DCode inputs drift during MCP preparation (#6195)", async () => { const detached = { server: "search", providerName: "mcp-search" }; const scrubbed = { server: "filesystem", adapter: "deepagents-config" }; + const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", sandboxEntry: makeDcodeSandboxEntry(), @@ -104,6 +111,7 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { entries: [detached], detachedProviderEntries: [detached], scrubbedAdapterEntries: [scrubbed], + runtimeSelection, }, }); configureDcodeSession(harness); @@ -112,12 +120,15 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); - expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha"); + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith( + "alpha", + runtimeSelection, + ); expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).toHaveBeenCalledWith( "alpha", [detached], [scrubbed], - undefined, + runtimeSelection, ); expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.onboardSpy).not.toHaveBeenCalled(); diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index befc703199e..22753126395 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -22,6 +22,7 @@ import { listHarnessRebuildBackups, loadRebuildSandbox, mcpBridge, + mcpBridgeProviderInspection, messaging, messagingHostForwardLifecycle, nim, @@ -751,6 +752,14 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const ensureMessagingHostForwardAfterRebuildSpy = vi .spyOn(messagingHostForwardLifecycle, "ensureMessagingHostForwardAfterRebuild") .mockReturnValue(true); + const mcpRuntimeSelection = overrides.mcpPreparation?.runtimeSelection ?? { + gatewayName: "nemoclaw", + workspace: "default", + }; + vi.spyOn( + mcpBridgeProviderInspection, + "getMcpProviderInspectionRuntimeSelection", + ).mockReturnValue(mcpRuntimeSelection); const emptyMcpPreparation = { entries: [], detachedProviderEntries: [], diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index d51291b9232..a07ebbbf6e4 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -41,6 +41,7 @@ export const gatewayTeardownAuthority = requireDist( 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 nim = requireDist("../../inference/nim.js"); From b96a765d822c0dabd6bc3991e71621e2d299106c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 17:09:13 -0700 Subject: [PATCH 10/13] fix(openshell): replace ambient runtime selectors Signed-off-by: Apurv Kumaria --- src/lib/actions/sandbox/mcp-bridge-state.ts | 7 +- .../mcp-bridge-status-boundaries.test.ts | 13 +- .../rebuild-dcode-mutation-edge.test.ts | 56 ++++++- .../sandbox/rebuild-dcode-orchestrator.ts | 34 ++++- .../sandbox/rebuild-dcode-preflight.test.ts | 137 +++++++++++++++--- .../sandbox/rebuild-dcode-preflight.ts | 35 +++-- .../sandbox/rebuild-flow-helpers.test.ts | 70 ++++++++- .../actions/sandbox/rebuild-flow-helpers.ts | 21 ++- .../sandbox/rebuild-flow-lifecycle.test.ts | 85 ++++++++++- src/lib/actions/sandbox/rebuild-pipeline.ts | 25 +--- .../sandbox/rebuild-preflight-phase.ts | 16 +- .../rebuild-preflight-target-phase.test.ts | 45 +++++- .../sandbox/rebuild-preflight-target-phase.ts | 50 +++++-- .../adapters/openshell/runtime-selection.ts | 37 ++++- src/lib/adapters/openshell/runtime.ts | 2 + src/lib/gateway-runtime-action.ts | 4 + .../onboard/authoritative-rebuild-target.ts | 32 +--- test/helpers/rebuild-flow-dcode-harness.ts | 25 ++-- test/helpers/rebuild-flow-generic-harness.ts | 21 ++- test/helpers/rebuild-flow-test-support.ts | 5 + 20 files changed, 561 insertions(+), 159 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-state.ts b/src/lib/actions/sandbox/mcp-bridge-state.ts index 08b998f2986..4695ae5c7e8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-state.ts +++ b/src/lib/actions/sandbox/mcp-bridge-state.ts @@ -3,7 +3,10 @@ import { type AgentDefinition, type AgentMcpAdapter, loadAgent } from "../../agent/defs"; import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; -import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +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"; @@ -253,5 +256,5 @@ export async function ensureSandboxGatewaySelected( // 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 27fab62b618..12af4ca5c98 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts @@ -98,7 +98,13 @@ registry.registerSandbox({ } } }, }); require("./src/lib/actions/sandbox/mcp-bridge-status.js").statusMcpBridge("alpha", "github").then( - () => process.stdout.write(JSON.stringify({ providerEnvironments, policyEnvironments })), + () => 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)), ); `; @@ -111,8 +117,13 @@ require("./src/lib/actions/sandbox/mcp-bridge-status.js").statusMcpBridge("alpha 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( diff --git a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts index 5c9e9f73c59..4e3935c9ff9 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts @@ -20,7 +20,10 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), + sandboxEntry: { + ...makeDcodeSandboxEntry(), + mcp: { bridges: { search: mcpEntry } }, + }, dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], mcpPreparation: { entries: [mcpEntry], @@ -41,7 +44,38 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { ), ).resolves.toBeUndefined(); + expect(harness.mcpRuntimeSelectionResolverSpy).toHaveBeenCalledOnce(); expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(4); + expect( + (harness.preflightDcodeRouteSpy.mock.calls[0]?.[0] as { runtimeSelection?: unknown }) + .runtimeSelection, + ).toBe(runtimeSelection); + expect( + (harness.preflightDcodeRouteSpy.mock.calls[1]?.[0] as { runtimeSelection?: unknown }) + .runtimeSelection, + ).toBe(runtimeSelection); + expect( + (harness.preflightDcodeRouteSpy.mock.calls[2]?.[0] as { runtimeSelection?: unknown }) + .runtimeSelection, + ).toBe(runtimeSelection); + expect( + (harness.preflightDcodeRouteSpy.mock.calls[3]?.[0] as { runtimeSelection?: unknown }) + .runtimeSelection, + ).toBe(runtimeSelection); + expect(harness.gatewayRecoverySpy).toHaveBeenCalled(); + expect( + harness.gatewayRecoverySpy.mock.calls.every( + ([options]) => + (options as { runtimeSelection?: unknown }).runtimeSelection === runtimeSelection, + ), + ).toBe(true); + expect(harness.gatewaySchemaSpy).toHaveBeenCalled(); + expect( + harness.gatewaySchemaSpy.mock.calls.every( + ([options]) => + (options as { runtimeSelection?: unknown }).runtimeSelection === runtimeSelection, + ), + ).toBe(true); expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -97,6 +131,8 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { [mcpEntry], runtimeSelection, ); + expect(harness.prepareMcpBridgesForRebuildSpy.mock.calls[0]?.[1]).toBe(runtimeSelection); + expect(harness.restoreMcpBridgesAfterRebuildSpy.mock.calls[0]?.[2]).toBe(runtimeSelection); }); it("rolls back managed MCP mutation when DCode inputs drift during MCP preparation (#6195)", async () => { const detached = { server: "search", providerName: "mcp-search" }; @@ -104,7 +140,10 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), + sandboxEntry: { + ...makeDcodeSandboxEntry(), + mcp: { bridges: { search: detached } }, + }, dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], dcodeImageVerificationResults: [true, true, false], mcpPreparation: { @@ -120,10 +159,8 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); - expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith( - "alpha", - runtimeSelection, - ); + expect(harness.mcpRuntimeSelectionResolverSpy).toHaveBeenCalledOnce(); + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha", runtimeSelection); expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).toHaveBeenCalledWith( "alpha", [detached], @@ -137,7 +174,12 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { expect.any(Object), true, "nemoclaw", - undefined, + runtimeSelection, + ); + expect(harness.prepareMcpBridgesForRebuildSpy.mock.calls[0]?.[1]).toBe(runtimeSelection); + expect(harness.reattachMcpProvidersAfterRebuildAbortSpy.mock.calls[0]?.[3]).toBe( + runtimeSelection, ); + expect(harness.relockSpy.mock.calls[0]?.[4]).toBe(runtimeSelection); }); }); diff --git a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts index 72099e67a88..6dbc3398991 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts @@ -53,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, @@ -62,6 +62,7 @@ export type DcodeRebuildOrchestrator = { skipLiveRoute: boolean, gatewayPort: number, baseImageOptions?: RebuildAgentBaseImageOptions, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise; revalidateBeforeDelete( resumeConfig: RebuildResumeConfig, @@ -69,6 +70,7 @@ export type DcodeRebuildOrchestrator = { dcodeAutoApprovalMode: DcodeAutoApprovalMode, skipLiveRoute: boolean, gatewayPort: number, + runtimeSelection?: OpenShellRuntimeSelection, ): Promise; checkAtDeleteEdge( resumeConfig: RebuildResumeConfig, @@ -141,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); }), @@ -161,6 +169,7 @@ export function createDcodeRebuildOrchestrator( skipLiveRoute, gatewayPort, baseImageOptions, + runtimeSelection, ) => run(async () => { if (!scope.enabled) { @@ -177,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({ @@ -192,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(); @@ -207,6 +220,7 @@ export function createDcodeRebuildOrchestrator( dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, + runtimeSelection, ) => run(async () => { if (!scope.enabled) return true; @@ -221,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; @@ -236,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, }); }), diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts index f7bc8f017d0..b05f4e9f114 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.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 } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { configureDcodeSession, expectNoDcodeMutation, @@ -12,11 +12,71 @@ import { installRebuildFlowTestHooks, snapshotEnv, } from "../../../../test/helpers/rebuild-flow-dcode-harness"; +import * as gatewayRuntime from "../../gateway-runtime-action"; +import { ensureDcodeRebuildTargetGatewaySelected } from "./rebuild-dcode-preflight"; import { resolveRebuildDurableConfig } from "./rebuild-durable-config"; describe("rebuildSandbox DCode flow: preflight", () => { installRebuildFlowTestHooks({ acceptThirdPartySoftware: true }); + it("keeps non-MCP ambient selectors while pinning the recorded gateway (#10514)", async () => { + const restoreEnv = snapshotEnv([ + "OPENSHELL_GATEWAY", + "OPENSHELL_GATEWAY_ENDPOINT", + "OPENSHELL_LOCAL_TLS_DIR", + "OPENSHELL_TOKEN", + "OPENSHELL_WORKSPACE", + ]); + 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 gatewayState = { + state: "healthy_named" as const, + activeGateway: "nemoclaw", + status: "", + gatewayInfo: "", + }; + const recover = vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockResolvedValue({ + recovered: true, + attempted: false, + before: gatewayState, + after: gatewayState, + }); + const bail = vi.fn((message: string): never => { + throw new Error(message); + }); + + try { + await expect( + ensureDcodeRebuildTargetGatewaySelected( + "alpha", + makeDcodeSandboxEntry() as never, + vi.fn(), + bail, + ), + ).resolves.toBe(true); + + expect(recover).toHaveBeenCalledWith({ + gatewayName: "nemoclaw", + recoverableStates: [ + "missing_named", + "named_unhealthy", + "named_unreachable", + "connected_other", + ], + }); + expect(process.env.OPENSHELL_GATEWAY).toBe("nemoclaw"); + 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"); + } finally { + restoreEnv(); + } + }); + it.each([ ["defaults legacy state to disabled", undefined, undefined, "disabled", null], ["uses recorded state", "thread-opt-in", undefined, "thread-opt-in", null], @@ -28,25 +88,28 @@ describe("rebuildSandbox DCode flow: preflight", () => { "thread-opt-in", "recorded dcodeAutoApprovalMode value must be disabled or thread-opt-in", ], - ] as const)("resolves durable DCode mode: %s (#6478)", (_label, recorded, requested, expected, error) => { - const config = resolveRebuildDurableConfig( - "alpha", - { - name: "alpha", - agent: "langchain-deepagents-code", - nemoclawVersion: "0.1.0", - ...(recorded !== undefined ? { dcodeAutoApprovalMode: recorded as never } : {}), - }, - null, - undefined, - undefined, - false, - requested, - ); - - expect(config.dcodeAutoApprovalMode).toBe(expected); - expect(config.dcodeAutoApprovalModeError).toBe(error); - }); + ] as const)( + "resolves durable DCode mode: %s (#6478)", + (_label, recorded, requested, expected, error) => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + agent: "langchain-deepagents-code", + nemoclawVersion: "0.1.0", + ...(recorded !== undefined ? { dcodeAutoApprovalMode: recorded as never } : {}), + }, + null, + undefined, + undefined, + false, + requested, + ); + + expect(config.dcodeAutoApprovalMode).toBe(expected); + expect(config.dcodeAutoApprovalModeError).toBe(error); + }, + ); it("rejects a DCode auto-approval override for unsupported agents before mutation (#6478)", async () => { const harness = createRebuildFlowHarness({ @@ -177,14 +240,37 @@ describe("rebuildSandbox DCode flow: preflight", () => { restoreEnv(); } }); - it("restores the prior gateway when messaging conflict preflight throws after target pin (#6195)", async () => { - const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); + it("restores the complete OpenShell environment when preflight fails after target pin (#6195)", async () => { + const restoreEnv = snapshotEnv([ + "OPENSHELL_GATEWAY", + "OPENSHELL_GATEWAY_ENDPOINT", + "OPENSHELL_LOCAL_TLS_DIR", + "OPENSHELL_TOKEN", + "OPENSHELL_UNRELATED", + "OPENSHELL_WORKSPACE", + ]); process.env.OPENSHELL_GATEWAY = "previous-gateway"; + process.env.OPENSHELL_GATEWAY_ENDPOINT = "https://previous.invalid"; + process.env.OPENSHELL_LOCAL_TLS_DIR = "/previous/tls"; + process.env.OPENSHELL_TOKEN = "previous-token"; + process.env.OPENSHELL_UNRELATED = "previous-value"; + process.env.OPENSHELL_WORKSPACE = "previous-workspace"; + const mcpEntry = { server: "search", providerName: "mcp-search" }; + const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; try { const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), + sandboxEntry: { + ...makeDcodeSandboxEntry(), + mcp: { bridges: { search: mcpEntry } }, + }, + mcpPreparation: { + entries: [mcpEntry], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + runtimeSelection, + }, preflightMessagingConflicts: () => { throw new Error("messaging conflict preflight failed"); }, @@ -197,6 +283,11 @@ describe("rebuildSandbox DCode flow: preflight", () => { expect(harness.preflightMessagingConflictsSpy).toHaveBeenCalledOnce(); expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); + expect(process.env.OPENSHELL_GATEWAY_ENDPOINT).toBe("https://previous.invalid"); + expect(process.env.OPENSHELL_LOCAL_TLS_DIR).toBe("/previous/tls"); + expect(process.env.OPENSHELL_TOKEN).toBe("previous-token"); + expect(process.env.OPENSHELL_UNRELATED).toBe("previous-value"); + expect(process.env.OPENSHELL_WORKSPACE).toBe("previous-workspace"); expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index f305a5dc3cb..54a0cc70903 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts @@ -14,7 +14,11 @@ import { pinTrustedAgentRemoteBaseImageOverrideForOperation, } from "../../agent/onboard"; import { RD as _RD, R } from "../../cli/terminal-style"; -import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +import { + recoverNamedGatewayRuntime, + replaceOpenShellRuntimeSelectionEnv, + 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"; @@ -104,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; @@ -117,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; } }; @@ -200,7 +199,8 @@ export async function ensureDcodeRebuildTargetGatewaySelected( 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; } @@ -519,6 +519,7 @@ export async function prepareDcodeReplacementBeforeMutation( gatewayPort, log, bail, + runtimeSelection, } = input; let buildContext: PreparedDcodeRebuildImage | null = null; let pinnedBase: PinnedDcodeBaseImage | null = null; @@ -533,7 +534,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); @@ -558,11 +559,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); diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts index 21ccd597785..44ef533d4df 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 () => { diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index b833709a25d..a8b822da3a7 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -23,6 +23,8 @@ import { import { getNamedGatewayLifecycleState, recoverNamedGatewayRuntime, + replaceOpenShellRuntimeSelectionEnv, + snapshotOpenShellEnv, } from "../../gateway-runtime-action"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { @@ -49,6 +51,7 @@ import { openRebuildShieldsWindow, type RebuildShieldsWindow } from "./rebuild-s import * as snapshotBackup from "./snapshot/backup-authority"; export { removeStaleRebuildDockerOrphan } from "../../onboard/openshell-docker-sandbox-containers"; +export { replaceOpenShellRuntimeSelectionEnv, snapshotOpenShellEnv }; export type RebuildSandboxEntry = SandboxEntry & { agents?: unknown[] }; @@ -136,9 +139,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 +164,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; } @@ -477,8 +491,7 @@ export function backupSandboxStateForRebuild( ); if (!backup.success) { console.error(" Failed to back up sandbox state."); - const allStateDirsFailed = - backup.backedUpDirs.length === 0 && backup.failedDirs.length > 0; + const allStateDirsFailed = backup.backedUpDirs.length === 0 && backup.failedDirs.length > 0; if (allStateDirsFailed && backup.backedUpFiles.length > 0) { const dirCount = backup.failedDirs.length; const fileCount = backup.backedUpFiles.length; diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index ef4921f68ae..5e8ebce6091 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -460,11 +460,10 @@ describe("rebuildSandbox flow: lifecycle", () => { expect.objectContaining({ toolDisclosure: "direct" }), ); expect(harness.session.toolDisclosure).toBe("direct"); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith( - "alpha", - [mcpEntry], - { gatewayName: "nemoclaw", workspace: "default" }, - ); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry], { + gatewayName: "nemoclaw", + workspace: "default", + }); harness.registryUpdateSpy.mock.calls.forEach(([, update]) => { expect(update).not.toHaveProperty("toolDisclosure"); }); @@ -518,10 +517,20 @@ describe("rebuildSandbox flow: lifecycle", () => { "COMPATIBLE_API_KEY", "NEMOCLAW_REASONING", "NEMOCLAW_REASONING_EFFORT", + "OPENSHELL_GATEWAY", + "OPENSHELL_GATEWAY_ENDPOINT", + "OPENSHELL_LOCAL_TLS_DIR", + "OPENSHELL_TOKEN", + "OPENSHELL_WORKSPACE", ]); process.env.COMPATIBLE_API_KEY = "compat-key"; process.env.NEMOCLAW_REASONING = "false"; process.env.NEMOCLAW_REASONING_EFFORT = "low"; + 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 mcpEntry = { server: "github", agent: "openclaw", @@ -532,8 +541,12 @@ describe("rebuildSandbox flow: lifecycle", () => { policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", }; + const runtimeSelection = { gatewayName: "nemoclaw", workspace: "default" }; let reasoningSeenInsideOnboard: string | undefined; let effortSeenInsideOnboard: string | undefined; + let openShellEnvDuringSessionCount: Record | undefined; + let openShellEnvDuringVersionCheck: Record | undefined; + let openShellEnvBeforeBackup: Record | undefined; try { const harness = createRebuildFlowHarness({ applyPreset: () => true, @@ -545,10 +558,39 @@ describe("rebuildSandbox flow: lifecycle", () => { compatibleEndpointReasoningEffort: "high", mcp: { bridges: { github: mcpEntry } }, }, + openshellBinary: "/test/openshell", sessionSandboxName: "other", mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], + runtimeSelection, + }, + beforeActiveSessionCount: () => { + openShellEnvDuringSessionCount = { + gateway: process.env.OPENSHELL_GATEWAY, + endpoint: process.env.OPENSHELL_GATEWAY_ENDPOINT, + localTlsDir: process.env.OPENSHELL_LOCAL_TLS_DIR, + token: process.env.OPENSHELL_TOKEN, + workspace: process.env.OPENSHELL_WORKSPACE, + }; + }, + beforeVersionCheck: () => { + openShellEnvDuringVersionCheck = { + gateway: process.env.OPENSHELL_GATEWAY, + endpoint: process.env.OPENSHELL_GATEWAY_ENDPOINT, + localTlsDir: process.env.OPENSHELL_LOCAL_TLS_DIR, + token: process.env.OPENSHELL_TOKEN, + workspace: process.env.OPENSHELL_WORKSPACE, + }; + }, + beforeBackup: () => { + openShellEnvBeforeBackup = { + gateway: process.env.OPENSHELL_GATEWAY, + endpoint: process.env.OPENSHELL_GATEWAY_ENDPOINT, + localTlsDir: process.env.OPENSHELL_LOCAL_TLS_DIR, + token: process.env.OPENSHELL_TOKEN, + workspace: process.env.OPENSHELL_WORKSPACE, + }; }, onboard: (session) => { // The recreate reapplies the recorded configuration, never the @@ -569,12 +611,43 @@ describe("rebuildSandbox flow: lifecycle", () => { expect(effortSeenInsideOnboard).toBe("high"); expect(harness.session.compatibleEndpointReasoning).toBe("true"); expect(harness.session.compatibleEndpointReasoningEffort).toBe("high"); + expect(harness.mcpRuntimeSelectionResolverSpy).toHaveBeenCalledOnce(); + expect(openShellEnvDuringSessionCount).toEqual({ + gateway: "nemoclaw", + endpoint: undefined, + localTlsDir: undefined, + token: undefined, + workspace: "default", + }); + expect(openShellEnvDuringVersionCheck).toEqual({ + gateway: "nemoclaw", + endpoint: undefined, + localTlsDir: undefined, + token: undefined, + workspace: "default", + }); + expect( + (harness.gatewaySchemaSpy.mock.calls[0]?.[0] as { runtimeSelection?: unknown }) + .runtimeSelection, + ).toBe(runtimeSelection); + expect(openShellEnvBeforeBackup).toEqual({ + gateway: "nemoclaw", + endpoint: undefined, + localTlsDir: undefined, + token: undefined, + workspace: "default", + }); expect(process.env.NEMOCLAW_REASONING).toBe("false"); expect(process.env.NEMOCLAW_REASONING_EFFORT).toBe("low"); + expect(process.env.OPENSHELL_GATEWAY).toBe("hostile-gateway"); + 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"); expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith( "alpha", [mcpEntry], - { gatewayName: "nemoclaw", workspace: "default" }, + runtimeSelection, ); } finally { restoreEnv(); diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index a5f78c91a78..bd0fe2d8819 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -31,6 +31,7 @@ import { REBUILD_HERMES_DASHBOARD_ENV_KEYS } from "./rebuild-durable-config"; import { disposeRebuildAgentBaseImagePreflight, removeStaleRebuildDockerOrphan, + snapshotOpenShellEnv, } from "./rebuild-flow-helpers"; import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase"; import { @@ -110,11 +111,11 @@ export async function rebuildSandbox( () => withMcpLifecycleLock(sandboxName, async () => { 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, @@ -123,6 +124,7 @@ export async function rebuildSandbox( try { await rebuildSandboxUnlocked(sandboxName, options, opts); } finally { + restoreOpenShellEnv(); for (const key of scopedEnvKeys) delete process.env[key]; Object.assign( process.env, @@ -281,11 +283,7 @@ async function rebuildSandboxUnlocked( } }; const capturePolicyHandoff = (runtimeSelection?: OpenShellRuntimeSelection): boolean => { - const capturedPath = captureRebuildPolicySource( - sandboxName, - undefined, - runtimeSelection, - ); + const capturedPath = captureRebuildPolicySource(sandboxName, undefined, runtimeSelection); if (!capturedPath) return false; try { return publishPolicyHandoff(fs.readFileSync(capturedPath, "utf8")); @@ -353,6 +351,7 @@ async function rebuildSandboxUnlocked( durableConfig.dcodeAutoApprovalMode, recoveryRecreate, recreateOptions.targetGatewayPort, + recreateOptions.runtimeSelection, )) ) { return; @@ -388,9 +387,7 @@ async function rebuildSandboxUnlocked( expectedGatewayAuthority, agentName: rebuildAgent || "openclaw", targetIntentFingerprint: fingerprintRebuildRecreateTargetIntent(recreateOptions), - ...(mcpRuntimeSelection - ? { resolveRuntimeSelection: () => mcpRuntimeSelection } - : {}), + ...(mcpRuntimeSelection ? { resolveRuntimeSelection: () => mcpRuntimeSelection } : {}), log, onAuthorityRefusal: (lines) => bail(lines.join("\n")), }); @@ -413,9 +410,7 @@ async function rebuildSandboxUnlocked( // restart converges to that sandbox instead of deleting it. if (recreateJournal.acceptedTarget) { if (mcpEntries.length > 0 && !recreateJournal.runtimeSelection) { - bail( - "The accepted MCP replacement is missing its recorded OpenShell runtime target.", - ); + bail("The accepted MCP replacement is missing its recorded OpenShell runtime target."); return; } const recoveryBackup = findRebuildRecoveryBackup(rebuildRecoveryIdentity); @@ -607,11 +602,7 @@ async function rebuildSandboxUnlocked( }; }, cleanupDockerOrphanAfterDelete: () => - removeStaleRebuildDockerOrphan( - sandboxName, - sandboxEntry.openshellDriver, - log, - ), + removeStaleRebuildDockerOrphan(sandboxName, sandboxEntry.openshellDriver, log), onDeleted: () => { sandboxStillExists = false; retainPolicyHandoffForRecovery = true; diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index 348610523d9..27e50e3821b 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, @@ -267,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 @@ -302,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.test.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts index 86b7a30144c..10f0e91a116 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, @@ -15,31 +16,67 @@ import { 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("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 ff1bb881f6e..217b3509620 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,6 +37,7 @@ import { ensureRebuildAgentBaseImage, ensureRebuildTargetGatewaySelected, pinRebuildAgentBaseImageForRecreate, + replaceOpenShellRuntimeSelectionEnv, type RebuildAgentBaseImagePreflight, type RebuildSandboxEntry, } from "./rebuild-flow-helpers"; @@ -94,14 +96,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 (Object.keys(sandboxEntry.mcp?.bridges ?? {}).length === 0) 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; } @@ -155,6 +179,7 @@ export async function prepareRebuildTargetPreflights(args: { requestedObservabilityEnabled?: boolean; allowLegacyManagedImageRecovery?: boolean; preparedBackupRecovery?: boolean; + mcpRuntimeSelection?: OpenShellRuntimeSelection; log: RebuildLog; bail: RebuildBail; }): Promise { @@ -168,11 +193,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, @@ -199,16 +227,7 @@ export async function prepareRebuildTargetPreflights(args: { bail, ); if (!recreateOptions) return null; - if (Object.keys(sandboxEntry.mcp?.bridges ?? {}).length > 0) { - try { - recreateOptions.runtimeSelection = getMcpPreparationRuntimeSelection(sandboxEntry); - } catch (error) { - bail( - `Could not bind MCP rebuild preflight to the recorded OpenShell target: ${error instanceof Error ? error.message : String(error)}`, - ); - return null; - } - } + if (mcpRuntimeSelection) recreateOptions.runtimeSelection = mcpRuntimeSelection; let managedWorkloadRebuildCatalog: Awaited< ReturnType > = null; @@ -308,10 +327,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/adapters/openshell/runtime-selection.ts b/src/lib/adapters/openshell/runtime-selection.ts index 8c9af1ce60a..ffa0e447121 100644 --- a/src/lib/adapters/openshell/runtime-selection.ts +++ b/src/lib/adapters/openshell/runtime-selection.ts @@ -9,12 +9,11 @@ export type OpenShellRuntimeSelection = { workspace: string; }; -/** Replace ambient OpenShell selectors with one authority-derived runtime target. */ -export function buildOpenShellRuntimeSelectionEnv( - baseEnv: Record, +/** Remove ambient OpenShell selectors and install one authority-derived target. */ +export function replaceOpenShellRuntimeSelectionEnv( + env: Record, runtimeSelection: OpenShellRuntimeSelection, -): Record { - const env = { ...baseEnv }; +): void { for (const name of Object.keys(env)) { if (name.startsWith("OPENSHELL_")) delete env[name]; } @@ -23,6 +22,34 @@ export function buildOpenShellRuntimeSelectionEnv( 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 f6536173316..225453ad829 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -21,6 +21,8 @@ export { buildOpenShellCommandEnv, buildOpenShellRuntimeSelectionEnv, buildSelectedOpenShellSubprocessEnv, + replaceOpenShellRuntimeSelectionEnv, + snapshotOpenShellEnv, type OpenShellRuntimeSelection, } from "./runtime-selection"; diff --git a/src/lib/gateway-runtime-action.ts b/src/lib/gateway-runtime-action.ts index 1aa7af21e55..773b7a0b0c0 100644 --- a/src/lib/gateway-runtime-action.ts +++ b/src/lib/gateway-runtime-action.ts @@ -20,6 +20,10 @@ import { export { resolveGatewayName, resolveSandboxGatewayName }; +export const replaceOpenShellRuntimeSelectionEnv = + openshellRuntime.replaceOpenShellRuntimeSelectionEnv; +export const snapshotOpenShellEnv = openshellRuntime.snapshotOpenShellEnv; + type StartGatewayForRecoveryOptions = { gatewayName?: string; gatewayPort?: number; diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index d1bb8c039c1..27969486766 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -3,7 +3,8 @@ import { findDashboardForwardOwner } from "./dashboard-port"; import { - buildOpenShellRuntimeSelectionEnv, + replaceOpenShellRuntimeSelectionEnv, + snapshotOpenShellEnv, type OpenShellRuntimeSelection, } from "../adapters/openshell/runtime-selection"; import { resolveGatewayName } from "./gateway-binding"; @@ -46,32 +47,9 @@ function beginOpenShellRuntimeSelectionEnvScope( runtimeSelection: OpenShellRuntimeSelection, env: NodeJS.ProcessEnv, ): () => void { - const previous = Object.fromEntries( - Object.entries(env).filter( - (entry): entry is [string, string] => - entry[0].startsWith("OPENSHELL_") && entry[1] !== undefined, - ), - ); - const baseEnv = Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); - const selected = buildOpenShellRuntimeSelectionEnv(baseEnv, runtimeSelection); - for (const name of Object.keys(env)) { - if (name.startsWith("OPENSHELL_")) delete env[name]; - } - for (const [name, value] of Object.entries(selected)) { - if (name.startsWith("OPENSHELL_")) env[name] = value; - } - - 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); - }; + const restore = snapshotOpenShellEnv(env); + replaceOpenShellRuntimeSelectionEnv(env, runtimeSelection); + return restore; } /** Keep every OpenShell child in an inner rebuild onboard on its frozen target. */ diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index 22753126395..c7171f9a0c3 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -157,6 +157,9 @@ export type RebuildFlowHarness = { restoreTrustedAgentRemoteBaseImageOverrideSpy: MockInstance; executeSandboxCommandSpy: MockInstance; executeSandboxExecCommandSpy: MockInstance; + gatewayRecoverySpy: MockInstance; + gatewaySchemaSpy: MockInstance; + mcpRuntimeSelectionResolverSpy: MockInstance; checkAndRecoverSandboxProcessesSpy: MockInstance; restartSandboxGatewaySpy: MockInstance; ensureMessagingHostForwardAfterRebuildSpy: MockInstance; @@ -247,7 +250,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): requiredCapabilities: [], }); - vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null); + const gatewaySchemaSpy = vi + .spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue") + .mockReturnValue(null); vi.spyOn(gatewayDrift, "detectOpenShellStateRpcResultIssue").mockReturnValue(null); vi.spyOn(gatewayTeardownAuthority, "resolveGatewayTeardownAuthority").mockImplementation( resolveGatewayAuthority, @@ -328,8 +333,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): : ({ name: sessionAgentName } as never), ); vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue(agentDisplayName); - vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockImplementation( - async (...args: unknown[]) => { + const gatewayRecoverySpy = vi + .spyOn(gatewayRuntime, "recoverNamedGatewayRuntime") + .mockImplementation(async (...args: unknown[]) => { const gatewayName = (args[0] as { gatewayName?: string } | undefined)?.gatewayName ?? "nemoclaw"; const state = { state: "healthy_named", activeGateway: gatewayName }; @@ -341,8 +347,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): after: state, } ); - }, - ); + }); vi.spyOn(gatewayState, "getReconciledSandboxGatewayState").mockResolvedValue( overrides.reconciledSandboxGatewayState ?? { state: "present", output: "alpha Ready" }, ); @@ -756,10 +761,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): gatewayName: "nemoclaw", workspace: "default", }; - vi.spyOn( - mcpBridgeProviderInspection, - "getMcpProviderInspectionRuntimeSelection", - ).mockReturnValue(mcpRuntimeSelection); + const mcpRuntimeSelectionResolverSpy = vi + .spyOn(mcpBridgeProviderInspection, "getMcpProviderInspectionRuntimeSelection") + .mockReturnValue(mcpRuntimeSelection); const emptyMcpPreparation = { entries: [], detachedProviderEntries: [], @@ -801,6 +805,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): restoreTrustedAgentRemoteBaseImageOverrideSpy, executeSandboxCommandSpy, executeSandboxExecCommandSpy, + gatewayRecoverySpy, + gatewaySchemaSpy, + mcpRuntimeSelectionResolverSpy, checkAndRecoverSandboxProcessesSpy, restartSandboxGatewaySpy, ensureMessagingHostForwardAfterRebuildSpy, diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index c8806a5712e..962ae17716d 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -106,7 +106,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): requiredCapabilities: [], }); - vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null); + const gatewaySchemaSpy = vi + .spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue") + .mockReturnValue(null); vi.spyOn(gatewayDrift, "detectOpenShellStateRpcResultIssue").mockReturnValue(null); vi.spyOn(gatewayTeardownAuthority, "resolveGatewayTeardownAuthority").mockImplementation( resolveGatewayAuthority, @@ -193,7 +195,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const warnUnpreservedUserManagedFilesSpy = vi .spyOn(rebuildFlowHelpers, "warnUnpreservedUserManagedFiles") .mockImplementation(() => undefined); - vi.spyOn(resolve, "resolveOpenshell").mockReturnValue(null); + vi.spyOn(resolve, "resolveOpenshell").mockReturnValue(overrides.openshellBinary ?? null); vi.spyOn(agentDefs, "loadAgent").mockReturnValue(agentDef); vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue( agentDef.name === "openclaw" ? null : ({ name: agentDef.name } as never), @@ -395,11 +397,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): } return true; }); - vi.spyOn(sandboxSession, "getActiveSandboxSessions").mockReturnValue({ - detected: false, - sessions: [], + vi.spyOn(sandboxSession, "getActiveSandboxSessions").mockImplementation(() => { + overrides.beforeActiveSessionCount?.(); + return { detected: false, sessions: [] }; }); vi.spyOn(sandboxVersion, "checkAgentVersion").mockImplementation(() => { + overrides.beforeVersionCheck?.(); Object.assign(currentSandboxEntry, overrides.entryUpdatesAfterVersionCheck ?? {}); return ( overrides.versionCheck ?? { @@ -671,9 +674,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): gatewayName: "nemoclaw", workspace: "default", }; - vi.spyOn(mcpBridgeProvider, "getMcpProviderInspectionRuntimeSelection").mockReturnValue( - mcpRuntimeSelection, - ); + const mcpRuntimeSelectionResolverSpy = vi + .spyOn(mcpBridgeProvider, "getMcpProviderInspectionRuntimeSelection") + .mockReturnValue(mcpRuntimeSelection); const mcpPreparation = overrides.mcpPreparation ? { ...overrides.mcpPreparation, runtimeSelection: mcpRuntimeSelection } : undefined; @@ -715,6 +718,8 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): errorSpy, executeSandboxCommandSpy, executeSandboxExecCommandSpy, + gatewaySchemaSpy, + mcpRuntimeSelectionResolverSpy, ensureMessagingHostForwardAfterRebuildSpy, ensureRebuildAgentBaseImageSpy, ensureTargetGatewaySpy, diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index c98c4ce8f25..4348b9b8cff 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -90,6 +90,7 @@ export type RebuildFlowOverrides = { manifest: Record, ) => { ok: true; manifest: Record } | { ok: false; reason: string }; managedImageEvidence?: boolean; + openshellBinary?: string | null; staleRecovery?: boolean; reconciledSandboxGatewayState?: SandboxGatewayState; mcpPreparation?: { @@ -124,6 +125,8 @@ export type RebuildFlowOverrides = { error?: Error; }; backupPreservedEnv?: PreservedEnvFile[]; + beforeActiveSessionCount?: () => void; + beforeVersionCheck?: () => void; ensureValidatedBraveSearchCredential?: () => Promise; ensureValidatedWebSearchCredential?: () => Promise; hermesCredentialKeys?: string[] | null; @@ -148,6 +151,8 @@ export type RebuildFlowHarness = { errorSpy: MockInstance; executeSandboxCommandSpy: MockInstance; executeSandboxExecCommandSpy: MockInstance; + gatewaySchemaSpy: MockInstance; + mcpRuntimeSelectionResolverSpy: MockInstance; ensureMessagingHostForwardAfterRebuildSpy: MockInstance; ensureRebuildAgentBaseImageSpy: MockInstance; ensureTargetGatewaySpy: MockInstance; From c84baf4ff1530e712fd5cd0349d42e13630ae869 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 19:09:16 -0700 Subject: [PATCH 11/13] fix(openshell): explain target-drift recovery Signed-off-by: Apurv Kumaria --- .../sandbox/mcp-bridge-rebuild-exec-unavailable.ts | 2 +- src/lib/actions/sandbox/rebuild-destroy-phase.test.ts | 11 ++++------- src/lib/actions/sandbox/rebuild-destroy-phase.ts | 4 ++-- test/mcp/mcp-destroy-lifecycle.test.ts | 5 ++++- 4 files changed, 11 insertions(+), 11 deletions(-) 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 97d35e727cc..49ae86fa9ce 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -144,7 +144,7 @@ async function inspectReadOnlyRecoveryState( currentRuntimeSelection.localTlsDir !== expectedRuntimeSelection.localTlsDir) ) { throw new McpBridgeError( - `Sandbox '${sandboxName}' changed its MCP gateway authority before host-side rebuild recovery could inspect providers. Refusing to continue on a different target.`, + `Sandbox '${sandboxName}' changed its MCP gateway authority before host-side rebuild recovery could inspect providers. Refusing to continue on a different target. NemoClaw did not delete the original sandbox. Retry after the recorded OpenShell gateway is stable.`, ); } const providerRuntimeSelection = expectedRuntimeSelection ?? currentRuntimeSelection; diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts index aa51553fe91..992715311bc 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -381,7 +381,9 @@ describe("rebuild destroy phase", () => { relockShieldsIfNeeded: vi.fn(() => true), onDeleted: vi.fn(), }), - ).rejects.toThrow("Rebuild delete target does not match the frozen OpenShell target"); + ).rejects.toThrow( + "Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. Retry after the recorded OpenShell gateway is stable.", + ); expectNoSandboxDelete(mocks.runOpenshell); }); @@ -489,12 +491,7 @@ describe("rebuild destroy phase", () => { expect(revalidateBeforeDelete).toHaveBeenCalledOnce(); expect(mocks.runOpenshell).not.toHaveBeenCalled(); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); - expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith( - "alpha", - [], - [], - undefined, - ); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith("alpha", [], [], undefined); expect(mocks.stopNimContainer).not.toHaveBeenCalled(); expect(mocks.stopNimContainerByName).not.toHaveBeenCalled(); expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index 80d10fe97aa..af6893b4d74 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -368,8 +368,8 @@ export async function runRebuildDestroyPhase( relockShieldsIfNeeded(true); bail( mcpRecoveryFailure - ? `Rebuild delete target does not match the frozen OpenShell target; MCP provider recovery also failed: ${mcpRecoveryFailure}` - : "Rebuild delete target does not match the frozen OpenShell target.", + ? `Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. MCP provider recovery also failed: ${mcpRecoveryFailure}. Retry after the recorded OpenShell gateway is stable.` + : "Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. Retry after the recorded OpenShell gateway is stable.", ); return null; } diff --git a/test/mcp/mcp-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index 904fc6d9a65..b4e9507c27e 100644 --- a/test/mcp/mcp-destroy-lifecycle.test.ts +++ b/test/mcp/mcp-destroy-lifecycle.test.ts @@ -70,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, })); @@ -665,6 +666,8 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { ); expect(message).toMatch(/changed its MCP gateway authority.*different target/i); + expect(message).toContain("NemoClaw did not delete the original sandbox"); + expect(message).toContain("Retry after the recorded OpenShell gateway is stable"); expect(registry.getSandbox("alpha")).toEqual(before); expect(testState.calls).toEqual([]); expect(testState.adapterCalls).toEqual([]); From 0bbe283de7e1bc54c5895653af048e436c764ce5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 1 Sep 2026 11:47:39 -0700 Subject: [PATCH 12/13] fix(mcp): complete runtime target review evidence Signed-off-by: Apurv Kumaria --- ...mcp-bridge-adapter-hermes-branding.test.ts | 34 ++++++++++++------- .../mcp-bridge-rebuild-exec-unavailable.ts | 6 ++-- .../sandbox/rebuild-destroy-phase.test.ts | 2 +- .../actions/sandbox/rebuild-destroy-phase.ts | 4 +-- test/mcp/mcp-destroy-lifecycle.test.ts | 4 ++- 5 files changed, 31 insertions(+), 19 deletions(-) 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 3342d06e8fb..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,16 +3,13 @@ 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", () => ({ @@ -51,14 +48,16 @@ describe("Hermes MCP recovery guidance", () => { 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(); }); @@ -72,12 +71,23 @@ describe("Hermes MCP recovery guidance", () => { 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.runOpenshellProviderCommand.mockImplementation((_args, options) => { - expect(options?.runtimeSelection).toEqual({ - gatewayName: "nemoclaw-8091", - workspace: "default", - }); + 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 }), @@ -89,6 +99,6 @@ describe("Hermes MCP recovery guidance", () => { assertHermesMcpMutationRuntimeCapability("alpha", runtimeSelection), ).not.toThrow(); expect(() => unregisterHermesAdapter("alpha", entry, runtimeSelection)).not.toThrow(); - expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(2); + expect(mocks.runOpenshell).toHaveBeenCalledTimes(2); }); }); 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 49ae86fa9ce..41a1cdc3a72 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -144,7 +144,7 @@ async function inspectReadOnlyRecoveryState( currentRuntimeSelection.localTlsDir !== expectedRuntimeSelection.localTlsDir) ) { throw new McpBridgeError( - `Sandbox '${sandboxName}' changed its MCP gateway authority before host-side rebuild recovery could inspect providers. Refusing to continue on a different target. NemoClaw did not delete the original sandbox. Retry after the recorded OpenShell gateway is stable.`, + `Sandbox '${sandboxName}' changed its MCP gateway authority before host-side rebuild recovery could inspect providers. Refusing to continue on a different target. NemoClaw did not delete the original sandbox. Confirm the recorded OpenShell gateway is healthy and its gateway name, workspace, and TLS authority match the sandbox's recorded target, then retry.`, ); } const providerRuntimeSelection = expectedRuntimeSelection ?? currentRuntimeSelection; @@ -187,8 +187,8 @@ function assertValidationSnapshotCurrent( if (drifted || targetChanged) { throw new McpBridgeError( drifted - ? `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.` - : `Sandbox MCP gateway authority changed after host-side rebuild preflight. Refusing to delete the still-live sandbox; retry after its gateway state is stable.`, + ? `MCP server '${drifted.server}' changed after host-side rebuild preflight. NemoClaw did not delete the original sandbox. Confirm the recorded OpenShell gateway 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. NemoClaw did not delete the original sandbox. Confirm the recorded OpenShell gateway is healthy and its gateway name, workspace, and TLS authority match the sandbox's recorded target, then retry.`, ); } } diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts index 992715311bc..483b344c1e9 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -382,7 +382,7 @@ describe("rebuild destroy phase", () => { onDeleted: vi.fn(), }), ).rejects.toThrow( - "Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. Retry after the recorded OpenShell gateway is stable.", + "Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. Confirm the recorded OpenShell gateway is healthy and its gateway name, workspace, and TLS authority match the original sandbox, then retry.", ); expectNoSandboxDelete(mocks.runOpenshell); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index af6893b4d74..5a2671dea32 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -368,8 +368,8 @@ export async function runRebuildDestroyPhase( relockShieldsIfNeeded(true); bail( mcpRecoveryFailure - ? `Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. MCP provider recovery also failed: ${mcpRecoveryFailure}. Retry after the recorded OpenShell gateway is stable.` - : "Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. Retry after the recorded OpenShell gateway is stable.", + ? `Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. MCP provider recovery also failed: ${mcpRecoveryFailure}. Confirm the recorded OpenShell gateway is healthy and its gateway name, workspace, and TLS authority match the original sandbox, then retry.` + : "Rebuild delete target does not match the frozen OpenShell target. NemoClaw did not delete the original sandbox. Confirm the recorded OpenShell gateway is healthy and its gateway name, workspace, and TLS authority match the original sandbox, then retry.", ); return null; } diff --git a/test/mcp/mcp-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index b4e9507c27e..894ec01588b 100644 --- a/test/mcp/mcp-destroy-lifecycle.test.ts +++ b/test/mcp/mcp-destroy-lifecycle.test.ts @@ -667,7 +667,9 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(message).toMatch(/changed its MCP gateway authority.*different target/i); expect(message).toContain("NemoClaw did not delete the original sandbox"); - expect(message).toContain("Retry after the recorded OpenShell gateway is stable"); + expect(message).toContain( + "Confirm the recorded OpenShell gateway is healthy and its gateway name, workspace, and TLS authority match the sandbox's recorded target, then retry", + ); expect(registry.getSandbox("alpha")).toEqual(before); expect(testState.calls).toEqual([]); expect(testState.adapterCalls).toEqual([]); From 341db9ab6d9b1d02c7b0fc22db89bc1ef06ee7ce Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 1 Sep 2026 12:21:55 -0700 Subject: [PATCH 13/13] test(sandbox): preserve runtime mock exports Signed-off-by: Apurv Kumaria --- ...pshot-command-host-local-authority.test.ts | 3 ++- .../snapshot-failed-create-cleanup.test.ts | 3 ++- .../deepagents-mcp-legacy-lifecycle.test.ts | 19 +++++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) 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/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;