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
16 changes: 8 additions & 8 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}'...`);

Expand Down
72 changes: 72 additions & 0 deletions src/lib/onboard/policy-carryforward.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
66 changes: 66 additions & 0 deletions src/lib/onboard/policy-carryforward.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading