Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3493,7 +3493,7 @@ jobs:
needs: generate-matrix
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',upgrade-stale-sandbox,') || contains(format(',{0},', inputs.targets), ',upgrade-stale-sandbox,') }}
runs-on: ubuntu-latest
timeout-minutes: 55
timeout-minutes: 85
env:
E2E_JOB: "1"
E2E_TARGET_ID: "upgrade-stale-sandbox"
Expand Down
11 changes: 11 additions & 0 deletions docs/inference/use-shared-gateway-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ Before changing the route, `connect` verifies the same provider-global identity
When the identity is compatible, `connect` warns and re-points the route to the sandbox's recorded provider and model.
When the identity differs or required metadata is incomplete, `connect` stops because a provider-and-model-only route change cannot safely reconstruct that configuration.

## Rebuild a Legacy Shared Route

During rebuild, NemoClaw may find same-gateway legacy sandbox records that use the selected supported provider but omit its credential environment-variable name.
Before deleting the target sandbox, NemoClaw fills only those missing names from the provider's canonical configuration and saves the target and peer metadata together.
The peer migration does not replace an explicit credential environment-variable name.

Credential environment-variable name, custom endpoint, or API-family conflicts still stop the rebuild.
Incomplete routes and invalid gateway bindings also stop the rebuild.
NemoClaw reads a fresh registry snapshot immediately before deletion.
If that snapshot contains a target route change or peer provider-identity conflict, rebuild stops and leaves the original sandbox intact.

## Inspect Recorded and Live Routes

Run sandbox status to compare the sandbox's recorded route with the gateway's live route.
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2725,6 +2725,11 @@ Credentials are stripped from backups before storage.
Policy presets applied to the old sandbox are reapplied to the new one so your egress rules survive the rebuild.
Before creating the replacement sandbox, NemoClaw prints the finalized create-time policy scope whenever presets are included.
The replacement uses the recorded compatible-endpoint reasoning mode, reasoning effort, and web search selection instead of ambient shell values.
When same-gateway legacy sandbox records use the selected supported provider but omit its credential environment-variable name, rebuild fills only those missing names from the provider's canonical configuration.
The target update and peer metadata migration use one registry update.
Conflicting credential environment-variable names, custom endpoints, or API families still stop the rebuild.
Incomplete routes and invalid gateway bindings also stop the rebuild.
NemoClaw checks the shared route again immediately before deleting the original sandbox.
Rebuild preserves the recorded sandbox GPU enablement mode and, for an explicitly enabled sandbox, its recorded device selector.
It re-resolves the Docker-driver GPU route from the current host and current `NEMOCLAW_DOCKER_GPU_PATCH` value, so native-only, explicitly authorized native-with-fallback, and compatibility-only routing may differ from the original onboarding run.
A rebuild preserves the recorded tool-disclosure mode unless `--tool-disclosure` explicitly changes it; it ignores an ambient `NEMOCLAW_TOOL_DISCLOSURE` value while recreating the sandbox.
Expand Down
27 changes: 27 additions & 0 deletions src/lib/actions/sandbox/rebuild-destroy-phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { expectNoSandboxDelete } from "../../../../test/helpers/rebuild-delete-assertions";

