Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ab7ac4
refactor(destroy): pin recorded runtime target
apurvvkumaria Sep 1, 2026
6426638
refactor(rebuild): preserve recorded runtime target
apurvvkumaria Sep 1, 2026
5ab2c86
merge: refresh destroy target stack from MCP
apurvvkumaria Sep 1, 2026
4febccb
merge: refresh rebuild target stack from destroy
apurvvkumaria Sep 1, 2026
29859bf
refactor(destroy): pin recorded runtime target
apurvvkumaria Sep 1, 2026
8fdc83d
refactor(rebuild): preserve recorded runtime target
apurvvkumaria Sep 1, 2026
21f7343
chore(stack): include repaired PR 10815 ancestry
prekshivyas Sep 3, 2026
771bc50
chore(stack): retain PR 10816 branch ancestry
prekshivyas Sep 3, 2026
8e42938
chore(stack): include repaired PR 10816 ancestry
prekshivyas Sep 3, 2026
4a96825
chore(stack): retain PR 10817 branch ancestry
prekshivyas Sep 3, 2026
187646b
chore(stack): include refreshed PR 10815 ancestry
prekshivyas Sep 3, 2026
dba1c2f
chore(stack): include refreshed PR 10816 ancestry
prekshivyas Sep 3, 2026
5765470
fix(rebuild): preserve forward workspace
prekshivyas Sep 3, 2026
838f434
chore(stack): include PR 10815 review fix
prekshivyas Sep 3, 2026
b5e6cec
chore(stack): include PR 10816 review ancestry
prekshivyas Sep 3, 2026
ebafb57
chore(stack): include refreshed PR 10815 ancestry
prekshivyas Sep 3, 2026
4023ff3
chore(stack): include refreshed PR 10816 ancestry
prekshivyas Sep 3, 2026
582e610
merge: resolve PR 10817 conflicts with MCP target
prekshivyas Sep 4, 2026
4bbe5fa
fix(rebuild): keep prepared-only MCP cleanup local
prekshivyas Sep 4, 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
7 changes: 5 additions & 2 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"src/lib/actions/sandbox/process-recovery.ts": 27,
"src/lib/adapters/docker/index.ts": 43,
"src/lib/adapters/openshell/client.ts": 20,
"src/lib/adapters/openshell/command-argv.ts": 25,
"src/lib/adapters/openshell/resolve.ts": 27,
"src/lib/adapters/openshell/runtime.ts": 55,
"src/lib/adapters/openshell/timeouts.ts": 38,
Expand Down Expand Up @@ -40,10 +41,11 @@
"src/lib/actions/sandbox/connect.ts": 43,
"src/lib/actions/sandbox/destroy.ts": 29,
"src/lib/actions/sandbox/doctor.ts": 27,
"src/lib/actions/sandbox/forward-recovery.ts": 21,
"src/lib/actions/sandbox/gateway-state.ts": 21,
"src/lib/actions/sandbox/status-snapshot.ts": 19,
"src/lib/actions/sandbox/policy-channel.ts": 30,
"src/lib/actions/sandbox/process-recovery.ts": 21,
"src/lib/actions/sandbox/process-recovery.ts": 22,
"src/lib/actions/sandbox/rebuild-pipeline.ts": 29,
"src/lib/actions/sandbox/snapshot.ts": 38,
"src/lib/actions/uninstall/run-plan.ts": 25,
Expand All @@ -54,7 +56,8 @@
"src/lib/onboard/machine/handlers/sandbox.ts": 21,
"src/lib/policy/index.ts": 23,
"src/lib/sandbox/config.ts": 22,
"src/lib/shields/index.ts": 25
"src/lib/shields/index.ts": 25,
"src/lib/state/sandbox.ts": 21
}
},
"allowedCycles": [],
Expand Down
3 changes: 3 additions & 0 deletions src/lib/actions/sandbox/backup-shields-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,6 +19,7 @@ export interface BackupShieldsWindowOptions {
shieldsUpCommand: string;
deferAutoRestoreWhileOwnerAlive?: boolean;
allowLegacyHermesProtocol?: boolean;
runtimeSelection?: OpenShellRuntimeSelection;
}

export function openBackupShieldsWindow(
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/lib/actions/sandbox/destroy-confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions src/lib/actions/sandbox/destroy-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 [];
Expand Down Expand Up @@ -47,8 +54,9 @@ export function assertSandboxDestroyCommandAvailable(sandboxName: string): void
export async function confirmSandboxDestroy(
sandboxName: string,
options: DestroySandboxOptions,
runtimeSelection?: OpenShellRuntimeSelection,
): Promise<boolean> {
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
Expand Down
103 changes: 93 additions & 10 deletions src/lib/actions/sandbox/destroy-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { isDeepStrictEqual } from "node:util";

import { buildSelectedOpenShellSubprocessEnv } from "../../adapters/openshell/command-argv";
import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection";
import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy";
import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/policy-state";
import { R, YW } from "../../cli/terminal-style";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -94,6 +97,7 @@ export type SandboxDestroyExecutionResult =
deleteResult: ReturnType<DestroyRunOpenshell>;
detachOutcome: DetachSandboxProvidersResult;
forcedLocalCleanup: boolean;
runtimeSelection?: OpenShellRuntimeSelection;
/** Common lifecycle conclusively retired this row's explicit llama.cpp claim. */
commonLlamaCppAuthorityRetired?: true;
}
Expand All @@ -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 } : {}),
};
}

