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
93 changes: 42 additions & 51 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboar
const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle");
const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata");
const sandboxReuse: typeof import("./onboard/sandbox-reuse") = require("./onboard/sandbox-reuse");
const sandboxRecreateTransaction: typeof import("./onboard/sandbox-recreate-transaction") =
require("./onboard/sandbox-recreate-transaction");
const sandboxRegistration: typeof import("./onboard/sandbox-registration") =
require("./onboard/sandbox-registration");
const {
Expand Down Expand Up @@ -786,12 +788,8 @@ const { getGatewayReuseSnapshot, selectNamedGatewayForReuseIfNeeded } =
cliDisplayName,
});

const { getSandboxReuseState, repairRecordedSandbox } = sandboxReuse.createSandboxReuseHelpers({
runCaptureOpenshell,
runOpenshell,
getSandboxStateFromOutputs,
note,
});
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const { getSandboxReuseState, getSandboxRecreateObservation, repairRecordedSandbox } = sandboxReuse.createSandboxReuseHelpers({ runCaptureOpenshell, runOpenshell, getSandboxStateFromOutputs, note });

const {
executeSandboxCommandForVerification,
Expand Down Expand Up @@ -2290,6 +2288,30 @@ 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);
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const recreateRuntime = sandboxRecreateTransaction.createSandboxRecreateRuntime(onboardSession, createIntent?.recreateTransaction, sandboxName, GATEWAY_NAME, existingEntry, getSandboxRecreateObservation, note);
const restoreReusedSandboxDashboard = (selectionVerified: boolean): void => {
({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({
sandboxName,
chatUiUrl,
env: process.env,
agent,
model,
provider,
selectionVerified,
sandboxGpuConfig: effectiveSandboxGpuConfig,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
manageDashboard,
ensureDashboardForward,
hermesDashboardForwarding,
updateReusedSandboxMetadata,
}));
};
if (recreateRuntime.acceptedTarget) {
restoreReusedSandboxDashboard(true);
return sandboxName;
}
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const observabilityDrift = observabilityPolicy.hasRegisteredDcodeObservabilityDrift(liveExists, isManagedDcodeAgent, existingEntry, createIntent?.observabilityEnabled);
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const dcodeAutoApprovalPlan = dcodeAutoApprovalFlow.prepareDcodeAutoApprovalCreatePlan({ sandboxName, liveExists, managedDcodeAgent: isManagedDcodeAgent, registryEntry: existingEntry, requestedMode: createIntent?.dcodeAutoApprovalMode }, { error: console.error, exitProcess: (code) => process.exit(code) });
Expand Down Expand Up @@ -2435,22 +2457,7 @@ async function createSandboxWithBaseImageResolution(
" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.",
);
}
({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({
sandboxName,
chatUiUrl,
env: process.env,
agent,
model,
provider,
selectionVerified: !selectionDrift.unknown,
sandboxGpuConfig: effectiveSandboxGpuConfig,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
manageDashboard,
ensureDashboardForward,
hermesDashboardForwarding,
updateReusedSandboxMetadata,
}));
restoreReusedSandboxDashboard(!selectionDrift.unknown);
return sandboxName;
}
} else {
Expand Down Expand Up @@ -2480,22 +2487,7 @@ async function createSandboxWithBaseImageResolution(
if (await promptYesNoOrDefault(" Reuse existing sandbox?", null, true)) {
policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive());
upsertMessagingProviders(messagingTokenDefs);
({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({
sandboxName,
chatUiUrl,
env: process.env,
agent,
model,
provider,
selectionVerified: !selectionDrift.unknown,
sandboxGpuConfig: effectiveSandboxGpuConfig,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
manageDashboard,
ensureDashboardForward,
hermesDashboardForwarding,
updateReusedSandboxMetadata,
}));
restoreReusedSandboxDashboard(!selectionDrift.unknown);
return sandboxName;
}
}
Expand Down Expand Up @@ -2569,18 +2561,13 @@ async function createSandboxWithBaseImageResolution(
}

const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName);
baseImageResolutionFlow.captureBaseResolution(
baseImageResolutionContext,
previousEntry?.imageTag,
);
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
baseImageResolutionFlow.captureBaseResolution(baseImageResolutionContext, previousEntry?.imageTag);
policyPresetCarry.applyRecreatePolicyCarryForward(sandboxName, isNonInteractive(), note);

