Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand All @@ -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,
Expand All @@ -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",
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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);
});
});
8 changes: 8 additions & 0 deletions src/lib/actions/sandbox/mcp-bridge-adapter-hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof runOpenshellProviderCommand>;
Expand All @@ -133,6 +136,7 @@ export function assertHermesMcpMutationRuntimeCapability(sandboxName: string): v
),
{
ignoreError: true,
runtimeSelection,
stdio: ["ignore", "pipe", "pipe"],
timeout: 45_000,
},
Expand Down Expand Up @@ -232,8 +236,12 @@ function runHermesAdapterCommand(
// placeholder and endpoint metadata.
let result: ReturnType<typeof runOpenshellProviderCommand>;
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
Expand Down
36 changes: 35 additions & 1 deletion src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -42,6 +43,10 @@ vi.mock("./mcp-bridge-provider", () => ({
assertNoProviderCredentialCollisions: vi.fn(),
assertNoRegisteredProviderCredentialCollisions: vi.fn(),
detachProvider: vi.fn(),
getMcpProviderInspectionRuntimeSelection: vi.fn(() => ({
gatewayName: "nemoclaw-8091",
workspace: "default",
})),
inspectMcpProvider: mocks.inspectMcpProvider,
preflightMcpEntryTargets: vi.fn(),
waitForDetachedMcpCredential: vi.fn(),
Expand All @@ -50,7 +55,7 @@ vi.mock("./mcp-bridge-provider", () => ({
vi.mock("./mcp-bridge-destroy-preflight", () => ({
cloneMcpBridgeEntry: vi.fn((entry: McpBridgeEntry) => ({ ...entry, env: [...entry.env] })),
discardSafeIncompleteMcpAdds: mocks.discardSafeIncompleteMcpAdds,
inspectExactMcpDestroyProvider: vi.fn(),
inspectExactMcpDestroyProvider: mocks.inspectExactMcpDestroyProvider,
}));

vi.mock("./mcp-bridge-policy", () => ({
Expand Down Expand Up @@ -118,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(() => {
Expand Down Expand Up @@ -176,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"]);
});
});
51 changes: 36 additions & 15 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 @@ -96,6 +97,7 @@ function assertPreparedMcpAddResourcesAbsent(
adapter: AgentMcpAdapter,
entry: McpBridgeEntry,
target: McpBridgeTargetValidation,
providerRuntimeSelection: ReturnType<typeof getMcpProviderInspectionRuntimeSelection>,
): void {
const adapterInspection = inspectAgentAdapterRegistration(sandboxName, adapter, entry);
if (adapterInspection.state !== "absent") {
Expand All @@ -108,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
Expand Down Expand Up @@ -185,6 +187,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.
assertMcpDestroyNotPending(sandbox);
const agent = getSandboxAgent(sandbox);
const adapter = getBridgeAdapter(agent);
Expand Down Expand Up @@ -311,7 +314,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 All @@ -323,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 ??
Expand All @@ -337,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;
}
}
Expand All @@ -350,15 +353,21 @@ 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;
}
}

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
Expand All @@ -381,8 +390,8 @@ async function addMcpBridgeUnlocked(
// Credential keys are sandbox-global. Prove this key is not already
// supplied by a foreign attachment before opening its MCP route, then check
// again after provider creation to close the intervening race.
assertNoProviderCredentialCollisions(sandboxName, [entry]);
ensureMcpBridgeProviderProfile();
assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection);
ensureMcpBridgeProviderProfile(providerRuntimeSelection);
// Load the real protocol:mcp policy without a credential binding before
// provider mutation. OpenShell requires the endpointless provider to be
// attached before it accepts credential_binding.provider, and withholds
Expand All @@ -395,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
Expand All @@ -418,14 +428,14 @@ async function addMcpBridgeUnlocked(
// adapter mutations. A process death before this write fails closed.
writeBridgeEntry(sandboxName, entry);
}
assertNoProviderCredentialCollisions(sandboxName, [entry]);
assertNoProviderCredentialCollisions(sandboxName, [entry], providerRuntimeSelection);
if (providerResult.action === "updated" && previousCredentialRevision === undefined) {
throw new McpBridgeError(
`Could not retain the prior OpenShell credential revision for provider '${entry.providerName}'.`,
);
}
providerAttachAttempted = true;
attachProvider(sandboxName, entry);
attachProvider(sandboxName, entry, providerRuntimeSelection);
applyGeneratedPolicy(sandboxName, entry, target);
let refreshedAfterObservedAbsence = false;
let credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, {
Expand Down Expand Up @@ -455,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) {
Expand All @@ -469,6 +482,7 @@ async function addMcpBridgeUnlocked(
allowExisting: true,
expectedProviderId: entry.providerId,
requireExisting: true,
runtimeSelection: providerRuntimeSelection,
});
credentialRevision = waitForAttachedMcpCredential(sandboxName, entry, {
previousRevision: credentialRevision,
Expand All @@ -492,7 +506,7 @@ async function addMcpBridgeUnlocked(
} catch (error) {
const rollbackProviderInspection =
(providerAttachAttempted || providerCreated) && entry.providerId
? inspectMcpProvider(providerName)
? inspectMcpProvider(providerName, providerRuntimeSelection)
: undefined;
const rollbackProviderOwned =
!!rollbackProviderInspection &&
Expand All @@ -508,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") {
Expand All @@ -520,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.
Expand Down
Loading
Loading