diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 46bd4c70e3..f5eacf435b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6134,9 +6134,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.error(" A sandbox name cannot be prompted for in this context."); process.exit(1); } - // Same fail-fast contract for NEMOCLAW_POLICY_TIER (#3741): - // validate before usage-notice state, preflight, gateway, or inference work. - policyTierEnv.validatePolicyTierEnvEarly(); + // Fail fast for NEMOCLAW_POLICY_TIER only where selectPolicyTier reads it. + if (isNonInteractive()) policyTierEnv.validatePolicyTierEnvEarly(); const noticeAccepted = await ensureUsageNoticeConsent({ nonInteractive: isNonInteractive(), acceptedByFlag: opts.acceptThirdPartySoftware === true, diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 2b0f28256c..b3bbc20c04 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -17,18 +17,26 @@ const repoRoot = path.join(import.meta.dirname, ".."); * Run a small inline Node script that mocks out the minimal dependencies of * onboard.js, calls the given async expression, and prints a JSON payload. */ -function runScript(scriptBody: string): SpawnSyncReturns { +function runScript( + scriptBody: string, + envOverrides: Record = {}, +): SpawnSyncReturns { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-tier-onboard-")); const scriptPath = path.join(tmpDir, "script.js"); fs.writeFileSync(scriptPath, scriptBody); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + ...envOverrides, + }; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete env[key]; + } const result = spawnSync(process.execPath, [scriptPath], { cwd: repoRoot, encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - NEMOCLAW_NON_INTERACTIVE: "1", - }, + env, timeout: 15000, }); fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -198,6 +206,44 @@ process.exit = (code = 0) => { assert.ok(!result.stdout.includes("UNEXPECTED_SUCCESS")); }); + it("ignores invalid NEMOCLAW_POLICY_TIER during interactive onboarding", () => { + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const script = String.raw` +process.env.NEMOCLAW_POLICY_TIER = "invalid_tier"; +delete process.env.NEMOCLAW_NON_INTERACTIVE; +const { onboard } = require(${onboardPath}); +const exitMarker = "__NEMOCLAW_TEST_PROCESS_EXIT__"; +process.exit = (code = 0) => { + const err = new Error(exitMarker); + err.code = Number(code); + throw err; +}; +(async () => { + try { + await onboard({ + acceptThirdPartySoftware: true, + sandboxName: "tier-test", + }); + process.stdout.write("UNEXPECTED_SUCCESS\n"); + process.exitCode = 0; + } catch (err) { + if (!err || err.message !== exitMarker) { + process.stderr.write((err && err.stack) || String(err)); + process.exitCode = 99; + return; + } + process.stdout.write(JSON.stringify({ exitCode: err.code }) + "\n"); + process.exitCode = err.code; + } +})(); +`; + const result = runScript(script, { NEMOCLAW_NON_INTERACTIVE: undefined }); + assert.equal(result.status, 1, result.stderr); + assert.doesNotMatch(result.stderr, /Unknown policy tier: invalid_tier/); + assert.match(result.stderr, /Interactive onboarding requires a TTY/); + assert.ok(!result.stdout.includes("UNEXPECTED_SUCCESS")); + }); + it("treats whitespace-only NEMOCLAW_POLICY_TIER as the balanced default", () => { const script = buildPreamble({ tierEnv: " " }) +