Skip to content
Merged
37 changes: 16 additions & 21 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ const {
} = modelRouter;
const routedInference: typeof import("./onboard/routed-inference") = require("./onboard/routed-inference");
const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = require("./onboard/runtime-boundary");
const { installSandboxCancelRollback, makeOnboardCancelExit, wasSandboxDefault, restoreDefaultAfterRecreate }: typeof import("./onboard/cancel-rollback") = require("./onboard/cancel-rollback");
const { handleAgentSetupState }: typeof import("./onboard/machine/handlers/agent-setup") = require("./onboard/machine/handlers/agent-setup");
const { handleFinalizationState }: typeof import("./onboard/machine/handlers/finalization") = require("./onboard/machine/handlers/finalization");
const { handleGatewayState }: typeof import("./onboard/machine/handlers/gateway") = require("./onboard/machine/handlers/gateway");
Expand Down Expand Up @@ -2980,6 +2981,8 @@ async function createSandbox(

// Reconcile local registry state with the live OpenShell gateway state.
const liveExists = pruneStaleSandboxEntry(sandboxName);
// #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox.
const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName);

// Declared outside the liveExists block so it is accessible during
// post-creation restore (the sandbox create path runs after the block).
Expand Down Expand Up @@ -3818,7 +3821,7 @@ async function createSandbox(
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
});
registry.setDefault(sandboxName);
restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization

if (restoreBackupPath) {
note(
Expand Down Expand Up @@ -3863,6 +3866,8 @@ async function createSandbox(

warnIfLandlockUnsupported({ dockerInfoFormat, runCapture });

// #4614: arm rollback only when the sandbox was not live before (never a recreate/rebuild).
if (!liveExists) sandboxCancelRollback.arm(sandboxName);
return sandboxName;
}

Expand Down Expand Up @@ -5856,10 +5861,7 @@ async function selectPolicyTier(): Promise<string> {
process.removeListener("SIGTERM", onSigterm);
};

const onSigterm = () => {
cleanup();
process.exit(1);
};
const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup);
process.once("SIGTERM", onSigterm);

const onData = (key: string) => {
Expand All @@ -5871,8 +5873,7 @@ async function selectPolicyTier(): Promise<string> {
selectedIdx = cursor;
redraw();
} else if (key === "\x03") {
cleanup();
process.exit(1);
makeOnboardCancelExit(sandboxCancelRollback, cleanup)();
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
Expand Down Expand Up @@ -6047,10 +6048,7 @@ async function selectTierPresetsAndAccess(
process.removeListener("SIGTERM", onSigterm);
};

const onSigterm = () => {
cleanup();
process.exit(1);
};
const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup);
process.once("SIGTERM", onSigterm);

const onData = (key: string) => {
Expand All @@ -6063,8 +6061,7 @@ async function selectTierPresetsAndAccess(
.map((p) => ({ name: p.name, access: accessModes[p.name] })),
);
} else if (key === "\x03") {
cleanup();
process.exit(1);
makeOnboardCancelExit(sandboxCancelRollback, cleanup)();
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
Expand Down Expand Up @@ -6197,10 +6194,7 @@ async function presetsCheckboxSelector(
process.removeListener("SIGTERM", onSigterm);
};

const onSigterm = () => {
cleanup();
process.exit(1);
};
const onSigterm = makeOnboardCancelExit(sandboxCancelRollback, cleanup);
process.once("SIGTERM", onSigterm);

const onData = (key: string) => {
Expand All @@ -6209,9 +6203,7 @@ async function presetsCheckboxSelector(
process.stdout.write("\n");
resolve([...selected]);
} else if (key === "\x03") {
// Ctrl+C
cleanup();
process.exit(1);
makeOnboardCancelExit(sandboxCancelRollback, cleanup)(); // Ctrl+C
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
Expand Down Expand Up @@ -6302,6 +6294,8 @@ const onboardRuntimeBoundary = new OnboardRuntimeBoundary({
maybeForceE2eStepFailure,
});

const sandboxCancelRollback = installSandboxCancelRollback({ runOpenshell, registry, clearOnboardSession: onboardSession.clearSession }); // #4614

const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary);
const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary);
const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary);
Expand Down Expand Up @@ -6926,7 +6920,6 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
listRegistrySandboxes: registry.listSandboxes,
createSandbox,
updateSandboxRegistry: (name, updates) => registry.updateSandbox(name, updates),
setDefaultSandbox: registry.setDefault,
getSandboxAgentRegistryFields,
recordStepComplete,
toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters<typeof toSessionUpdates>[0]),
Expand Down Expand Up @@ -7016,6 +7009,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
},
});
session = policiesResult.session;
sandboxCancelRollback.disarm(); // #4614: policies confirmed, past the cancellable window