Expand All @@ -134,13 +141,20 @@ async function prepareMcpDestroy(
sandbox: SandboxEntry | null,
sandboxConfirmedAbsent: boolean,
force: boolean,
runtimeSelection?: McpDestroyPreparation["runtimeSelection"],
): Promise<McpDestroyPreparation> {
if (Object.keys(sandbox?.mcp?.bridges ?? {}).length === 0) {
return emptyMcpDestroyPreparation();
return emptyMcpDestroyPreparation(runtimeSelection);
}
const preparation = sandboxConfirmedAbsent
? await prepareMcpBridgesForAbsentSandboxDestroy(sandboxName, { force })
: await prepareMcpBridgesForDestroy(sandboxName, { force });
? await prepareMcpBridgesForAbsentSandboxDestroy(sandboxName, {
force,
...(runtimeSelection ? { runtimeSelection } : {}),
})
: await prepareMcpBridgesForDestroy(sandboxName, {
force,
...(runtimeSelection ? { runtimeSelection } : {}),
});
if (sandboxConfirmedAbsent && preparation.entries.length > 0) {
console.warn(
` ${YW}⚠${R} Sandbox '${sandboxName}' is already absent, so its retained-volume MCP adapter entry cannot be scrubbed in place. Exact OpenShell providers will be deleted so any stale credential placeholder cannot authenticate; same-name onboarding may need to replace stale MCP adapter config.`,
Expand All @@ -154,12 +168,17 @@ function wipeAndHardenLiveSandbox(
sandboxRuntimeConfirmedAbsent: boolean,
cliName: string,
deps: NonNullable<SandboxDestroyExecutionInput["deps"]> = {},
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 };

Expand All @@ -171,6 +190,7 @@ function wipeAndHardenLiveSandbox(
shieldsUp(sandboxName, {
throwOnError: true,
allowLegacyHermesProtocol: true,
...(runtimeSelection ? { runtimeSelection } : {}),
});
} catch (error) {
/**
Expand Down Expand Up @@ -251,6 +271,9 @@ async function restoreMcpAfterDeleteAbort(
allowLegacyHermesProtocol: true,
deferAutoRestoreWhileOwnerAlive: true,
processToken: hardened.timerProcessToken,
...(preparation.runtimeSelection
? { runtimeSelection: preparation.runtimeSelection }
: {}),
});
openedRollbackWindow = true;
}
Expand All @@ -264,6 +287,9 @@ async function restoreMcpAfterDeleteAbort(
shieldsUp(sandboxName, {
throwOnError: true,
allowLegacyHermesProtocol: true,
...(preparation.runtimeSelection
? { runtimeSelection: preparation.runtimeSelection }
: {}),
});
} catch (error) {
const detail = redactDestroyError(error);
Expand Down Expand Up @@ -302,6 +328,7 @@ export async function executeSandboxDestroy({
getSandbox,
listSandboxes,
runOpenshell,
mcpRuntimeSelection,
sandbox,
sandboxConfirmedAbsent,
sandboxName,
Expand All @@ -313,6 +340,7 @@ export async function executeSandboxDestroy({
deps = {},
}: SandboxDestroyExecutionInput): Promise<SandboxDestroyExecutionResult> {
return withTimerBoundShieldsMutationLockAsync(sandboxName, "destroy sandbox", async () => {
let destroyRuntimeSelection = mcpRuntimeSelection;
type IdentityContinuity =
| { status: "match" }
| { status: "changed"; subject?: string }
Expand Down Expand Up @@ -359,6 +387,7 @@ export async function executeSandboxDestroy({
const liveFingerprint = inspectIdentity({
sandboxName,
gatewayName: pendingCreateIdentity.gatewayName,
...(destroyRuntimeSelection ? { runtimeSelection: destroyRuntimeSelection } : {}),
});
if (
liveFingerprint !== pendingCreateIdentity.sandboxIdentityFingerprint ||
Expand Down Expand Up @@ -475,7 +504,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 {
Expand All @@ -490,6 +525,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.
Expand Down Expand Up @@ -557,6 +615,8 @@ export async function executeSandboxDestroy({
sandboxRuntimeConfirmedAbsent,
cliName,
deps,
selectedRunOpenshell,
destroyRuntimeSelection,
);
} catch (error) {
const mcpRecoveryFailure = await restoreMcpForAbort(notHardened);
Expand All @@ -581,7 +641,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);
Expand Down Expand Up @@ -614,15 +677,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<DestroyRunOpenshell> = sandboxConfirmedAbsent
? { status: 0, stdout: "", stderr: "" }
: runOpenshell(deleteArgs, {
: selectedRunOpenshell(deleteArgs, {
ignoreError: true,
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", "pipe"],
Expand Down Expand Up @@ -770,6 +852,7 @@ export async function executeSandboxDestroy({
deleteResult,
alreadyGone,
forcedLocalCleanup,
...(destroyRuntimeSelection ? { runtimeSelection: destroyRuntimeSelection } : {}),
...(commonLlamaCppAuthorityRetired ? { commonLlamaCppAuthorityRetired: true as const } : {}),
};
});
Expand Down
Loading
Loading