Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
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.
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
20 changes: 16 additions & 4 deletions src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
removeGeneratedPolicy,
} from "./mcp-bridge-policy";
import {
getMcpProviderInspectionRuntimeSelection,
inspectMcpProvider,
type McpProviderInspectionRuntimeSelection,
type McpProviderInspection,
providerMatchesManagedCredential,
providerShapeDetail,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -122,15 +125,19 @@ 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) {
throw new McpBridgeError(
`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}'.`,
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 25 additions & 5 deletions src/lib/actions/sandbox/mcp-bridge-destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import {
deleteProvider,
detachProvider,
getMcpProviderInspectionRuntimeSelection,
inspectMcpProvider,
waitForDetachedMcpCredential,
} from "./mcp-bridge-provider";
Expand Down Expand Up @@ -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");
Expand All @@ -94,6 +96,7 @@ export async function prepareMcpBridgesForDestroy(
for (const entry of entries) {
inspectExactMcpDestroyProvider(entry, {
allowMissing: destroyAlreadyPending,
runtimeSelection: providerRuntimeSelection,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
}
if (destroyAlreadyPending) {
Expand Down Expand Up @@ -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}'.`,
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
});
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -319,17 +333,23 @@ export async function finalizeMcpBridgesAfterSandboxDelete(
inspectExactMcpDestroyProvider(entry, {
allowMissing: true,
force: options.force,
runtimeSelection: providerRuntimeSelection,
}),
);
for (const [index, entry] of entries.entries()) {
if (!inspections[index]?.exists) continue;
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 ??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function sandbox(overrides: Partial<SandboxEntry> = {}): SandboxEntry {
return {
name: "alpha",
agent: "hermes",
gatewayName: "nemoclaw-8091",
mcp: {
bridges: { github: entry },
managedServerNames: ["github", "retired"],
Expand Down Expand Up @@ -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)", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -161,10 +162,12 @@ export function inspectHermesMcpRuntimeIntent(
managedServerNames,
options.credentialRevisions,
);
const runtimeSelection = getMcpProviderInspectionRuntimeSelection(sandbox);
let result: ReturnType<typeof runOpenshellProviderCommand>;
try {
result = runOpenshellProviderCommand(buildInspectArgs(sandboxName, JSON.stringify(payload)), {
ignoreError: true,
runtimeSelection,
stdio: ["ignore", "pipe", "pipe"],
timeout: HERMES_MCP_INSPECT_TIMEOUT_MS,
});
Expand Down
Loading
Loading