const noRestorePending = pendingStateRestore === null && pendingStateRestoreBackupPath === null;
if (
noRestorePending &&
!notReadyRecreateInProgress &&
!shouldSkipPreRecreateBackup(process.env)
) {
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
if (noRestorePending && !notReadyRecreateInProgress && !shouldSkipPreRecreateBackup(process.env)) {
note(" Backing up workspace state before recreating sandbox...");
const result = recreateProtection.backup();
if (!result.ok) {
Expand All @@ -2594,13 +2581,13 @@ async function createSandboxWithBaseImageResolution(

note(` Deleting and recreating sandbox '${sandboxName}'...`);

recreateRuntime.advance("deleting");
runSandboxProviderPreDeleteCleanup(sandboxName, { runOpenshell, redact });
runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true });
recreateRuntime.confirmDeleted();
if (previousEntry?.imageTag) {
const rmiResult = dockerRmi(previousEntry.imageTag, {
ignoreError: true,
suppressOutput: true,
});
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const rmiResult = dockerRmi(previousEntry.imageTag, { ignoreError: true, suppressOutput: true });
if (rmiResult.status !== 0) {
console.warn(` Warning: failed to remove old sandbox image '${previousEntry.imageTag}'.`);
}
Expand Down Expand Up @@ -2726,6 +2713,7 @@ async function createSandboxWithBaseImageResolution(
});
const restoreBackupPath =
pendingStateRestore?.manifest?.backupPath ?? pendingStateRestoreBackupPath;
recreateRuntime.advance("creating");
const {
createResult,
dockerGpuCreatePatch,
Expand Down Expand Up @@ -2821,6 +2809,7 @@ async function createSandboxWithBaseImageResolution(
buildContext.extractBuiltImageRef(`${firstCreateOutput}\n${createResult.output}`) ??
resolveSandboxImageTagFromCreateOutput(`${firstCreateOutput}\n${createResult.output}`, buildId);
const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig);
recreateRuntime.recordCreated();
finalizeCreatedSandbox(
{
sandboxName,
Expand Down Expand Up @@ -2867,6 +2856,7 @@ async function createSandboxWithBaseImageResolution(
hermesToolGateways,
hermesDashboardState: finalHermesDashboardState,
dashboardPort: actualDashboardPort,
...recreateRuntime.registrationFields,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
}),
Expand Down Expand Up @@ -4415,6 +4405,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
hydrateMessagingChannelConfig,
messagingChannelConfigsEqual,
getSandboxReuseState,
getSandboxRecreateObservation,
getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) =>
getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, {
runCaptureOpenshell,
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/checkpoint-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function checkpoint(overrides: Partial<OnboardCheckpoint> = {}): OnboardCheckpoi
gatewayAuthority: decisionUnset(),
effectGroups: {},
bindings: { credentialEnvs: [], registeredProviders: [] },
sandboxRecreate: null,
...overrides,
};
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/checkpoint-resume-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const loadedCheckpoint: OnboardCheckpoint = {
gatewayAuthority: decisionUnset(),
effectGroups: {},
bindings: { credentialEnvs: [], registeredProviders: [] },
sandboxRecreate: null,
};

function makeDeps(overrides: Partial<OnboardSessionBootstrapDeps>): OnboardSessionBootstrapDeps {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/onboard/lifecycle-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ runtime mutation
| **Credential rotation** — `configRotateToken` in `src/lib/sandbox/config.ts` | A session with `credentialEnv` selects the provider and binding. A non-null different `sandboxName` is rejected, but a legacy/null session name is accepted for the requested sandbox. The new value comes from a named environment variable, stdin, or a secret prompt; it is trimmed, then rejected when empty or still containing internal whitespace. | `saveCredential` first stages the value in the current process. OpenShell provider update is the first external mutation, with provider create as a fallback; audit follows. No sandbox deletion. | The logical binding is unchanged, so session and registry are not rewritten. The raw value exists only in process memory/environment and the gateway provider; audit records action/sandbox/reason without the value. | No rollback after a successful provider update; an audit failure can report failure after the credential is already active. Covered by the rotate-token cases in `test/config-set-nested-ssrf.test.ts`. Gap: a null-name legacy session is not strongly bound to the requested sandbox. |
| **Config, policy, resource, port-forward, and runtime setup contributions** — `configSet`; `prepareInitialSandboxCreatePolicy`; `selectResourceProfileForSandbox`; manifest compiler/runtime appliers; dashboard and channel forward helpers | Config uses validated dotpaths and SSRF-safe URL rewriting. Create/rebuild contributions are assembled by `sandbox-create-plan.ts` and `MessagingWorkflowPlanner`: policy presets/keys, resource flags, package/build steps, `hostForward`, runtime node preloads, env aliases, and secret scans. | Config’s first effect is a compare-and-swap sandbox write. Build-time contributions inherit the enclosing create/recreate boundary. Forward helpers can stop an existing forward and start its replacement in place after readiness, without recreating the sandbox. | Durable owners are compact registry messaging/policy/inference metadata, current manifests used for plan rehydration, onboard session, sandbox config/hash, gateway provider state, and shields audit. An interrupted onboarding session records the selected resource values or an explicit OpenShell-default choice; the resolved create intent remains process-local. Logical bindings are serializable; raw provider values are not. | CAS rejects stale config writes; OpenClaw/Hermes commit config and integrity hashes together, while other agents may refresh a path hash afterward. Audit and optional restart are post-commit and forward-only. Forward recovery can re-establish declared forwards. Gaps: no cross-contribution effect transaction/checkpoint. |

## Durable resumed recreate journal

A resumed same-name replacement writes a secret-free journal before the lower create path can delete the source sandbox.
The journal binds the session, sandbox, selected gateway, source registry row, source OpenShell ID fingerprint, target intent, target generation, and the replacement OpenShell ID fingerprint after creation.

Recovery accepts only these states:

- The source row and live ID match, so deletion can continue.
- The source row remains and OpenShell reports no sandbox, so creation can continue.
- The journaled replacement ID matches both the registry row and the ready live sandbox, and the registry row has the target generation, so the replacement can be accepted.

All other combinations stop before the current run reuses, deletes, or creates a sandbox.
The lower create path stamps the target generation and hashed OpenShell ID into the replacement row.
The handler clears the journal after it records both create and registration receipts.

This slice covers resumed onboard replacement, including not-ready repair and non-default gateways.
Rebuild and non-resumed re-onboard remain under #6492.

## Agent-specific differences

| Agent | Lifecycle difference |
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/machine/core-flow-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ function createPhases(
hydrateMessagingChannelConfig: (config) => config,
messagingChannelConfigsEqual: () => true,
getSandboxReuseState: () => "missing",
getSandboxRecreateObservation: () => ({ state: "missing", liveIdentityFingerprint: null }),
getDcodeSelectionDrift: () => ({ changed: false, unknown: false }),
hasSandboxGpuDrift: () => false,
getSandboxHermesToolGateways: () => [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function crashedCheckpoint(overrides: Partial<OnboardCheckpoint> = {}): OnboardC
},
},
bindings: { credentialEnvs: [], registeredProviders: [] },
sandboxRecreate: null,
...overrides,
};
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/machine/handlers/sandbox-messaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ function withMessagingCheckpoint(
gatewayAuthority: decisionUnset(),
effectGroups: {},
bindings: { credentialEnvs: [], registeredProviders: [] },
sandboxRecreate: null,
};
session.checkpoint = checkpoint;
return session;
Expand Down
Loading
Loading