diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ae68ac6ead1..acd561987d4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -356,8 +356,9 @@ import type { ProbeResult, ValidationFailureLike, } from "./onboard/types"; -import { channelHasStaticToken, getChannelTokenKeys, listChannels } from "./sandbox/channels"; import { getMessagingToken } from "./onboard/messaging-token"; +import { decidePolicyCarryForward } from "./onboard/policy-carryforward"; +import { channelHasStaticToken, getChannelTokenKeys, listChannels } from "./sandbox/channels"; import { streamGatewayStart } from "./onboard/gateway"; import { reportGpuPassthroughRecovery } from "./onboard/gpu-recovery"; import type { StreamSandboxCreateResult } from "./sandbox/create-stream"; @@ -5446,13 +5447,12 @@ async function createSandbox( } const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName); - const previousPolicies = previousEntry?.policies ?? null; - if (previousPolicies && previousPolicies.length > 0) { - onboardSession.updateSession((current: Session) => { - current.policyPresets = previousPolicies; - return current; - }); - } + const decision = decidePolicyCarryForward(previousEntry?.policies, process.env, isNonInteractive()); + onboardSession.updateSession((c: Session) => { + c.policyPresets = decision.newPresets; + return c; + }); + if (decision.overrideNote !== null) note(decision.overrideNote); note(` Deleting and recreating sandbox '${sandboxName}'...`); diff --git a/src/lib/onboard/policy-carryforward.test.ts b/src/lib/onboard/policy-carryforward.test.ts new file mode 100644 index 00000000000..26216047f99 --- /dev/null +++ b/src/lib/onboard/policy-carryforward.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { decidePolicyCarryForward, shouldCarryPreviousPolicies } from "./policy-carryforward"; + +describe("shouldCarryPreviousPolicies (#2675)", () => { + it("drops previous policies when NEMOCLAW_POLICY_PRESETS overrides on recreate", () => { + expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true)).toBe( + false, + ); + }); + + it("ignores env var in interactive mode (previous list still wins)", () => { + expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, false)).toBe( + true, + ); + }); + + it("drops previous policies when NEMOCLAW_POLICY_MODE=skip", () => { + expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_MODE: "skip" }, true)).toBe( + false, + ); + }); + + it("drops previous policies when NEMOCLAW_POLICY_MODE=custom forces explicit selection", () => { + expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_MODE: "custom" }, true)).toBe( + false, + ); + }); + + it("carries previous policies when NEMOCLAW_POLICY_MODE=suggested (implicit)", () => { + expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_MODE: "suggested" }, true)).toBe( + true, + ); + }); +}); + +describe("decidePolicyCarryForward (#2675)", () => { + it("emits NEMOCLAW_POLICY_PRESETS override note when env clears previous presets", () => { + const decision = decidePolicyCarryForward(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true); + expect(decision.newPresets).toBeNull(); + expect(decision.overrideNote).toContain("NEMOCLAW_POLICY_PRESETS overrides previous presets"); + expect(decision.overrideNote).toContain("was: npm"); + }); + + it("emits NEMOCLAW_POLICY_MODE override note when mode forces clearing", () => { + const decision = decidePolicyCarryForward(["npm"], { NEMOCLAW_POLICY_MODE: "skip" }, true); + expect(decision.newPresets).toBeNull(); + expect(decision.overrideNote).toContain("NEMOCLAW_POLICY_MODE=skip"); + expect(decision.overrideNote).toContain("was: npm"); + }); + + it("carries presets forward in interactive mode even when env vars are set", () => { + const decision = decidePolicyCarryForward(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, false); + expect(decision.newPresets).toEqual(["npm"]); + expect(decision.overrideNote).toBeNull(); + }); + + it("clears without note when there are no previous policies to override", () => { + const decision = decidePolicyCarryForward([], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true); + expect(decision.newPresets).toBeNull(); + expect(decision.overrideNote).toBeNull(); + }); + + it("carries forward without note when no env override is set", () => { + const decision = decidePolicyCarryForward(["npm"], {}, true); + expect(decision.newPresets).toEqual(["npm"]); + expect(decision.overrideNote).toBeNull(); + }); +}); diff --git a/src/lib/onboard/policy-carryforward.ts b/src/lib/onboard/policy-carryforward.ts new file mode 100644 index 00000000000..7b6eb558827 --- /dev/null +++ b/src/lib/onboard/policy-carryforward.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Decides whether `nemoclaw onboard --recreate-sandbox` should carry the +// previous sandbox's policy presets forward into the new session, or honour +// a `NEMOCLAW_POLICY_PRESETS` / `NEMOCLAW_POLICY_MODE` environment override. +// See #2675. +// +// "suggested"/"default"/"auto" are intentionally absent from EXPLICIT_POLICY_MODES: +// they map to the implicit carry-forward semantic, equivalent to leaving +// NEMOCLAW_POLICY_MODE unset. +export const EXPLICIT_POLICY_MODES = ["skip", "none", "no", "custom", "list"]; + +export type PolicyEnv = { + NEMOCLAW_POLICY_PRESETS?: string; + NEMOCLAW_POLICY_MODE?: string; +}; + +export function shouldCarryPreviousPolicies( + previousPolicies: string[] | null | undefined, + env: PolicyEnv, + nonInteractive: boolean, +): boolean { + if (!Array.isArray(previousPolicies) || previousPolicies.length === 0) return false; + if (!nonInteractive) return true; + if ((env.NEMOCLAW_POLICY_PRESETS ?? "").trim().length > 0) return false; + const mode = (env.NEMOCLAW_POLICY_MODE ?? "").trim().toLowerCase(); + if (EXPLICIT_POLICY_MODES.includes(mode)) return false; + return true; +} + +export type PolicyCarryForwardDecision = { + // The value to assign to session.policyPresets: `previousPolicies` when the + // recreate path carries them forward, otherwise `null` to clear the slot. + newPresets: string[] | null; + // Human-readable note explaining that an env override is replacing the + // recorded presets. Null when no note is warranted. + overrideNote: string | null; +}; + +export function decidePolicyCarryForward( + previousPolicies: string[] | null | undefined, + env: PolicyEnv, + nonInteractive: boolean, +): PolicyCarryForwardDecision { + const prev = Array.isArray(previousPolicies) ? previousPolicies : null; + if (shouldCarryPreviousPolicies(prev, env, nonInteractive)) { + return { newPresets: prev, overrideNote: null }; + } + if (!prev || prev.length === 0 || !nonInteractive) return { newPresets: null, overrideNote: null }; + const wasList = prev.join(", "); + if ((env.NEMOCLAW_POLICY_PRESETS ?? "").trim().length > 0) { + return { + newPresets: null, + overrideNote: ` [non-interactive] NEMOCLAW_POLICY_PRESETS overrides previous presets on recreate (was: ${wasList}).`, + }; + } + const mode = (env.NEMOCLAW_POLICY_MODE ?? "").trim().toLowerCase(); + if (EXPLICIT_POLICY_MODES.includes(mode)) { + return { + newPresets: null, + overrideNote: ` [non-interactive] NEMOCLAW_POLICY_MODE=${mode} overrides previous presets on recreate (was: ${wasList}).`, + }; + } + return { newPresets: null, overrideNote: null }; +}