await handleFinalizationState({
sandboxName,
Expand All @@ -7030,6 +7024,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
webSearchEnabled: braveProviderProfile.shouldEnableBraveWebSearch(webSearchConfig),
deps: {
ensureAgentDashboardForward,
setDefaultSandbox: registry.setDefault,
verifyWebSearchInsideSandbox,
recordPostVerifyStarted,
recordSessionComplete,
Expand Down
211 changes: 211 additions & 0 deletions src/lib/onboard/cancel-rollback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// 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 {
buildCancelRollbackMessage,
createSandboxCancelRollback,
installSandboxCancelRollback,
makeOnboardCancelExit,
type SandboxCancelRollbackDeps,
} from "./cancel-rollback";

function createDeps(overrides: Partial<SandboxCancelRollbackDeps> = {}) {
const calls = {
deleteContainer: vi.fn((_name: string) => true),
removeFromRegistry: vi.fn(),
clearSession: vi.fn(),
log: vi.fn(),
};
const deps: SandboxCancelRollbackDeps = {
deleteSandboxContainer: calls.deleteContainer,
removeSandboxFromRegistry: calls.removeFromRegistry,
clearOnboardSession: calls.clearSession,
log: calls.log,
...overrides,
};
return { calls, deps };
}

describe("createSandboxCancelRollback", () => {
it("rolls back (delete + unregister) when armed and cancelled", () => {
const { deps, calls } = createDeps();
const rollback = createSandboxCancelRollback(deps);

rollback.arm("new-sb");
rollback.markCancelled();
rollback.runIfArmed();

expect(calls.deleteContainer).toHaveBeenCalledWith("new-sb");
expect(calls.removeFromRegistry).toHaveBeenCalledWith("new-sb");
// also discards the aborted session so `nemoclaw list` recovery can't resurrect it
expect(calls.clearSession).toHaveBeenCalledOnce();
// delete is attempted before the registry entry is removed
expect(calls.deleteContainer.mock.invocationCallOrder[0]).toBeLessThan(
calls.removeFromRegistry.mock.invocationCallOrder[0],
);
expect(calls.log).toHaveBeenCalledWith(expect.stringContaining("removed incomplete sandbox 'new-sb'"));
});

it("still unregisters and prints manual cleanup when container delete fails", () => {
const { deps, calls } = createDeps({ deleteSandboxContainer: vi.fn(() => false) });
const rollback = createSandboxCancelRollback(deps);

rollback.arm("new-sb");
rollback.markCancelled();
rollback.runIfArmed();

expect(calls.removeFromRegistry).toHaveBeenCalledWith("new-sb");
expect(calls.log).toHaveBeenCalledWith(expect.stringContaining("unregistered incomplete sandbox 'new-sb'"));
expect(calls.log).toHaveBeenCalledWith(expect.stringContaining('openshell sandbox delete "new-sb"'));
});

it("does NOT roll back on a non-cancel exit (armed but not cancelled)", () => {
const { deps, calls } = createDeps();
const rollback = createSandboxCancelRollback(deps);

rollback.arm("new-sb");
// no markCancelled() — this is an ordinary failure-path process.exit
rollback.runIfArmed();

expect(calls.deleteContainer).not.toHaveBeenCalled();
expect(calls.removeFromRegistry).not.toHaveBeenCalled();
expect(calls.clearSession).not.toHaveBeenCalled();
expect(calls.log).not.toHaveBeenCalled();
});

it("does NOT roll back when cancelled before any sandbox was armed", () => {
const { deps, calls } = createDeps();
const rollback = createSandboxCancelRollback(deps);

rollback.markCancelled();
rollback.runIfArmed();

expect(calls.deleteContainer).not.toHaveBeenCalled();
expect(calls.removeFromRegistry).not.toHaveBeenCalled();
});

it("does NOT roll back after disarm (policies confirmed), even if later cancelled", () => {
const { deps, calls } = createDeps();
const rollback = createSandboxCancelRollback(deps);

rollback.arm("new-sb");
rollback.disarm();
rollback.markCancelled();
rollback.runIfArmed();

expect(calls.deleteContainer).not.toHaveBeenCalled();
expect(calls.removeFromRegistry).not.toHaveBeenCalled();
});

it("is idempotent — runs the rollback at most once", () => {
const { deps, calls } = createDeps();
const rollback = createSandboxCancelRollback(deps);

rollback.arm("new-sb");
rollback.markCancelled();
rollback.runIfArmed();
rollback.runIfArmed();
rollback.runIfArmed();

expect(calls.deleteContainer).toHaveBeenCalledTimes(1);
expect(calls.removeFromRegistry).toHaveBeenCalledTimes(1);
});

it("reports armed state via isArmed()", () => {
const { deps } = createDeps();
const rollback = createSandboxCancelRollback(deps);

expect(rollback.isArmed()).toBe(false);
rollback.arm("new-sb");
expect(rollback.isArmed()).toBe(true);
rollback.disarm();
expect(rollback.isArmed()).toBe(false);
});

it("re-arming after a previous sandbox tracks the latest name", () => {
const { deps, calls } = createDeps();
const rollback = createSandboxCancelRollback(deps);

rollback.arm("first");
rollback.arm("second");
rollback.markCancelled();
rollback.runIfArmed();

expect(calls.deleteContainer).toHaveBeenCalledWith("second");
expect(calls.deleteContainer).not.toHaveBeenCalledWith("first");
});
});

describe("installSandboxCancelRollback", () => {
it("wires delete to openshell and unregister to the registry, and registers an exit hook", () => {
const runOpenshell = vi.fn(() => ({ status: 0 }));
const removeSandbox = vi.fn();
const exitHandlers: Array<() => void> = [];

const rollback = installSandboxCancelRollback({
runOpenshell,
registry: { removeSandbox },
clearOnboardSession: () => {},
registerExitHandler: (h) => exitHandlers.push(h),
});

expect(exitHandlers).toHaveLength(1);

rollback.arm("new-sb");
rollback.markCancelled();
exitHandlers[0]();

expect(runOpenshell).toHaveBeenCalledWith(["sandbox", "delete", "new-sb"], { ignoreError: true });
expect(removeSandbox).toHaveBeenCalledWith("new-sb");
});

it("does not fire the rollback on a non-cancel exit", () => {
const runOpenshell = vi.fn(() => ({ status: 0 }));
const removeSandbox = vi.fn();
const exitHandlers: Array<() => void> = [];

const rollback = installSandboxCancelRollback({
runOpenshell,
registry: { removeSandbox },
clearOnboardSession: () => {},
registerExitHandler: (h) => exitHandlers.push(h),
});
rollback.arm("new-sb"); // armed, but never cancelled
exitHandlers[0]();

expect(runOpenshell).not.toHaveBeenCalled();
expect(removeSandbox).not.toHaveBeenCalled();
});
});

describe("makeOnboardCancelExit", () => {
it("cleans up, marks cancelled, then exits non-zero", () => {
const order: string[] = [];
const cleanup = vi.fn(() => order.push("cleanup"));
const rollback = { markCancelled: vi.fn(() => order.push("markCancelled")) };
const exit = vi.fn((_code: number) => {
order.push("exit");
});

makeOnboardCancelExit(rollback, cleanup, exit)();

expect(order).toEqual(["cleanup", "markCancelled", "exit"]);
expect(exit).toHaveBeenCalledWith(1);
});
});

describe("buildCancelRollbackMessage", () => {
it("reports a clean removal when the delete succeeded", () => {
const lines = buildCancelRollbackMessage("sb", true);
expect(lines.join("\n")).toContain("removed incomplete sandbox 'sb'");
expect(lines.join("\n")).not.toContain("openshell sandbox delete");
});

it("falls back to manual cleanup guidance when the delete failed", () => {
const lines = buildCancelRollbackMessage("sb", false);
expect(lines.join("\n")).toContain("unregistered incomplete sandbox 'sb'");
expect(lines.join("\n")).toContain('openshell sandbox delete "sb"');
});
});
Loading
Loading