Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
30ec889
fix(onboard): preserve fresh DCode selection
apurvvkumaria Jul 6, 2026
c5799be
refactor(onboard): extract gateway failure handler
apurvvkumaria Jul 6, 2026
621dcae
test(onboard): keep finalization setup linear
apurvvkumaria Jul 6, 2026
56a78d6
refactor(onboard): isolate DCode resume policy
apurvvkumaria Jul 6, 2026
4d05d1c
refactor(state): generalize file restore policy
apurvvkumaria Jul 6, 2026
3bdc9e6
refactor(onboard): reuse restore state types
apurvvkumaria Jul 6, 2026
da93bfe
fix(onboard): redact normalized gateway logs
apurvvkumaria Jul 6, 2026
a500603
fix(onboard): preserve tagged DCode model IDs
apurvvkumaria Jul 6, 2026
24be6e1
fix(state): allowlist restored DCode preferences
apurvvkumaria Jul 6, 2026
03a41ef
fix(onboard): explain DCode recovery path
apurvvkumaria Jul 6, 2026
3faed1f
chore(state): track DCode ownership migration
apurvvkumaria Jul 6, 2026
3fc91d3
docs(dcode): clarify restored preferences
apurvvkumaria Jul 6, 2026
39e4fdb
fix(onboard): make orphan recovery actionable
apurvvkumaria Jul 6, 2026
253fd90
test(onboard): provide live DCode identity fixture
apurvvkumaria Jul 6, 2026
79858ca
fix(onboard): preserve DCode recreate metadata
apurvvkumaria Jul 6, 2026
c849b21
test(onboard): cover partial DCode restore
apurvvkumaria Jul 6, 2026
3b06dd2
test(onboard): verify partial restore routing
apurvvkumaria Jul 6, 2026
0857f8d
test(onboard): stub prepared dcode identity
cv Jul 6, 2026
88b99bf
fix(state): drop free-form DCode restore values
apurvvkumaria Jul 6, 2026
95be79f
test(e2e): prove fresh DCode model switch
apurvvkumaria Jul 6, 2026
45316f7
merge(main): resolve onboarding conflicts
apurvvkumaria Jul 6, 2026
da29660
test(e2e): use compatible DCode switch model
apurvvkumaria Jul 6, 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
6 changes: 1 addition & 5 deletions agents/langchain-deepagents-code/generate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,7 @@ function tomlArray(values: readonly string[]): string {

function modelNameForOpenAiProvider(model: string): string {
const trimmed = model.trim();
const providerSeparator = trimmed.indexOf(":");
if (providerSeparator > 0) {
return trimmed.slice(providerSeparator + 1);
}
return trimmed;
return trimmed.startsWith("openai:") ? trimmed.slice("openai:".length) : trimmed;
}

function buildConfig(settings: Settings): string {
Expand Down
8 changes: 7 additions & 1 deletion agents/langchain-deepagents-code/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ state_dirs:
- agent/skills

# ── Top-level durable state files ───────────────────────────────
# config.toml is non-secret NemoClaw-generated provider/model configuration.
# config.toml mixes DCode preferences with NemoClaw-managed model routing.
# Managed re-onboard restore carries forward only boolean ui.show_scrollbar,
# ui.show_url_open_toast, and threads.relative_time preferences, plus
# threads.sort_order when it is updated_at or created_at. Fresh models/update
# tables and provider metadata remain authoritative. All other backup keys,
# including ui.theme and behavior-bearing, unknown, or security-sensitive keys,
# are dropped.
# .env and user-authored .deepagents/.mcp.json content are intentionally omitted
# because they may contain service credentials. NemoClaw writes only direct-HTTP
# bridge endpoint config and OpenShell placeholders to its separate
Expand Down
6 changes: 5 additions & 1 deletion docs/get-started/quickstart-langchain-deepagents-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ For project-specific Python dependencies, create a separate virtual environment
## State and Backup

Deep Agents Code state lives under `/sandbox/.deepagents`.
NemoClaw snapshot and rebuild flows preserve the app state directory, skills, and generated config when those files exist.
NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist.
During managed re-onboarding, NemoClaw restores only these `config.toml` preferences from backup: boolean `ui.show_scrollbar`, boolean `ui.show_url_open_toast`, boolean `threads.relative_time`, and `threads.sort_order` when it is `updated_at` or `created_at`.
Freshly generated model routing, update settings, provider metadata, and all other configuration remain authoritative.
NemoClaw drops all other backup settings, including `ui.theme`, behavior-bearing keys, unknown keys, and security-sensitive keys.
It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check.
Run `nemoclaw <sandbox-name> snapshot create` after active `dcode` tasks finish.
For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle.
NemoClaw intentionally does not back up `.deepagents/.env` or the user-owned `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there.
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,8 @@ Existing live sandboxes are not deleted by this cancel rollback path.

If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection.
In interactive mode, the wizard asks for confirmation before delete and recreate.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs.
For managed Deep Agents Code sandboxes, NemoClaw also recreates when the live `dcode identity` selection is unreadable; other agent paths continue to reuse by default when their stored selection cannot be read.
Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected.

Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live.
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ Existing live sandboxes are not deleted by this cancel rollback path.

If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection.
In interactive mode, the wizard asks for confirmation before delete and recreate.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs.
For managed Deep Agents Code sandboxes, NemoClaw also recreates when the live `dcode identity` selection is unreadable; other agent paths continue to reuse by default when their stored selection cannot be read.
Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected.

Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live.
Expand Down
205 changes: 89 additions & 116 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ const {
const {
getSelectionDrift,
}: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift");
const {
getDcodeSelectionDrift,
requiresSelectionRecreate,
usesManagedDcodeIdentity,
}: typeof import("./onboard/dcode-selection-drift") = require("./onboard/dcode-selection-drift");
const {
finalizeCreatedSandbox,
}: typeof import("./onboard/created-sandbox-finalization") = require("./onboard/created-sandbox-finalization");
const providerKeyBridge: typeof import("./onboard/provider-key-bridge") = require("./onboard/provider-key-bridge");
const {
isLinuxDockerDriverGatewayEnabled,
Expand Down Expand Up @@ -154,9 +162,6 @@ const os = require("os");
const path = require("path");
const pRetry = require("p-retry");

/** Strip ANSI escape sequences before printing process output to the terminal.
* Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */
const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
const runner: typeof import("./runner") = require("./runner");
const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, runFile, validateName } = runner;
const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile");
Expand Down Expand Up @@ -514,7 +519,7 @@ const { trackChildExit } =
require("./onboard/child-exit-tracker") as typeof import("./onboard/child-exit-tracker");
const { reportDockerDriverGatewayStartFailure } =
require("./onboard/docker-driver-gateway-failure") as typeof import("./onboard/docker-driver-gateway-failure");
const { printDockerDaemonRecovery, reportLegacyGatewayStartResultFailure } =
const { createFinalGatewayStartFailureHandler, reportLegacyGatewayStartResultFailure } =
require("./onboard/gateway-start-failure") as typeof import("./onboard/gateway-start-failure");
const dockerDriverGatewayEnv: typeof import("./onboard/docker-driver-gateway-env") =
require("./onboard/docker-driver-gateway-env");
Expand Down Expand Up @@ -1331,80 +1336,15 @@ function destroyGateway(
});
}

type FinalGatewayStartFailureOptions = {
retries: number;
dockerUnreachable?: boolean;
collectDiagnostics?: () => string | null | undefined;
cleanupGateway?: () => void;
exitProcess?: (code: number) => never;
printError?: (message?: string) => void;
};

function handleFinalGatewayStartFailure({
retries,
dockerUnreachable = false,
collectDiagnostics = () =>
const handleFinalGatewayStartFailure = createFinalGatewayStartFailureHandler({
getGatewayName: () => GATEWAY_NAME,
collectDiagnostics: () =>
runCaptureOpenshell(["doctor", "logs", "--name", GATEWAY_NAME], {
ignoreError: true,
timeout: 10_000,
}),
cleanupGateway = destroyGateway,
exitProcess = (code) => process.exit(code),
printError = (message = "") => console.error(message),
}: FinalGatewayStartFailureOptions): never {
if (dockerUnreachable) {
printDockerDaemonRecovery(printError);
return exitProcess(1);
}

printError(` Gateway failed to start after ${retries + 1} attempts.`);
printError(" Gateway state preserved until diagnostics are collected.");
printError("");

try {
const logs = redact(collectDiagnostics() || "");
if (logs) {
printError(" Gateway logs:");
for (const line of String(logs)
.split("\n")
.map((l) => l.replace(/\r/g, "").replace(ANSI_RE, ""))
.filter(Boolean)) {
printError(` ${line}`);
}
printError("");
}
} catch {
// doctor logs unavailable — continue to best-effort cleanup and manual instructions
}

printError(" Cleaning up failed gateway state...");
try {
cleanupGateway();
printError(" Cleanup attempted.");
} catch (err) {
const message = compactText(err instanceof Error ? err.message : String(err));
printError(message ? ` Cleanup attempt failed: ${message}` : " Cleanup attempt failed.");
}
printError("");
printError(" Diagnostic command attempted before cleanup:");
printError(` openshell doctor logs --name ${GATEWAY_NAME}`);
printError(" openshell doctor check");
printError("");
printError(" If gateway cleanup did not complete, run:");
printError(` openshell gateway remove ${GATEWAY_NAME}`);
printError(` # For OpenShell releases that still expose lifecycle commands:`);
printError(` openshell gateway destroy -g ${GATEWAY_NAME}`);
if (process.platform === "linux") {
printError(
" sudo pkill -f openshell-gateway # if a privileged host gateway process remains",
);
}
printError(
` docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs -r docker volume rm`,
);
printError(` nemoclaw onboard --resume`);
return exitProcess(1);
}
cleanupGateway: destroyGateway,
});

