Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
d2689c5
refactor(onboard): extract docker gateway runtime helpers
cv Jun 10, 2026
cfae232
test(onboard): cover docker gateway runtime helpers
cv Jun 10, 2026
75f5a52
docs(onboard): explain docker gateway runtime boundary
cv Jun 10, 2026
128ef56
refactor(onboard): extract dashboard port create resolver
cv Jun 10, 2026
8d9f4c1
test(onboard): document malformed dashboard URL resolution
cv Jun 10, 2026
b6ea38f
refactor(onboard): centralize reused dashboard metadata
cv Jun 10, 2026
2d9d81a
test(onboard): cover disabled Hermes dashboard reuse metadata
cv Jun 10, 2026
318a002
refactor(onboard): extract sandbox registration payload
cv Jun 10, 2026
106d27d
refactor(onboard): extract messaging preparation
cv Jun 10, 2026
45b449b
test(onboard): cover messaging preparation edge cases
cv Jun 10, 2026
97b99bc
refactor(onboard): extract build context staging
cv Jun 10, 2026
a928abd
test(onboard): cover build context staging edge cases
cv Jun 10, 2026
1521702
refactor(onboard): extract sandbox launch envelope
cv Jun 10, 2026
d2c9122
test(onboard): cover sandbox launch shell boundaries
cv Jun 10, 2026
4beb640
refactor(onboard): extract build patch config
cv Jun 10, 2026
6bca4c8
refactor(onboard): extract dockerfile patch flow
cv Jun 10, 2026
06a7cf2
fix(onboard): harden dockerfile patch writes
cv Jun 10, 2026
ebf0c47
fix(onboard): satisfy secure dockerfile open
cv Jun 10, 2026
979706f
test(onboard): isolate dockerfile security guard
cv Jun 10, 2026
3513878
test(onboard): cover dockerfile write guards
cv Jun 10, 2026
b793d63
fix(onboard): validate dockerfile before truncating
cv Jun 10, 2026
a08fce9
refactor(onboard): extract sandbox create plan
cv Jun 10, 2026
0c5db46
refactor(onboard): extract sandbox messaging preflight
cv Jun 10, 2026
0d87a4d
refactor(onboard): extract entry option resolution
cv Jun 10, 2026
13031f9
test(onboard): cover from entry option guards
cv Jun 10, 2026
2c5eb66
fix(onboard): keep entry reserved-name exits clean
cv Jun 10, 2026
726a539
chore(onboard): refresh entry option resume comment
cv Jun 10, 2026
a7e54ba
refactor(onboard): extract session bootstrap flow
cv Jun 10, 2026
c8cc679
test(onboard): cover resume conflict diagnostic failures
cv Jun 10, 2026
f959ccc
Merge remote-tracking branch 'origin/main' into codex/tmp-dashboard-p…
cv Jun 11, 2026
c06aae6
Merge branch 'codex/tmp-dashboard-port-merge' into codex/tmp-reuse-da…
cv Jun 11, 2026
c66b889
Merge branch 'codex/tmp-reuse-dashboard-merge' into HEAD
cv Jun 11, 2026
c35baff
Merge branch 'codex/tmp-registration-merge' into HEAD
cv Jun 11, 2026
9e0ded0
Merge branch 'codex/tmp-messaging-prep-merge' into HEAD
cv Jun 11, 2026
a59867f
Merge branch 'codex/tmp-build-context-merge' into HEAD
cv Jun 11, 2026
6d40787
Merge branch 'codex/tmp-sandbox-launch-merge' into HEAD
cv Jun 11, 2026
89d74e5
Merge branch 'codex/tmp-build-patch-config-merge' into HEAD
cv Jun 11, 2026
5cccd4b
Merge branch 'codex/tmp-dockerfile-patch-merge' into HEAD
cv Jun 11, 2026
5d0b22e
Merge branch 'codex/tmp-create-plan-merge' into HEAD
cv Jun 11, 2026
a90589f
fix(onboard): drop unused build patch config bindings
cv Jun 11, 2026
a42525e
Merge branch 'codex/tmp-build-patch-config-merge-v2' into HEAD
cv Jun 11, 2026
3f469f0
Merge branch 'codex/tmp-dockerfile-patch-merge-v2' into HEAD
cv Jun 11, 2026
c4548ef
Merge branch 'codex/tmp-create-plan-merge-v2' into HEAD
cv Jun 11, 2026
9557fbb
Merge branch 'codex/tmp-messaging-preflight-merge-v2' into HEAD
cv Jun 11, 2026
4cafa11
Merge branch 'codex/tmp-entry-options-merge-v2' into HEAD
cv Jun 11, 2026
57ed3dc
Merge remote-tracking branch 'origin/main' into codex/pr-5148-update
cv Jun 11, 2026
491a555
Merge remote-tracking branch 'origin/codex/onboard-build-patch-config…
cv Jun 11, 2026
bf9b269
Merge remote-tracking branch 'origin/codex/onboard-dockerfile-patch-f…
cv Jun 11, 2026
e36fba1
Merge remote-tracking branch 'origin/codex/onboard-create-plan-flow' …
cv Jun 11, 2026
24f6819
fix(onboard): preserve messaging gateway conflicts
cv Jun 11, 2026
225c01e
test(onboard): assert messaging preflight return channels
cv Jun 11, 2026
edc16f7
merge: refresh entry option flow stack base
cv Jun 11, 2026
20349dc
merge: refresh session bootstrap stack base
cv Jun 11, 2026
eb1c166
merge(onboard): main into build patch config flow
cv Jun 11, 2026
e9d1600
fix(onboard): narrow build patch config helper
cv Jun 11, 2026
b239831
fix(onboard): clarify build patch config boundary
cv Jun 11, 2026
d183ff0
Merge remote-tracking branch 'origin/codex/onboard-build-patch-config…
cv Jun 11, 2026
02d6ea2
Merge remote-tracking branch 'origin/codex/onboard-dockerfile-patch-f…
cv Jun 11, 2026
80077d8
Merge remote-tracking branch 'origin/codex/onboard-create-plan-flow' …
cv Jun 11, 2026
e72cebb
Merge remote-tracking branch 'origin/codex/onboard-messaging-prefligh…
cv Jun 11, 2026
fc4794e
Merge remote-tracking branch 'origin/codex/onboard-entry-options-flow…
cv Jun 11, 2026
d4b1b86
Merge remote-tracking branch 'origin/main' into codex/pr-5164-update
cv Jun 12, 2026
921c3b6
Merge remote-tracking branch 'origin/main' into codex/pr-5164-update
cv Jun 12, 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
138 changes: 27 additions & 111 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const sandboxMessagingPreflight: typeof import("./onboard/sandbox-messaging-pref
const sandboxCreatePlan: typeof import("./onboard/sandbox-create-plan") = require("./onboard/sandbox-create-plan");
const sandboxCreateLaunch: typeof import("./onboard/sandbox-create-launch") = require("./onboard/sandbox-create-launch");
const onboardEntryOptions: typeof import("./onboard/entry-options") = require("./onboard/entry-options");
const onboardSessionBootstrap: typeof import("./onboard/session-bootstrap") = require("./onboard/session-bootstrap");
const channelState: typeof import("./onboard/channel-state") = require("./onboard/channel-state");
const {
ensureOllamaLoopbackSystemdOverride,
Expand Down Expand Up @@ -4881,119 +4882,34 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
let traceCompleted = false;
try {
onboardTrace = onboardTracing.startOnboardTrace(opts, process.env);
let session: Session | null;
let selectedMessagingChannels: string[] = [];
// Merged, absolute fromDockerfile: explicit flag/env takes precedence; on
// resume falls back to what the original session recorded so the same image
// is used even when --from is omitted from the resume invocation.
let fromDockerfile: string | null;
if (resume) {
session = onboardSession.loadSession();
setOnboardBrandingAgent(opts.agent || session?.agent || process.env.NEMOCLAW_AGENT || null);
if (!session || session.resumable === false) {
console.error(" No resumable onboarding session was found.");
console.error(" --resume only continues an interrupted onboarding run.");
console.error(" To change configuration on an existing sandbox, rebuild it:");
console.error(` ${cliName()} onboard`);
process.exit(1);
}
const sessionFrom = session?.metadata?.fromDockerfile || null;
fromDockerfile = requestedFromDockerfile
? path.resolve(requestedFromDockerfile)
: sessionFrom
? path.resolve(sessionFrom)
: null;
const resumeConflicts = getResumeConfigConflicts(session, {
let { session, fromDockerfile } = await onboardSessionBootstrap.prepareOnboardSession(
{
resume,
fresh,
requestedFromDockerfile,
requestedSandboxName,
cannotPrompt,
nonInteractive: isNonInteractive(),
fromDockerfile: requestedFromDockerfile,
sandboxName: requestedSandboxName,
agent: opts.agent || null,
});
if (resumeConflicts.length > 0) {
for (const conflict of resumeConflicts) {
try {
await onboardRuntimeBoundary.recordResumeConflict(conflict);
} catch {
/* diagnostic-only */
}
if (conflict.field === "sandbox") {
console.error(
` Resumable state belongs to sandbox '${conflict.recorded}', not '${conflict.requested}'.`,
);
} else if (conflict.field === "agent") {
console.error(
` Session was started with agent '${conflict.recorded}', not '${conflict.requested}'.`,
);
} else if (conflict.field === "fromDockerfile") {
if (!conflict.recorded) {
console.error(
` Session was started without --from; add --from '${conflict.requested}' to resume it.`,
);
} else if (!conflict.requested) {
console.error(
` Session was started with --from '${conflict.recorded}'; rerun with that path to resume it.`,
);
} else {
console.error(
` Session was started with --from '${conflict.recorded}', not '${conflict.requested}'.`,
);
}
} else {
console.error(
` Resumable state recorded ${conflict.field} '${conflict.recorded}', not '${conflict.requested}'.`,
);
}
}
console.error(
` Run: ${cliName()} onboard # start a fresh onboarding session`,
);
console.error(" Or rerun with the original settings to continue that session.");
process.exit(1);
}
onboardSession.updateSession((current: Session) => {
repairResumeMachineSnapshot(current);
current.mode = isNonInteractive() ? "non-interactive" : "interactive";
current.failure = null;
current.status = "in_progress";
return current;
});
session = onboardSession.loadSession();
// #2753: a resumed onboard whose sandbox step did not complete has no
// recorded sandboxName (the onboard fix only persists it after
// createSandbox succeeds). Falling through would silently default to
// the agent's `my-assistant` instead of the user's original --name.
// Use `cannotPrompt` so non-TTY runs without explicit --non-interactive
// are also caught, and `requestedSandboxName` from
// resolveOnboardEntryOptions so whitespace-only env values can't satisfy
// the guard.
const sandboxStepCompleted = session?.steps?.sandbox?.status === "complete";
const recoveredSandboxName =
requestedSandboxName || (sandboxStepCompleted ? session?.sandboxName || null : null);
if (cannotPrompt && !recoveredSandboxName) {
console.error(
" Cannot resume non-interactive onboard: the previous run was interrupted before sandbox creation completed,",
);
console.error(
" so no sandbox name was recorded. Re-run with --name <sandbox> (or set NEMOCLAW_SANDBOX_NAME).",
);
process.exit(1);
}
} else {
// --fresh asks for an explicit fresh start. createSession + saveSession
// already overwrites any existing file, but clearing first removes the
// old file outright so an interrupted createSession cannot leave the
// previous session readable on disk.
if (fresh) {
onboardSession.clearSession();
}
fromDockerfile = requestedFromDockerfile ? path.resolve(requestedFromDockerfile) : null;
session = onboardSession.saveSession(
onboardSession.createSession({
mode: isNonInteractive() ? "non-interactive" : "interactive",
metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile || null },
}),
);
}
agentFlag: opts.agent || null,
envAgent: process.env.NEMOCLAW_AGENT || null,
},
{
loadSession: onboardSession.loadSession,
clearSession: onboardSession.clearSession,
createSession: onboardSession.createSession,
saveSession: onboardSession.saveSession,
updateSession: onboardSession.updateSession,
repairResumeMachineSnapshot,
setOnboardBrandingAgent,
getResumeConfigConflicts,
recordResumeConflict: (conflict) => onboardRuntimeBoundary.recordResumeConflict(conflict),
resolvePath: path.resolve,
cliName,
error: (message) => console.error(message),
exitProcess: (code) => process.exit(code),
},
);
await onboardRuntimeBoundary.recordOnboardStarted(resume);
await recordStateResult(advanceTo("preflight", { metadata: { state: "init" } }));
// Backstop for the resume path: a session may exist (so the early guard
Expand Down
218 changes: 218 additions & 0 deletions src/lib/onboard/session-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// 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 { createSession, type Session } from "../state/onboard-session";
import { prepareOnboardSession, type OnboardSessionBootstrapDeps } from "./session-bootstrap";
import type { ResumeConfigConflict } from "./resume-config";

class ExitError extends Error {
constructor(readonly code: number) {
super(`exit ${code}`);
}
}

function completeSandboxStep(): Session["steps"][string] {
return {
status: "complete",
startedAt: "2026-06-10T00:00:00.000Z",
completedAt: "2026-06-10T00:01:00.000Z",
error: null,
};
}

function createDeps(
initialSession: Session | null = null,
overrides: Partial<OnboardSessionBootstrapDeps> = {},
): { deps: OnboardSessionBootstrapDeps; getSession: () => Session | null } {
let session = initialSession;
const deps: OnboardSessionBootstrapDeps = {
loadSession: vi.fn(() => session),
clearSession: vi.fn(() => {
session = null;
}),
createSession: vi.fn((sessionOverrides?: Partial<Session>) => createSession(sessionOverrides)),
saveSession: vi.fn((next: Session) => {
session = next;
return next;
}),
updateSession: vi.fn((mutator: (session: Session) => Session | void) => {
const current = session ?? createSession();
const next = mutator(current) ?? current;
session = next;
return next;
}),
repairResumeMachineSnapshot: vi.fn((current: Session) => current),
setOnboardBrandingAgent: vi.fn(),
getResumeConfigConflicts: vi.fn(() => []),
recordResumeConflict: vi.fn(async () => undefined),
resolvePath: vi.fn((value: string) => `/abs/${value}`),
cliName: vi.fn(() => "nemoclaw"),
error: vi.fn(),
exitProcess: vi.fn((code: number) => {
throw new ExitError(code);
}) as (code: number) => never,
...overrides,
};
return { deps, getSession: () => session };
}

describe("prepareOnboardSession", () => {
it("creates a fresh session and records the resolved Dockerfile", async () => {
const existing = createSession({ sessionId: "old-session" });
const { deps, getSession } = createDeps(existing);

const result = await prepareOnboardSession(
{
resume: false,
fresh: true,
requestedFromDockerfile: "Dockerfile.custom",
requestedSandboxName: null,
cannotPrompt: false,
nonInteractive: true,
},
deps,
);

expect(deps.clearSession).toHaveBeenCalledTimes(1);
expect(result.fromDockerfile).toBe("/abs/Dockerfile.custom");
expect(result.session?.mode).toBe("non-interactive");
expect(result.session?.metadata.fromDockerfile).toBe("/abs/Dockerfile.custom");
expect(getSession()?.sessionId).not.toBe("old-session");
});

it("resumes an existing session and falls back to the recorded Dockerfile", async () => {
const initial = createSession({
agent: "hermes",
failure: {
step: "inference",
message: "failed",
recordedAt: "2026-06-10T00:00:00.000Z",
},
metadata: { gatewayName: "nemoclaw", fromDockerfile: "Dockerfile.recorded" },
sandboxName: "demo",
status: "failed",
steps: {
...createSession().steps,
sandbox: completeSandboxStep(),
},
});
const { deps } = createDeps(initial);

const result = await prepareOnboardSession(
{
resume: true,
fresh: false,
requestedFromDockerfile: null,
requestedSandboxName: null,
cannotPrompt: true,
nonInteractive: true,
envAgent: "openclaw",
},
deps,
);

expect(result.fromDockerfile).toBe("/abs/Dockerfile.recorded");
expect(result.session?.mode).toBe("non-interactive");
expect(result.session?.failure).toBeNull();
expect(result.session?.status).toBe("in_progress");
expect(deps.repairResumeMachineSnapshot).toHaveBeenCalledWith(initial);
expect(deps.setOnboardBrandingAgent).toHaveBeenCalledWith("hermes");
});

it("records and reports resume conflicts before exiting", async () => {
const conflict: ResumeConfigConflict = {
field: "fromDockerfile",
requested: "/abs/Dockerfile.new",
recorded: "/abs/Dockerfile.old",
};
const { deps } = createDeps(createSession(), {
getResumeConfigConflicts: vi.fn(() => [conflict]),
});

await expect(
prepareOnboardSession(
{
resume: true,
fresh: false,
requestedFromDockerfile: "Dockerfile.new",
requestedSandboxName: null,
cannotPrompt: false,
nonInteractive: false,
},
deps,
),
).rejects.toThrow(ExitError);

expect(deps.recordResumeConflict).toHaveBeenCalledWith(conflict);
expect(deps.error).toHaveBeenCalledWith(
" Session was started with --from '/abs/Dockerfile.old', not '/abs/Dockerfile.new'.",
);
expect(deps.error).toHaveBeenCalledWith(
" Run: nemoclaw onboard # start a fresh onboarding session",
);
expect(deps.exitProcess).toHaveBeenCalledWith(1);
});

it("still exits on resume conflicts when diagnostic recording fails", async () => {
const conflict: ResumeConfigConflict = {
field: "sandbox",
requested: "new-box",
recorded: "old-box",
};
const { deps } = createDeps(createSession(), {
getResumeConfigConflicts: vi.fn(() => [conflict]),
recordResumeConflict: vi.fn(async () => {
throw new Error("diagnostic write failed");
}),
});

await expect(
prepareOnboardSession(
{
resume: true,
fresh: false,
requestedFromDockerfile: null,
requestedSandboxName: "new-box",
cannotPrompt: false,
nonInteractive: false,
},
deps,
),
).rejects.toThrow(ExitError);

expect(deps.recordResumeConflict).toHaveBeenCalledWith(conflict);
expect(deps.error).toHaveBeenCalledWith(
" Resumable state belongs to sandbox 'old-box', not 'new-box'.",
);
expect(deps.exitProcess).toHaveBeenCalledWith(1);
expect(deps.updateSession).not.toHaveBeenCalled();
});

it("rejects non-interactive resume when no sandbox name can be recovered", async () => {
const { deps } = createDeps(createSession({ sandboxName: null }));

await expect(
prepareOnboardSession(
{
resume: true,
fresh: false,
requestedFromDockerfile: null,
requestedSandboxName: null,
cannotPrompt: true,
nonInteractive: true,
},
deps,
),
).rejects.toThrow(ExitError);

expect(deps.error).toHaveBeenCalledWith(
" Cannot resume non-interactive onboard: the previous run was interrupted before sandbox creation completed,",
);
expect(deps.error).toHaveBeenCalledWith(
" so no sandbox name was recorded. Re-run with --name <sandbox> (or set NEMOCLAW_SANDBOX_NAME).",
);
expect(deps.exitProcess).toHaveBeenCalledTimes(1);
});
});
Loading