Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b150040
fix(mcp): pin provider inspection target
apurvvkumaria Aug 30, 2026
ab24e8d
fix(mcp): pin collision inspection target
apurvvkumaria Aug 30, 2026
66c37e4
fix(mcp): pin provider lifecycle target
apurvvkumaria Aug 31, 2026
52e0890
fix(mcp): pin provider lifecycle commands
apurvvkumaria Aug 31, 2026
8aa8e1f
test(mcp): seed Hermes startup target
apurvvkumaria Aug 31, 2026
32bb524
test(mcp): assert Hermes startup target
apurvvkumaria Aug 31, 2026
627e9e3
merge: resolve conflicts with main
github-actions[bot] Aug 31, 2026
b7366ad
fix(mcp): bind lifecycle operations to sandbox target
apurvvkumaria Aug 31, 2026
9b21776
merge: reconcile current main into provider target hardening
apurvvkumaria Aug 31, 2026
3d8de5d
merge: reconcile Hermes lifecycle package API
apurvvkumaria Aug 31, 2026
b980933
fix(rebuild): bind authoritative preflight target
apurvvkumaria Aug 31, 2026
f1e6024
test(ci): isolate runtime target fixtures
apurvvkumaria Aug 31, 2026
b96a765
fix(openshell): replace ambient runtime selectors
apurvvkumaria Sep 1, 2026
c84baf4
fix(openshell): explain target-drift recovery
apurvvkumaria Sep 1, 2026
3199053
merge: resolve conflicts with main
github-actions[bot] Sep 1, 2026
31d7fb6
merge: integrate current main into provider target hardening
apurvvkumaria Sep 1, 2026
0bbe283
fix(mcp): complete runtime target review evidence
apurvvkumaria Sep 1, 2026
341db9a
test(sandbox): preserve runtime mock exports
apurvvkumaria Sep 1, 2026
4ccffcb
merge(main): sync current main
apurvvkumaria Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/lib/actions/sandbox/mcp-bridge-add-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
detachMissingProviderReference,
detachProvider,
ensureMcpBridgeProviderProfile,
getMcpProviderInspectionRuntimeSelection,
inspectMcpProvider,
type McpCredentialRevisionObservation,
observeMcpCredentialRevision,
Expand Down Expand Up @@ -185,6 +186,7 @@ async function addMcpBridgeUnlocked(
}
const matchingTrustedPrivateHosts = allTrustedPrivateHosts.filter((host) => host === urlHost);
const sandbox = getSandboxOrThrow(sandboxName);
const providerRuntimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
assertMcpDestroyNotPending(sandbox);
const agent = getSandboxAgent(sandbox);
const adapter = getBridgeAdapter(agent);
Expand Down Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}'.`,
Expand Down
59 changes: 59 additions & 0 deletions src/lib/actions/sandbox/mcp-bridge-provider-inspection.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
},
);
});
47 changes: 38 additions & 9 deletions src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +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";
Expand Down Expand Up @@ -36,6 +39,20 @@ export type McpProviderAttachmentInspection = {
error?: string;
};

export type McpProviderInspectionRuntimeSelection = {
gatewayName: string;
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<McpProviderInspection, "exists"> {
Expand Down Expand Up @@ -65,7 +82,10 @@ export function parseMcpProviderMetadata(output: string): Omit<McpProviderInspec
};
}

export function inspectMcpProvider(providerName: string | undefined): McpProviderInspection {
export function inspectMcpProvider(
providerName: string | undefined,
runtimeSelection?: McpProviderInspectionRuntimeSelection,
): McpProviderInspection {
if (!providerName) {
return {
exists: false,
Expand All @@ -77,11 +97,12 @@ export function inspectMcpProvider(providerName: string | undefined): McpProvide
}
const result = runOpenshellProviderCommand(["provider", "get", providerName], {
ignoreError: true,
runtimeSelection,
stdio: ["ignore", "pipe", "pipe"],
}) as OpenShellCommandResult;
if (result.status !== 0) {
const output = commandOutput(result);
if (/not\s+found|NotFound|does\s+not\s+exist|unknown\s+provider/i.test(output)) {
if (result.status === 1 && reportsExactProviderNotFound(output, providerName, output.length)) {
return {
exists: false,
id: null,
Expand Down Expand Up @@ -125,9 +146,11 @@ export function parseMcpProviderAttachmentNames(output: string): string[] {

export function inspectMcpProviderAttachments(
sandboxName: string,
runtimeSelection?: McpProviderInspectionRuntimeSelection,
): McpProviderAttachmentInspection {
const result = runOpenshellProviderCommand(["sandbox", "provider", "list", sandboxName], {
ignoreError: true,
runtimeSelection,
stdio: ["ignore", "pipe", "pipe"],
}) as OpenShellCommandResult;
const output = commandOutput(result);
Expand All @@ -139,7 +162,7 @@ export function inspectMcpProviderAttachments(
if (/^No providers attached to sandbox\b/m.test(clean)) return { attachments: [] };
const names = parseMcpProviderAttachmentNames(clean);
const attachments = names.map((name) => {
const provider = inspectMcpProvider(name);
const provider = inspectMcpProvider(name, runtimeSelection);
if (
provider.exists !== true ||
!provider.id ||
Expand Down Expand Up @@ -169,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}'.`,
Expand All @@ -198,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;
Expand All @@ -230,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(
Expand Down Expand Up @@ -406,9 +434,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);
}
98 changes: 95 additions & 3 deletions src/lib/actions/sandbox/mcp-bridge-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/mcp-bridge-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
assertNoAttachedProviderCredentialCollisions,
assertNoProviderCredentialCollisions,
assertNoRegisteredProviderCredentialCollisions,
getMcpProviderInspectionRuntimeSelection,
inspectMcpProvider,
inspectMcpProviderAttachments,
MCP_BRIDGE_PROVIDER_TYPE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "./mcp-bridge-destroy-preflight";
import {
assertNoProviderCredentialCollisions,
getMcpProviderInspectionRuntimeSelection,
preflightMcpEntryTargets,
} from "./mcp-bridge-provider";
import {
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading
Loading