diff --git a/.agents/skills/find-review-pr/SKILL.md b/.agents/skills/find-review-pr/SKILL.md new file mode 100644 index 00000000000..dce8d5fc142 --- /dev/null +++ b/.agents/skills/find-review-pr/SKILL.md @@ -0,0 +1,108 @@ +--- +name: find-review-pr +description: Finds open GitHub PRs with security and priority-high labels, links each to its issue, detects duplicates (multiple PRs fixing the same issue), and presents a table of review candidates. Use when looking for the next PR to review. Trigger keywords - find pr, find review, next pr, pr to review, duplicate pr, security pr. +user_invocable: true +--- + +# Find PR to Review + +Search for open PRs labeled `security` + `priority: high`, associate each with its linked issue, detect duplicates (multiple PRs targeting the same issue), and present a clean summary so you can decide what to review or close. + +## Prerequisites + +- `gh` (GitHub CLI) must be installed and authenticated. +- You must be in a GitHub repository (or the user must specify `OWNER/REPO`). + +## Step 1: Fetch candidate PRs + +List all open PRs that carry **both** the `security` and `priority: high` labels: + +```bash +gh pr list --label security --label "priority: high" --state open --limit 50 --json number,title,author,headRefName,labels,body,createdAt +``` + +If the result is empty, report that there are no matching PRs and stop. + +## Step 2: Extract linked issues + +For each PR, parse the body for linked issue references. Look for these patterns (case-insensitive): + +- `Fixes #NNN`, `Closes #NNN`, `Resolves #NNN` +- `Related Issue` / `Linked Issue` section containing `#NNN` +- Issue number in the PR title, e.g. `(#NNN)` suffix +- Branch name containing an issue number, e.g. `fix/something-NNN` + +Build a mapping: `PR# → [issue numbers]`. + +If a PR has no detectable linked issue, mark it as `(no linked issue)`. + +## Step 3: Detect duplicates + +Group PRs by linked issue number. Any issue with **two or more** open PRs is a duplicate group. + +For each duplicate group, fetch a brief summary of each competing PR to help the user decide which to keep: + +```bash +gh pr view --json number,title,author,createdAt,additions,deletions,reviewDecision,statusCheckRollup --jq '{number,title,author: .author.login,created: .createdAt,additions,deletions,review: .reviewDecision,checks: [.statusCheckRollup[]?.conclusion] | unique}' +``` + +## Step 4: Check for superseded PRs + +Also flag PRs whose body contains phrases like: + +- `follow-up to #NNN` / `supersedes #NNN` / `replaces #NNN` / `folds in #NNN` + +where `#NNN` is another **open** PR number in the candidate list. These indicate one PR has absorbed another. + +## Step 5: Present results + +### Duplicates / Superseded + +If duplicates or superseded PRs exist, present them first in a table: + +```markdown +### Duplicate PRs (same issue) + +| Issue | PR | Author | Title | +/- | Status | +|-------|-----|--------|-------|-----|--------| +| #804 | #1121 | user1 | ... | +50/-10 | Checks passing | +| #804 | #1300 | user2 | ... | +80/-20 | Checks failing | + +**Recommendation:** #1121 is smaller and passing checks — consider closing #1300. +``` + +For superseded PRs: + +```markdown +### Superseded PRs + +- #1416 supersedes/folds in #1392 (shell-quote sandboxName) + → Consider closing #1392 if #1416 covers its scope. +``` + +### Clean candidates + +Present non-duplicate PRs in a table: + +```markdown +### Review candidates (no duplicates) + +| PR | Issue | Title | Author | Age | +|----|-------|-------|--------|-----| +| #1476 | #577 | disable remote uninstall fallback | user1 | 2d | +| #1121 | #804 | Landlock read-only /sandbox | user2 | 6d | +``` + +### Summary line + +End with a one-line recommendation of which PR to review first, preferring: + +1. Older PRs (waiting longest) +2. PRs with passing checks +3. PRs with smaller diff size (easier to review) + +## Notes + +- Do NOT automatically close any PRs. Only present findings and recommendations. +- If the user specifies additional filters (e.g., a specific scope label like `OpenShell`), apply them. +- If the user asks for a different priority label, adjust accordingly. diff --git a/.agents/skills/nemoclaw-reference/references/commands.md b/.agents/skills/nemoclaw-reference/references/commands.md index 78d57b2f036..cb576e4d4e5 100644 --- a/.agents/skills/nemoclaw-reference/references/commands.md +++ b/.agents/skills/nemoclaw-reference/references/commands.md @@ -41,7 +41,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```console -$ nemoclaw onboard +$ nemoclaw onboard [--non-interactive] [--resume] [--from ] ``` The wizard prompts for a provider first, then collects the provider credential if needed. @@ -82,6 +82,26 @@ Uppercase letters are automatically lowercased. Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable, warns on unsupported runtimes such as Podman, and prints host remediation guidance when prerequisites are missing. +#### `--from ` + +Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. +The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. + +```console +$ nemoclaw onboard --from path/to/Dockerfile +``` + +The file can have any name; if it is not already named `Dockerfile`, onboard copies it to `Dockerfile` inside the staged build context automatically. +All NemoClaw build arguments (`NEMOCLAW_MODEL`, `NEMOCLAW_PROVIDER_KEY`, `NEMOCLAW_INFERENCE_BASE_URL`, etc.) are injected as `ARG` overrides at build time, so declare them in your Dockerfile if you need to reference them. + +In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable: + +```console +$ NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_FROM_DOCKERFILE=path/to/Dockerfile nemoclaw onboard +``` + +If a `--resume` is attempted with a different `--from` path than the original session, onboarding exits with a conflict error rather than silently building from the wrong image. + ### `nemoclaw list` List all registered sandboxes with their model, provider, and policy presets. diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 806d96b9e73..c08103526b6 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1327,6 +1327,18 @@ function getResumeConfigConflicts(session, opts = {}) { }); } + const requestedFrom = opts.fromDockerfile ? path.resolve(opts.fromDockerfile) : null; + const recordedFrom = session?.metadata?.fromDockerfile + ? path.resolve(session.metadata.fromDockerfile) + : null; + if (requestedFrom !== recordedFrom) { + conflicts.push({ + field: "fromDockerfile", + requested: requestedFrom, + recorded: recordedFrom, + }); + } + return conflicts; } @@ -1942,6 +1954,7 @@ async function createSandbox( sandboxNameOverride = null, webSearchConfig = null, enabledChannels = null, + fromDockerfile = null, ) { step(6, 8, "Creating sandbox"); @@ -2028,8 +2041,34 @@ async function createSandbox( registry.removeSandbox(sandboxName); } - // Stage only the files the Docker build actually consumes so uploads stay small. - const { buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(ROOT); + // Stage build context — use the custom Dockerfile path when provided, + // otherwise use the optimised default that only sends what the build needs. + let buildCtx, stagedDockerfile; + if (fromDockerfile) { + const fromResolved = path.resolve(fromDockerfile); + if (!fs.existsSync(fromResolved)) { + console.error(` Custom Dockerfile not found: ${fromResolved}`); + process.exit(1); + } + buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); + stagedDockerfile = path.join(buildCtx, "Dockerfile"); + // Copy the entire parent directory as build context. + fs.cpSync(path.dirname(fromResolved), buildCtx, { + recursive: true, + filter: (src) => { + const base = path.basename(src); + return !["node_modules", ".git", ".venv", "__pycache__"].includes(base); + }, + }); + // If the caller pointed at a file not named "Dockerfile", copy it to the + // location openshell expects (buildCtx/Dockerfile). + if (path.basename(fromResolved) !== "Dockerfile") { + fs.copyFileSync(fromResolved, stagedDockerfile); + } + console.log(` Using custom Dockerfile: ${fromResolved}`); + } else { + ({ buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(ROOT)); + } // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) @@ -3737,6 +3776,12 @@ async function onboard(opts = {}) { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; + // In non-interactive mode also accept the env var so CI pipelines can set it. + // This is the explicitly requested value; on resume it may be absent and the + // session-recorded path is used instead (see below). + const requestedFromDockerfile = + opts.fromDockerfile || + (isNonInteractive() ? process.env.NEMOCLAW_FROM_DOCKERFILE || null : null); const noticeAccepted = await ensureUsageNoticeConsent({ nonInteractive: isNonInteractive(), acceptedByFlag: opts.acceptThirdPartySoftware === true, @@ -3746,7 +3791,7 @@ async function onboard(opts = {}) { process.exit(1); } const lockResult = onboardSession.acquireOnboardLock( - `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}`, + `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}${requestedFromDockerfile ? ` --from ${requestedFromDockerfile}` : ""}`, ); if (!lockResult.acquired) { console.error(" Another NemoClaw onboarding run is already in progress."); @@ -3771,6 +3816,10 @@ async function onboard(opts = {}) { try { let session; + // 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; if (resume) { session = onboardSession.loadSession(); if (!session || session.resumable === false) { @@ -3778,8 +3827,15 @@ async function onboard(opts = {}) { console.error(" Run: nemoclaw onboard"); process.exit(1); } + const sessionFrom = session?.metadata?.fromDockerfile || null; + fromDockerfile = requestedFromDockerfile + ? path.resolve(requestedFromDockerfile) + : sessionFrom + ? path.resolve(sessionFrom) + : null; const resumeConflicts = getResumeConfigConflicts(session, { nonInteractive: isNonInteractive(), + fromDockerfile: requestedFromDockerfile, }); if (resumeConflicts.length > 0) { for (const conflict of resumeConflicts) { @@ -3787,6 +3843,20 @@ async function onboard(opts = {}) { console.error( ` Resumable state belongs to sandbox '${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}'.`, @@ -3805,10 +3875,11 @@ async function onboard(opts = {}) { }); session = onboardSession.loadSession(); } else { + fromDockerfile = requestedFromDockerfile ? path.resolve(requestedFromDockerfile) : null; session = onboardSession.saveSession( onboardSession.createSession({ mode: isNonInteractive() ? "non-interactive" : "interactive", - metadata: { gatewayName: "nemoclaw" }, + metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile || null }, }), ); } @@ -4007,6 +4078,7 @@ async function onboard(opts = {}) { sandboxName, webSearchConfig, enabledChannels, + fromDockerfile, ); onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); } diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 6b7bc61ca8c..858c54a838f 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -779,12 +779,29 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); + + // Extract --from before the unknown-arg validator: it takes a value + // so the set-based check would reject the value token as an unknown flag. + let fromDockerfile = null; + const fromIdx = args.indexOf("--from"); + if (fromIdx !== -1) { + fromDockerfile = args[fromIdx + 1]; + if (!fromDockerfile || fromDockerfile.startsWith("--")) { + console.error(" --from requires a path to a Dockerfile"); + console.error( + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--from ] [${NOTICE_ACCEPT_FLAG}]`, + ); + process.exit(1); + } + args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)]; + } + const allowedArgs = new Set(["--non-interactive", "--resume", NOTICE_ACCEPT_FLAG]); const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); console.error( - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [${NOTICE_ACCEPT_FLAG}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--from ] [${NOTICE_ACCEPT_FLAG}]`, ); process.exit(1); } @@ -792,7 +809,7 @@ async function onboard(args) { const resume = args.includes("--resume"); const acceptThirdPartySoftware = args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; - await runOnboard({ nonInteractive, resume, acceptThirdPartySoftware }); + await runOnboard({ nonInteractive, resume, fromDockerfile, acceptThirdPartySoftware }); } async function setup(args = []) { @@ -1262,6 +1279,7 @@ function help() { ${G}Getting Started:${R} ${B}nemoclaw onboard${R} Configure inference endpoint and credentials + nemoclaw onboard ${D}--from ${R} Use a custom Dockerfile for the sandbox image ${D}(non-interactive: ${NOTICE_ACCEPT_FLAG} or ${NOTICE_ACCEPT_ENV}=1)${R} ${G}Sandbox Management:${R} diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c312c69915a..e72c7a51db8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -63,7 +63,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```console -$ nemoclaw onboard +$ nemoclaw onboard [--non-interactive] [--resume] [--from ] ``` The wizard prompts for a provider first, then collects the provider credential if needed. @@ -104,6 +104,26 @@ Uppercase letters are automatically lowercased. Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable, warns on unsupported runtimes such as Podman, and prints host remediation guidance when prerequisites are missing. +#### `--from ` + +Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. +The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. + +```console +$ nemoclaw onboard --from path/to/Dockerfile +``` + +The file can have any name; if it is not already named `Dockerfile`, onboard copies it to `Dockerfile` inside the staged build context automatically. +All NemoClaw build arguments (`NEMOCLAW_MODEL`, `NEMOCLAW_PROVIDER_KEY`, `NEMOCLAW_INFERENCE_BASE_URL`, etc.) are injected as `ARG` overrides at build time, so declare them in your Dockerfile if you need to reference them. + +In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable: + +```console +$ NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_FROM_DOCKERFILE=path/to/Dockerfile nemoclaw onboard +``` + +If a `--resume` is attempted with a different `--from` path than the original session, onboarding exits with a conflict error rather than silently building from the wrong image. + ### `nemoclaw list` List all registered sandboxes with their model, provider, and policy presets. diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index 588d38bbbb4..65a8febc449 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -35,6 +35,7 @@ export interface SessionFailure { export interface SessionMetadata { gatewayName: string; + fromDockerfile: string | null; } export interface Session { @@ -86,7 +87,7 @@ export interface SessionUpdates { nimContainer?: string; webSearchConfig?: WebSearchConfig | null; policyPresets?: string[]; - metadata?: { gatewayName?: string }; + metadata?: { gatewayName?: string; fromDockerfile?: string | null }; } // ── Helpers ────────────────────────────────────────────────────── @@ -201,6 +202,7 @@ export function createSession(overrides: Partial = {}): Session { : null, metadata: { gatewayName: overrides.metadata?.gatewayName || "nemoclaw", + fromDockerfile: overrides.metadata?.fromDockerfile || null, }, steps: { ...defaultSteps(), @@ -238,7 +240,10 @@ export function normalizeSession(data: unknown): Session | null { lastCompletedStep: typeof d.lastCompletedStep === "string" ? d.lastCompletedStep : null, failure: sanitizeFailure(d.failure as Record | null), metadata: isObject(d.metadata) - ? ({ gatewayName: (d.metadata as Record).gatewayName } as SessionMetadata) + ? ({ + gatewayName: (d.metadata as Record).gatewayName, + fromDockerfile: (d.metadata as Record).fromDockerfile || null, + } as SessionMetadata) : undefined, } as Partial); normalized.resumable = d.resumable !== false; @@ -424,6 +429,7 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { if (isObject(updates.metadata) && typeof updates.metadata.gatewayName === "string") { safe.metadata = { gatewayName: updates.metadata.gatewayName, + fromDockerfile: (typeof updates.metadata.fromDockerfile === "string" ? updates.metadata.fromDockerfile : null), }; } return safe; diff --git a/test/onboard.test.js b/test/onboard.test.js index 62f37d6af02..bfe4fa10fcf 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1300,7 +1300,7 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*enabledChannels,\s*\);/, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*enabledChannels,\s*fromDockerfile,\s*\);/, ); }); @@ -3030,6 +3030,191 @@ const { setupMessagingChannels } = require(${onboardPath}); }, ); + it("uses the custom Dockerfile parent directory as build context when --from is given", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-dockerfile-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "create-sandbox-from.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + // Create a minimal custom Dockerfile in a temporary directory + const customBuildDir = path.join(tmpDir, "custom-image"); + fs.mkdirSync(customBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(customBuildDir, "Dockerfile"), + [ + "FROM ubuntu:22.04", + "ARG NEMOCLAW_MODEL=nvidia/nemotron-super-49b-v1", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-super-49b-v1", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + "RUN echo done", + ].join("\n"), + ); + fs.writeFileSync(path.join(customBuildDir, "extra.txt"), "extra build context file"); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const customDockerfilePath = JSON.stringify(path.join(customBuildDir, "Dockerfile")); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); +const fs = require("node:fs"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + const sandboxName = await createSandbox(null, "gpt-5.4", "openai-api", null, "my-assistant", null, null, ${customDockerfilePath}); + // Verify the staged build context contains the extra file from the custom dir + const createCmd = commands.find((e) => e.command.includes("'sandbox' 'create'")); + const fromMatch = createCmd && createCmd.command.match(/--from['\s]+'([^']+)'/); + let stagedDir = null; + let hasExtraFile = false; + if (fromMatch) { + const dockerfilePath = fromMatch[1]; + stagedDir = require("node:path").dirname(dockerfilePath); + hasExtraFile = fs.existsSync(require("node:path").join(stagedDir, "extra.txt")); + } + console.log(JSON.stringify({ sandboxName, hasExtraFile })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + assert.equal(payload.sandboxName, "my-assistant"); + assert.equal( + payload.hasExtraFile, + true, + "extra.txt from custom build context should be staged", + ); + }); + + it("exits with an error when the --from Dockerfile path does not exist", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-missing-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "create-sandbox-missing.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const missingPath = JSON.stringify(path.join(tmpDir, "does-not-exist", "Dockerfile")); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); + +runner.run = () => ({ status: 0 }); +runner.runCapture = () => ""; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + await createSandbox(null, "gpt-5.4", "openai-api", null, "my-assistant", null, null, ${missingPath}); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 1, "should exit 1 when fromDockerfile path is missing"); + assert.match(result.stderr, /Custom Dockerfile not found/); + }); + it("re-prompts on invalid sandbox names instead of exiting in interactive mode", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"),