function getGatewayClusterContainerState(): string {
const containerName = getGatewayClusterContainerName(GATEWAY_NAME);
Expand Down Expand Up @@ -2374,6 +2314,7 @@ async function createSandboxWithBaseImageResolution(
const effectiveSandboxGpuConfig =
sandboxGpuConfig ?? resolveSandboxGpuConfig(gpu, { flag: null, device: null });
const manageDashboard = dashboardRuntime.shouldManageDashboardForAgent(agent);
const isManagedDcodeAgent = usesManagedDcodeIdentity(agent?.name, fromDockerfile);
let effectivePort = 0,
chatUiUrl = "";
if (manageDashboard) {
Expand Down Expand Up @@ -2438,6 +2379,15 @@ async function createSandboxWithBaseImageResolution(

// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const { existingEntry, preservedMcpState, liveExists, effectiveToolDisclosure, toolDisclosureMigrationNeeded, toolDisclosureMigrationNote } = toolDisclosureFlow.prepareSandboxToolDisclosure(sandboxName, preparedBuildContext?.rebuildTarget?.fromDockerfile ? preparedBuildContext.stagedDockerfile : fromDockerfile, isRecreateSandbox(createIntent?.recreate), inspectSandboxForCreate, createIntent?.toolDisclosure ?? null);
if (liveExists && isManagedDcodeAgent && !existingEntry) {
console.error(
` Sandbox '${sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse or recreation.`,
);
console.error(
" Choose a different sandbox name, or remove the orphan explicitly with OpenShell.",
);
process.exit(1);
}
// #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox.
const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName);

Expand Down Expand Up @@ -2498,8 +2448,12 @@ async function createSandboxWithBaseImageResolution(
const needsProviderMigration =
hasMessagingTokens &&
messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name));
const selectionDrift = getSelectionDrift(sandboxName, provider, model, { runOpenshell });
const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown;
const selectionDrift = isManagedDcodeAgent
? getDcodeSelectionDrift(sandboxName, provider, model, preferredInferenceApi, {
runCaptureOpenshell,
})
: getSelectionDrift(sandboxName, provider, model, { runOpenshell });
const actionableSelectionDrift = requiresSelectionRecreate(selectionDrift, isManagedDcodeAgent);
const sandboxGpuDrift = hasSandboxGpuDrift(sandboxName, effectiveSandboxGpuConfig);
const existingSandboxEntry = registry.getSandbox(sandboxName);
const recordedHermesToolGateways = normalizeHermesToolGatewaySelections(
Expand Down Expand Up @@ -2553,7 +2507,7 @@ async function createSandboxWithBaseImageResolution(

if (isNonInteractive()) {
if (existingSandboxState === "ready") {
if (confirmedSelectionDrift) {
if (actionableSelectionDrift) {
note(" [non-interactive] Recreating sandbox due to provider/model drift.");
} else {
policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive());
Expand Down Expand Up @@ -2601,7 +2555,7 @@ async function createSandboxWithBaseImageResolution(
pendingStateRestoreBackupPath = outcome.restoreBackupPath;
}
} else if (existingSandboxState === "ready") {
if (confirmedSelectionDrift) {
if (actionableSelectionDrift) {
const confirmed = await confirmRecreateForSelectionDrift(
sandboxName,
selectionDrift,
Expand Down Expand Up @@ -2670,8 +2624,10 @@ async function createSandboxWithBaseImageResolution(
} else if (needsProviderMigration) {
console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`);
console.log(" Recreating to ensure credentials flow through the provider pipeline.");
} else if (confirmedSelectionDrift) {
note(` Sandbox '${sandboxName}' exists — recreating to apply model/provider change.`);
} else if (actionableSelectionDrift) {
note(
` Sandbox '${sandboxName}' exists — recreating because its live model/provider selection is stale or unreadable.`,
);
} else if (sandboxGpuDrift) {
note(` Sandbox '${sandboxName}' exists — recreating to apply sandbox GPU settings.`);
} else if (hermesToolGatewayDrift) {
Expand Down Expand Up @@ -3005,49 +2961,62 @@ async function createSandboxWithBaseImageResolution(
hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true);
}

// Register only after confirmed ready — prevents phantom entries
// Resolve registry metadata now, but publish it only after restored state is
// reconciled and the live agent selection is verified.
// openshell tags images with seconds; buildId is ms. Parse actual tag from output. Fixes #2672.
const resolvedImageTag =
prebuild.imageRef ?? resolveSandboxImageTagFromCreateOutput(createResult.output, buildId);

const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig);
const inferenceSelection = sandboxRegistration.selection;
sandboxRegistration.registerCreatedSandbox({
sandboxName,
inferenceSelection: inferenceSelection(sandboxName, provider, model, preferredInferenceApi),
runtimeFields: sandboxRuntimeFields,
agent,
agentVersionKnown: !fromDockerfile,
imageTag: resolvedImageTag,
appliedPolicies: initialSandboxPolicy.appliedPresets,
toolDisclosure: effectiveToolDisclosure,
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)),
plannedMessagingState,
preservedMcpState,
hermesToolGateways,
hermesDashboardState: finalHermesDashboardState,
dashboardPort: actualDashboardPort,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
});
restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization

if (restoreBackupPath) {
note(
pendingStateRestoreBackupPath
? " Restoring workspace state from pre-upgrade backup..."
: " Restoring workspace state from pre-recreate backup...",
);
const restore = sandboxState.restoreSandboxState(sandboxName, restoreBackupPath);
if (restore.success) {
note(
` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`,
);
} else {
console.error(` Warning: partial restore. Manual recovery: ${restoreBackupPath}`);
}
}
finalizeCreatedSandbox(
{
sandboxName,
restoreBackupPath,
preUpgradeBackup: pendingStateRestoreBackupPath !== null,
validateManagedDcode: isManagedDcodeAgent,
provider,
model,
preferredInferenceApi,
},
{
restoreSandboxState: sandboxState.restoreSandboxState,
getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) =>
getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, {
runCaptureOpenshell,
}),
note,
error: console.error,
exitProcess: (code) => process.exit(code),
register: () =>
sandboxRegistration.registerCreatedSandbox({
sandboxName,
inferenceSelection: inferenceSelection(
sandboxName,
provider,
model,
preferredInferenceApi,
),
runtimeFields: sandboxRuntimeFields,
agent,
agentVersionKnown: !fromDockerfile,
imageTag: resolvedImageTag,
appliedPolicies: initialSandboxPolicy.appliedPresets,
toolDisclosure: effectiveToolDisclosure,
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)),
plannedMessagingState,
preservedMcpState,
hermesToolGateways,
hermesDashboardState: finalHermesDashboardState,
dashboardPort: actualDashboardPort,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
}),
},
);
restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization

// DNS proxy — run a forwarder in the sandbox pod so the isolated
// sandbox namespace can resolve hostnames (fixes #626).
Expand Down Expand Up @@ -4565,6 +4534,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
hydrateMessagingChannelConfig,
messagingChannelConfigsEqual,
getSandboxReuseState,
getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) =>
getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, {
runCaptureOpenshell,
}),
hasSandboxGpuDrift,
getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways,
getSandboxRegistryEntry: registry.getSandbox,
Expand Down
Loading
Loading