const mocks = vi.hoisted(() => ({
captureOpenshell: vi.fn(),
Expand Down Expand Up @@ -182,6 +183,32 @@ describe("rebuild destroy phase", () => {
expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true);
});

it("blocks the exact delete edge when the shared inference route drifts (#7798)", async () => {
const bail = vi.fn((message: string): never => {
throw new Error(message);
});

await expect(
runRebuildDestroyPhase({
sandboxName: "alpha",
sandboxEntry: { name: "alpha", agent: "openclaw" },
staleRecovery: false,
backupManifest: null,
log: vi.fn(),
bail,
relockShieldsIfNeeded: vi.fn(() => true),
validateAtDeleteEdge: () => ({
ok: false,
message: "Shared inference route changed before sandbox deletion.",
}),
onDeleted: vi.fn(),
}),
).rejects.toThrow("Shared inference route changed before sandbox deletion.");

expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledOnce();
expectNoSandboxDelete(mocks.runOpenshell);
});

it("passes force=true to prepareMcpForRebuild when input.force is set (#7062)", async () => {
const log = vi.fn();
const bail = vi.fn((message: string): never => {
Expand Down
31 changes: 31 additions & 0 deletions src/lib/actions/sandbox/rebuild-destroy-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface RebuildDestroyPhaseInput {
relockShieldsIfNeeded: (sandboxStillExists: boolean) => boolean;
force?: boolean;
validateAfterMcpPreparation?: () => Promise<RebuildDeleteValidationResult>;
validateAtDeleteEdge?: () => RebuildDeleteValidationResult;
onDeleted: () => void;
onDeleteStateAmbiguous?: () => void;
}
Expand Down Expand Up @@ -229,6 +230,7 @@ export async function runRebuildDestroyPhase(
bail,
relockShieldsIfNeeded,
validateAfterMcpPreparation,
validateAtDeleteEdge,
onDeleted,
} = input;
const deleteTarget = resolveRebuildDeleteTarget(sandboxName, input.sandboxEntry);
Expand Down Expand Up @@ -355,6 +357,35 @@ export async function runRebuildDestroyPhase(
return null;
}

if (validateAtDeleteEdge) {
let validation: RebuildDeleteValidationResult;
try {
validation = validateAtDeleteEdge();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
log(`Unexpected delete-edge validation failure: ${redactFull(detail)}`);
validation = {
ok: false,
message: "Replacement validation failed before sandbox deletion.",
};
}
if (!validation.ok) {
const mcpRecoveryFailure = await reattachMcpAfterDeleteFailure(
sandboxName,
rebuildDetachedMcpProviderEntries,
rebuildScrubbedMcpAdapterEntries,
);
relockShieldsIfNeeded(true);
bail(
mcpRecoveryFailure
? `${validation.message} MCP provider recovery also failed: ${mcpRecoveryFailure}`
: validation.message,
validation.code,
);
return null;
}
}

log(`Running: openshell sandbox delete -g ${gatewayName} ${sandboxName}`);
const deleteResult = runOpenshell(["sandbox", "delete", "-g", gatewayName, sandboxName], {
ignoreError: true,
Expand Down
7 changes: 6 additions & 1 deletion src/lib/actions/sandbox/rebuild-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import { disposeRebuildAgentBaseImagePreflight } from "./rebuild-flow-helpers";
import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase";
import { runRebuildPostRestorePhase } from "./rebuild-post-restore-phase";
import { printRebuildPreflightFailure } from "./rebuild-preflight-error";
import { blockRebuildOnPendingBaselineTransition } from "./rebuild-preflight-guards";
import {
blockRebuildOnPendingBaselineTransition,
revalidateRebuildRouteBeforeDelete,
} from "./rebuild-preflight-guards";
import { runRebuildPreflightPhase } from "./rebuild-preflight-phase";
import {
disposePreparedBuildContext,
Expand Down Expand Up @@ -102,6 +105,7 @@ async function rebuildSandboxUnlocked(
recoveryManifest: validatedRecoveryManifest,
dcodePreflight,
preparedImage,
routePreflightReceipt,
releaseOnboardLock,
log,
bail,
Expand Down Expand Up @@ -251,6 +255,7 @@ async function rebuildSandboxUnlocked(
recreateOptions.targetGatewayPort,
);
},
validateAtDeleteEdge: () => revalidateRebuildRouteBeforeDelete(routePreflightReceipt),
onDeleted: () => {
sandboxStillExists = false;
},
Expand Down
216 changes: 216 additions & 0 deletions src/lib/actions/sandbox/rebuild-preflight-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,229 @@ import {
printOpenShellStateRpcIssue,
} from "../../adapters/openshell/gateway-drift";
import { CLI_NAME } from "../../cli/branding";
import {
checkGatewayRouteCompatibility,
formatGatewayRouteConflict,
type GatewayInferenceRoute,
isAdvisoryGatewayRouteConflict,
} from "../../inference/gateway-route-compatibility";
import { normalizeInferenceSelection } from "../../inference/selection";
import { resolveSandboxGatewayName } from "../../onboard/gateway-binding";
import * as onboardSession from "../../state/onboard-session";
import * as registry from "../../state/registry";
import { withLock } from "../../state/registry/lock";
import { load, save } from "../../state/registry/persistence";
import type { SandboxEntry, SandboxRegistry } from "../../state/registry/types";
import type { RebuildBail } from "./rebuild-credential-preflight";
import type { RebuildSandboxEntry } from "./rebuild-flow-helpers";
import type { RebuildVersionCheck } from "./rebuild-preflight-confirmation";
import { printRebuildPreflightFailure } from "./rebuild-preflight-error";
import { getRebuildCredentialEnvFromRegistry } from "./rebuild-resume-preflight";

export interface RebuildRoutePreflightReceipt {
readonly sandboxName: string;
readonly gatewayName: string;
readonly route: GatewayInferenceRoute;
readonly migratedSandboxNames: readonly string[];
}

export type RebuildRoutePreflightResult =
| { ok: true; receipt: RebuildRoutePreflightReceipt }
| { ok: false; message: string };

interface RebuildRouteRegistryDependencies {
withLock<T>(fn: () => T): T;
load(): SandboxRegistry;
save(data: SandboxRegistry): void;
}

const defaultRouteDependencies: RebuildRouteRegistryDependencies = {
withLock,
load,
save,
};

function normalizedRoute(entry: Partial<SandboxEntry>): GatewayInferenceRoute {
const route = normalizeInferenceSelection(entry);
return {
provider: route.provider,
model: route.model,
endpointUrl: route.endpointUrl,
preferredInferenceApi: route.preferredInferenceApi,
credentialEnv: route.credentialEnv,
};
}

function missingCredentialIdentity(value: unknown): boolean {
return typeof value !== "string" || value.trim().length === 0;
}

function sameRoute(left: GatewayInferenceRoute, right: GatewayInferenceRoute): boolean {
return (
left.provider === right.provider &&
left.model === right.model &&
left.endpointUrl === right.endpointUrl &&
left.preferredInferenceApi === right.preferredInferenceApi &&
left.credentialEnv === right.credentialEnv
);
}

function hardRouteConflict(
gatewayName: string,
sandboxName: string,
route: GatewayInferenceRoute,
sandboxes: readonly SandboxEntry[],
): string | null {
if (!route.provider || !route.model) {
return "Prepared rebuild inference route is missing its provider or model.";
}
const compatibility = checkGatewayRouteCompatibility({
gatewayName,
sandboxName,
route,
sandboxes,
});
if (compatibility.ok || isAdvisoryGatewayRouteConflict(compatibility)) return null;
return formatGatewayRouteConflict(compatibility);
}

/**
* Persist the target route and canonical credential identities for compatible
* legacy peers in one registry transaction. The ordinary compatibility guard
* remains literal and fail-closed; only this rebuild migration may fill a
* missing identity from the provider's canonical configuration.
*/
export function commitRebuildRoutePreflight(
input: {
sandboxName: string;
gatewayName: string;
targetUpdate: Partial<Omit<SandboxEntry, "name">>;
},
dependencies: RebuildRouteRegistryDependencies = defaultRouteDependencies,
): RebuildRoutePreflightResult {
return dependencies.withLock(() => {
const sandboxRegistry = dependencies.load();
const currentTarget = sandboxRegistry.sandboxes[input.sandboxName];
if (!currentTarget) {
return {
ok: false,
message: "Sandbox registry entry disappeared during rebuild route preflight.",
};
}
let targetGatewayName: string;
try {
targetGatewayName = resolveSandboxGatewayName(currentTarget);
} catch {
return {
ok: false,
message: "Sandbox gateway binding changed during rebuild route preflight.",
};
}
if (targetGatewayName !== input.gatewayName) {
return {
ok: false,
message: "Sandbox gateway binding changed during rebuild route preflight.",
};
}

const target = { ...currentTarget, ...input.targetUpdate };
const targetRoute = normalizedRoute(target);
const migratedSandboxNames: string[] = [];
for (const peer of Object.values(sandboxRegistry.sandboxes)) {
if (
peer.name === input.sandboxName ||
peer.provider !== targetRoute.provider ||
!missingCredentialIdentity(peer.credentialEnv)
) {
continue;
}
let peerGatewayName: string;
try {
peerGatewayName = resolveSandboxGatewayName(peer);
} catch {
continue;
}
if (peerGatewayName !== input.gatewayName) continue;
const credentialEnv = getRebuildCredentialEnvFromRegistry(peer.provider, peer.credentialEnv);
if (!credentialEnv) continue;
peer.credentialEnv = credentialEnv;
migratedSandboxNames.push(peer.name);
}

const projectedSandboxes = Object.values(sandboxRegistry.sandboxes).map((entry) =>
entry.name === input.sandboxName ? target : entry,
);
const conflict = hardRouteConflict(
input.gatewayName,
input.sandboxName,
targetRoute,
projectedSandboxes,
);
if (conflict) return { ok: false, message: conflict };

Object.assign(currentTarget, input.targetUpdate);
dependencies.save(sandboxRegistry);
return {
ok: true,
receipt: {
sandboxName: input.sandboxName,
gatewayName: input.gatewayName,
route: targetRoute,
migratedSandboxNames: migratedSandboxNames.sort(),
},
};
});
}

/**
* Re-read the complete shared-gateway route at the synchronous delete edge.
* A target-route change or peer hard conflict in that snapshot invalidates the
* earlier preflight receipt.
*/
export function revalidateRebuildRouteBeforeDelete(
receipt: RebuildRoutePreflightReceipt,
dependencies: Pick<RebuildRouteRegistryDependencies, "load"> = defaultRouteDependencies,
): RebuildRoutePreflightResult {
// Registry writes install complete files atomically. A read lock would end before
// the external delete, so this guard uses a fresh fail-closed snapshot.
const sandboxRegistry = dependencies.load();
const target = sandboxRegistry.sandboxes[receipt.sandboxName];
if (!target) {
return {
ok: false,
message: "Sandbox registry entry disappeared before sandbox deletion.",
};
}
let gatewayName: string;
try {
gatewayName = resolveSandboxGatewayName(target);
} catch {
return {
ok: false,
message: "Sandbox gateway binding changed before sandbox deletion.",
};
}
if (gatewayName !== receipt.gatewayName) {
return {
ok: false,
message: "Sandbox gateway binding changed before sandbox deletion.",
};
}
const currentRoute = normalizedRoute(target);
if (!sameRoute(currentRoute, receipt.route)) {
return {
ok: false,
message: "Sandbox inference route changed before sandbox deletion.",
};
}
const conflict = hardRouteConflict(
receipt.gatewayName,
receipt.sandboxName,
currentRoute,
Object.values(sandboxRegistry.sandboxes),
);
return conflict ? { ok: false, message: conflict } : { ok: true, receipt };
}

export function checkRebuildGatewaySchemaPreflight(
sandboxName: string,
Expand Down
Loading
Loading