diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index dbcd8a48ee1..0ebb0006ca4 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -68,7 +68,7 @@ The baseline policy is always applied regardless of the selected tier. | Tier | Presets included | Description | |------|------------------|-------------| -| Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. | +| Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. Restricted mode suppresses agent-required preset additions, such as OpenClaw pricing fetches; reapply them later with `policy-add` if cost recording or other agent-side features are needed. | | Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported` | Full dev tooling and web search for agents that support web search. No messaging platform access. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. | | Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index fdd175b28ea..541c30a3576 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -14,7 +14,7 @@ tiers: - name: restricted label: Restricted - description: Base sandbox only. No third-party network access beyond inference and core agent tooling. + description: Base sandbox only. No third-party network access beyond inference and core agent tooling. Restricted mode suppresses agent-required preset additions, such as OpenClaw pricing fetches; reapply them later with policy-add if cost recording or other agent-side features are needed. presets: [] - name: balanced diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ce5d85571ef..54ee91d60bb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4571,8 +4571,8 @@ async function setupPoliciesWithSelection( waitForSandboxReady, syncPresetSelection, selectPolicyTier, - setPolicyTier: (sandbox, tierName) => - registry.updateSandbox(sandbox, { policyTier: tierName }), + setPolicyTier: (s, t) => registry.updateSandbox(s, { policyTier: t }), + getRecordedPolicyTier: (s) => registry.getSandbox(s)?.policyTier ?? null, selectTierPresetsAndAccess, parsePolicyPresetEnv, env: process.env, diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index bf9bba626f9..0135c254ff0 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -265,7 +265,7 @@ network_policies: expect(prepared.cleanup?.()).toBe(true); }); - it("merges openclaw-diagnostics-otel-local at create time when OTEL is enabled", () => { + it("merges openclaw-diagnostics-otel-local at create time when OTEL is enabled and the tier is known non-restricted", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); process.env.NEMOCLAW_OPENCLAW_OTEL = "1"; process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; @@ -273,6 +273,7 @@ network_policies: delete process.env.NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE; const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], { agentName: "openclaw", + policyTier: "balanced", }); expect(prepared.appliedPresets).toEqual(["openclaw-diagnostics-otel-local"]); @@ -280,6 +281,19 @@ network_policies: expect(prepared.cleanup?.()).toBe(true); }); + it("defers openclaw-diagnostics-otel-local at create time when the tier is unknown (interactive flow)", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + process.env.NEMOCLAW_OPENCLAW_OTEL = "1"; + process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; + + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], { + agentName: "openclaw", + }); + + expect(prepared.appliedPresets).toEqual([]); + expect(prepared.policyPath).toBe(basePolicyPath); + }); + it("does not merge OpenClaw OTEL policy at create time for terminal agents", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); process.env.NEMOCLAW_OPENCLAW_OTEL = "1"; @@ -293,4 +307,32 @@ network_policies: expect(prepared.policyPath).toBe(basePolicyPath); expect(prepared.cleanup).toBeUndefined(); }); + + it("suppresses openclaw-diagnostics-otel-local at create time on the restricted tier (defence-in-depth)", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + process.env.NEMOCLAW_OPENCLAW_OTEL = "1"; + process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; + + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], { + agentName: "openclaw", + policyTier: "restricted", + }); + + expect(prepared.appliedPresets).toEqual([]); + expect(prepared.policyPath).toBe(basePolicyPath); + }); + + it("keeps openclaw-diagnostics-otel-local at create time on the balanced tier when OTEL is enabled", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + process.env.NEMOCLAW_OPENCLAW_OTEL = "1"; + process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; + + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], { + agentName: "openclaw", + policyTier: "balanced", + }); + + expect(prepared.appliedPresets).toEqual(["openclaw-diagnostics-otel-local"]); + expect(prepared.cleanup?.()).toBe(true); + }); }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 0ee5266fe19..fe66d74dd94 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -9,6 +9,7 @@ import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; import * as policies from "../policy"; import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; +import { filterSuppressedAgentRequiredPresets } from "./policy-tier-suppression"; import { cleanupTempDir, secureTempFile } from "./temp-files"; export type InitialSandboxPolicy = { @@ -203,6 +204,7 @@ export function prepareInitialSandboxCreatePolicy( dockerGpuPatch?: boolean; additionalPresets?: string[]; agentName?: string | null; + policyTier?: string | null; } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu @@ -214,13 +216,29 @@ export function prepareInitialSandboxCreatePolicy( const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : []; const buildCleanup = () => cleanupFns.length > 0 ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; - const requestedCreateTimePresets = [ - ...new Set([ - ...requiredMessagingChannelPolicyPresets(activeMessagingChannels), - ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), - ...(options.additionalPresets || []), - ]), - ]; + // Fail closed: the OpenClaw OTEL preset is added at create time only when the + // selected policy tier is known and is not Restricted. When the tier is null + // (interactive flow that selects later) the preset is deferred to the + // post-boot policy step, so a later Restricted selection cannot leave a + // transient host-local OTLP egress allowance during sandbox boot. The same + // suppression filter still runs so an explicit `policyTier: "restricted"` + // (non-interactive flow) drops openclaw-pricing from `additionalPresets`. + const tierKnown = typeof options.policyTier === "string" && options.policyTier.length > 0; + const otelCreateTimePresets = + tierKnown && options.policyTier !== "restricted" + ? requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw") + : []; + const requestedCreateTimePresets = filterSuppressedAgentRequiredPresets( + [ + ...new Set([ + ...requiredMessagingChannelPolicyPresets(activeMessagingChannels), + ...otelCreateTimePresets, + ...(options.additionalPresets || []), + ]), + ], + options.policyTier ?? null, + options.agentName ?? null, + ); const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))]; let basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8"); diff --git a/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts b/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts new file mode 100644 index 00000000000..4c39de5d266 --- /dev/null +++ b/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts @@ -0,0 +1,63 @@ +// 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 } from "../../../state/onboard-session"; +import { handlePoliciesState } from "./policies"; +import { + basePolicyHandlerOptions as baseOptions, + createPolicyHandlerDeps as createDeps, + makeMessagingPlan, +} from "./policies-test-fixtures"; + +// Handler-level fallback for the runtime check the advisor calls out: the +// narrowest live assertion (read the actual OpenShell-applied preset list +// after restricted OpenClaw onboarding and confirm `openclaw-pricing` and +// `openclaw-diagnostics-otel-local` are absent) lives in the nightly +// `network-policy-vitest` scenario — that path requires real OpenShell plus +// an `nvapi-` inference key and is intentionally not run on every PR push. +// This contract test covers the handler-side reconciliation branch — that +// restricted resume forces `setupPoliciesWithSelection` to run rather than +// taking the resume-skip branch whenever +// `policyResumeSelection.suppressedAgentRequiredPresetsLive` is true — so the +// recorded-empty + live-suppressed-preset case cannot silently leave +// third-party egress active on restricted sandboxes. +// Removal condition: when the nightly live `network-policy-vitest` scenario +// asserts the actual applied preset list on restricted OpenClaw onboarding +// (both default and `NEMOCLAW_OPENCLAW_OTEL=1` cases), this handler-level +// contract test stays as the cheap reconciliation regression and the live +// scenario takes over as the source-of-truth runtime gate. +describe("handlePoliciesState — restricted resume reconciliation", () => { + it("forces setup reconciliation on restricted resume when suppressed presets are live", async () => { + const session = createSession({ policyPresets: [] }); + const prepareResume = vi.fn((_sandboxName, _options) => ({ + policyPresets: [], + recordedPolicyPresetsNeedReconcile: false, + disabledMessagingPolicyPresetApplied: false, + suppressedAgentRequiredPresetsLive: true, + })); + const { deps, calls, setSession } = createDeps({ + preparePolicyPresetResumeSelection: prepareResume, + arePolicyPresetsApplied: vi.fn(() => true), + getActiveSandbox: vi.fn(() => ({ + messaging: { plan: makeMessagingPlan("my-assistant", []) }, + policyTier: "restricted", + })), + }); + setSession(session); + + await handlePoliciesState({ ...baseOptions(deps), resume: true }); + + expect(prepareResume).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ tierName: "restricted" }), + ); + expect(calls.skipped).not.toHaveBeenCalled(); + expect(calls.recordSkip).not.toHaveBeenCalled(); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ selectedPresets: [] }), + ); + }); +}); diff --git a/src/lib/onboard/machine/handlers/policies-test-fixtures.ts b/src/lib/onboard/machine/handlers/policies-test-fixtures.ts new file mode 100644 index 00000000000..6c8aa76f573 --- /dev/null +++ b/src/lib/onboard/machine/handlers/policies-test-fixtures.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import type { PoliciesStateOptions } from "./policies"; + +export type PolicyTestAgent = { name: string } | null; +export type PolicyTestWebSearchConfig = { fetchEnabled: true }; +type MessagingPlan = NonNullable; +type MessagingChannelId = MessagingPlan["channels"][number]["channelId"]; + +export function makeMessagingPlan( + sandboxName: string, + channels: readonly MessagingChannelId[], + disabledChannels: readonly MessagingChannelId[] = [], +): MessagingPlan { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: channels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +export function createPolicyHandlerDeps( + overrides: Partial["deps"]> = {}, +) { + let session = createSession(); + const calls = { + load: vi.fn(() => session), + activeSandbox: vi.fn(() => ({ + messaging: { plan: makeMessagingPlan("my-assistant", ["telegram"]) }, + })), + mergeChannels: vi.fn( + (selected: string[], recorded: string[], active: string[] | null | undefined) => + selected.length > 0 ? selected : (active ?? recorded), + ), + smoke: vi.fn(), + prepareResume: vi.fn( + ( + _sandboxName: string, + options: Parameters< + PoliciesStateOptions< + PolicyTestAgent, + PolicyTestWebSearchConfig + >["deps"]["preparePolicyPresetResumeSelection"] + >[1], + ) => ({ + policyPresets: (options.recordedPolicyPresets ?? []).filter( + (name) => name !== "unsupported", + ), + recordedPolicyPresetsNeedReconcile: (options.recordedPolicyPresets ?? []).includes( + "unsupported", + ), + disabledMessagingPolicyPresetApplied: false, + suppressedAgentRequiredPresetsLive: false, + }), + ), + appliedCheck: vi.fn(() => false), + skipped: vi.fn(), + recordSkip: vi.fn(async () => session), + startStep: vi.fn(async () => undefined), + setupPolicies: vi.fn(async () => ["npm"]), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + session = mutator(session) ?? session; + return session; + }), + complete: vi.fn(async () => session), + persistPolicies: vi.fn((_sandboxName: string, _appliedPolicyPresets: string[]) => undefined), + }; + return { + calls, + deps: { + loadSession: calls.load, + getActiveSandbox: calls.activeSandbox, + mergePolicyMessagingChannels: calls.mergeChannels, + verifyCompatibleEndpointSandboxSmoke: calls.smoke, + preparePolicyPresetResumeSelection: calls.prepareResume, + arePolicyPresetsApplied: calls.appliedCheck, + skippedStepMessage: calls.skipped, + recordStateSkipped: calls.recordSkip, + startRecordedStep: calls.startStep, + setupPoliciesWithSelection: calls.setupPolicies, + updateSession: calls.updateSession, + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + persistAppliedPolicyPresets: calls.persistPolicies, + ...overrides, + }, + setSession(next: Session) { + session = next; + }, + getSession: () => session, + }; +} + +export function basePolicyHandlerOptions( + deps: PoliciesStateOptions["deps"], +): PoliciesStateOptions { + return { + resume: false, + sandboxName: "my-assistant", + provider: "provider", + model: "model", + endpointUrl: "https://example.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + selectedMessagingChannels: [], + webSearchConfig: null, + webSearchSupported: true, + hermesToolGateways: [], + agent: null, + deps, + }; +} diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index d7a39077236..5a916af4f40 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -67,6 +67,7 @@ function createDeps(overrides: Partial false), @@ -234,6 +235,7 @@ describe("handlePoliciesState", () => { policyPresets: [...(options.recordedPolicyPresets ?? []), ...options.hermesToolGateways], recordedPolicyPresetsNeedReconcile: false, disabledMessagingPolicyPresetApplied: false, + suppressedAgentRequiredPresetsLive: false, })); const { deps, calls, setSession } = createDeps({ preparePolicyPresetResumeSelection: prepareResume, diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 47b771f6beb..b11203016f8 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -24,12 +24,14 @@ export interface PolicyPresetEntry { export interface ActiveSandboxPolicyState { messaging?: { plan: SandboxMessagingPlan } | null; + policyTier?: string | null; } export interface PolicyResumeSelection { policyPresets: string[]; recordedPolicyPresetsNeedReconcile: boolean; disabledMessagingPolicyPresetApplied: boolean; + suppressedAgentRequiredPresetsLive: boolean; } export interface PoliciesStateOptions { @@ -72,6 +74,7 @@ export interface PoliciesStateOptions { agent?: string | null; webSearchConfig: WebSearchConfig | null; webSearchSupported: boolean; + tierName?: string | null; }, ): PolicyResumeSelection; arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[]): boolean; @@ -166,12 +169,14 @@ export async function handlePoliciesState({ agent: normalizeAgentName((agent as { name?: string } | null)?.name), webSearchConfig, webSearchSupported, + tierName: activeSandbox?.policyTier ?? null, }); const recordedPolicyPresetsForSupport = policyResumeSelection.policyPresets; const resumePolicies = resume && !policyResumeSelection.recordedPolicyPresetsNeedReconcile && !policyResumeSelection.disabledMessagingPolicyPresetApplied && + !policyResumeSelection.suppressedAgentRequiredPresetsLive && deps.arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForSupport); let appliedPolicyPresets = recordedPolicyPresetsForSupport; diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts new file mode 100644 index 00000000000..d87fe916ae7 --- /dev/null +++ b/src/lib/onboard/policy-resume-selection.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebSearchConfig } from "../inference/web-search"; +import { + filterSetupPolicyPresetNamesForAgent, + filterSetupPolicyPresetsForAgent, +} from "./agent-policy-presets"; +import { + hasDisabledMessagingPolicyPreset, + mergeAppliedPolicyPresetsForDisabledMessagingCleanup, + pruneDisabledMessagingPolicyPresets, +} from "./messaging-policy-presets"; +import { + isStaleBuiltinBravePolicyPreset, + mergeRequiredSetupPolicyPresets, + type PreparedPolicyResumeSelection, +} from "./policy-selection"; +import { suppressedAgentRequiredPresets } from "./policy-tier-suppression"; + +type Preset = { name: string; access?: string }; + +type PoliciesApi = { + setupPolicyPresetSupported( + name: string, + options?: { webSearchSupported?: boolean | null }, + ): boolean; + listSetupPolicyPresets( + sandboxName: string, + options?: { webSearchSupported?: boolean | null }, + ): Preset[]; + listCustomPresets(sandboxName: string): Preset[]; + getAppliedPresets(sandboxName: string): string[]; + clampSetupPolicyPresetNames( + names: string[], + selectablePresets: Preset[], + options?: { webSearchSupported?: boolean | null }, + customPresetNames?: Set, + ): string[]; +}; + +export function preparePolicyPresetResumeSelection( + deps: { policies: PoliciesApi }, + sandboxName: string, + options: { + recordedPolicyPresets: string[] | null; + disabledChannels?: string[] | null; + enabledChannels?: string[] | null; + hermesToolGateways?: string[] | null; + agent?: string | null; + webSearchConfig?: WebSearchConfig | null; + webSearchSupported?: boolean | null; + env?: NodeJS.ProcessEnv; + tierName?: string | null; + }, +): PreparedPolicyResumeSelection { + const supportOptions = { webSearchSupported: options.webSearchSupported }; + const appliedPolicyPresets = deps.policies.getAppliedPresets(sandboxName); + const selectablePolicyPresets = [ + ...filterSetupPolicyPresetsForAgent( + deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), + options.agent, + ), + ...filterSetupPolicyPresetNamesForAgent(appliedPolicyPresets, options.agent).map((name) => ({ + name, + })), + ]; + const customPolicyPresetNames = new Set( + deps.policies.listCustomPresets(sandboxName).map((preset) => preset.name), + ); + const clampedRecordedPolicyPresets = deps.policies.clampSetupPolicyPresetNames( + options.recordedPolicyPresets || [], + selectablePolicyPresets, + supportOptions, + customPolicyPresetNames, + ); + const isStaleBuiltinBrave = (name: string) => + isStaleBuiltinBravePolicyPreset(name, { + webSearchConfig: options.webSearchConfig, + customPresetNames: customPolicyPresetNames, + }); + let policyPresets = pruneDisabledMessagingPolicyPresets( + clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), + options.disabledChannels, + ); + const recordedPolicyPresetsNeedReconcile = + Array.isArray(options.recordedPolicyPresets) && + policyPresets.length !== options.recordedPolicyPresets.length; + const appliedPolicyPresetsForSupport = deps.policies + .clampSetupPolicyPresetNames( + appliedPolicyPresets, + selectablePolicyPresets, + supportOptions, + customPolicyPresetNames, + ) + .filter((name) => !isStaleBuiltinBrave(name)); + const disabledMessagingPolicyPresetApplied = hasDisabledMessagingPolicyPreset( + appliedPolicyPresetsForSupport, + options.disabledChannels, + ); + policyPresets = mergeAppliedPolicyPresetsForDisabledMessagingCleanup( + policyPresets, + appliedPolicyPresetsForSupport, + options.disabledChannels, + ); + if (Array.isArray(options.recordedPolicyPresets)) { + policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { + enabledChannels: options.enabledChannels, + hermesToolGateways: options.hermesToolGateways, + agent: options.agent, + knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), + env: options.env, + tierName: options.tierName, + }); + } + const suppressedForTier = options.tierName + ? new Set(suppressedAgentRequiredPresets(options.tierName, options.agent)) + : null; + const suppressedAgentRequiredPresetsLive = + suppressedForTier !== null && + suppressedForTier.size > 0 && + appliedPolicyPresets.some((name) => suppressedForTier.has(name)); + + return { + policyPresets, + recordedPolicyPresetsNeedReconcile, + disabledMessagingPolicyPresetApplied, + suppressedAgentRequiredPresetsLive, + }; +} diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 6102f147ad3..804a4723577 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -13,20 +13,22 @@ import { mergeRequiredHermesToolGatewayPolicyPresets, } from "./hermes-managed-tools"; import { - hasDisabledMessagingPolicyPreset, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup, mergeRequiredMessagingChannelPolicyPresets, pruneDisabledMessagingPolicyPresets, requiredMessagingChannelPolicyPresets, } from "./messaging-policy-presets"; -import { - isOpenclawAgent, - mergeRequiredOpenclawOtelPolicyPresets, - requiredOpenclawOtelPolicyPresets, -} from "./openclaw-otel-policy-presets"; +import { mergeRequiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { seedInitialPolicyContext } from "./policy-context-seed"; +import { + agentRequiredPresetAdditions, + emitSuppressedAgentRequiredPresetsNote, + filterSuppressedAgentRequiredPresets, + RESTRICTED_TIER_NAME, +} from "./policy-tier-suppression"; import { withPolicyApplicationTrace } from "./tracing"; +export { suppressedAgentRequiredPresets } from "./policy-tier-suppression"; + type Preset = { name: string; access?: string }; type SupportOptions = { webSearchSupported?: boolean | null }; type PoliciesApi = { @@ -86,6 +88,7 @@ export type SetupPolicySelectionDeps = { ) => void; selectPolicyTier: () => Promise; setPolicyTier?: (sandboxName: string, tierName: string) => void; + getRecordedPolicyTier?: (sandboxName: string) => string | null | undefined; selectTierPresetsAndAccess: ( tierName: string, presets: Preset[], @@ -99,6 +102,7 @@ export type PreparedPolicyResumeSelection = { policyPresets: string[]; recordedPolicyPresetsNeedReconcile: boolean; disabledMessagingPolicyPresetApplied: boolean; + suppressedAgentRequiredPresetsLive: boolean; }; export function mergeRequiredSetupPolicyPresets( @@ -109,6 +113,7 @@ export function mergeRequiredSetupPolicyPresets( agent?: string | null; knownPresetNames?: string[] | Set | null; env?: NodeJS.ProcessEnv; + tierName?: string | null; } = {}, ): string[] { const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent(policyPresets, options.agent); @@ -128,7 +133,8 @@ export function mergeRequiredSetupPolicyPresets( env: options.env, }, ); - return filterSetupPolicyPresetNamesForAgent(mergedPresets, options.agent); + const agentScoped = filterSetupPolicyPresetNamesForAgent(mergedPresets, options.agent); + return filterSuppressedAgentRequiredPresets(agentScoped, options.tierName, options.agent); } export function isStaleBuiltinBravePolicyPreset( @@ -176,9 +182,8 @@ export function computeSetupPresetSuggestions( }; if (webSearchConfig) add("brave"); if (provider && deps.localInferenceProviders.includes(provider)) add("local-inference"); - if (isOpenclawAgent(agent)) { - add("openclaw-pricing"); - for (const preset of requiredOpenclawOtelPolicyPresets(agent, env)) add(preset); + if (tierName !== RESTRICTED_TIER_NAME) { + for (const preset of agentRequiredPresetAdditions(agent, env)) add(preset); } if (tierName === "open" && typeof agent === "string" && agent.trim().toLowerCase() === "hermes") { for (const preset of allHermesToolGatewayPolicyPresets()) add(preset); @@ -195,85 +200,7 @@ export function computeSetupPresetSuggestions( return suggestions; } -export function preparePolicyPresetResumeSelection( - deps: { policies: PoliciesApi }, - sandboxName: string, - options: { - recordedPolicyPresets: string[] | null; - disabledChannels?: string[] | null; - enabledChannels?: string[] | null; - hermesToolGateways?: string[] | null; - agent?: string | null; - webSearchConfig?: WebSearchConfig | null; - webSearchSupported?: boolean | null; - env?: NodeJS.ProcessEnv; - }, -): PreparedPolicyResumeSelection { - const supportOptions = { webSearchSupported: options.webSearchSupported }; - const appliedPolicyPresets = deps.policies.getAppliedPresets(sandboxName); - const selectablePolicyPresets = [ - ...filterSetupPolicyPresetsForAgent( - deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), - options.agent, - ), - ...filterSetupPolicyPresetNamesForAgent(appliedPolicyPresets, options.agent).map((name) => ({ - name, - })), - ]; - const customPolicyPresetNames = new Set( - deps.policies.listCustomPresets(sandboxName).map((preset) => preset.name), - ); - const clampedRecordedPolicyPresets = deps.policies.clampSetupPolicyPresetNames( - options.recordedPolicyPresets || [], - selectablePolicyPresets, - supportOptions, - customPolicyPresetNames, - ); - const isStaleBuiltinBrave = (name: string) => - isStaleBuiltinBravePolicyPreset(name, { - webSearchConfig: options.webSearchConfig, - customPresetNames: customPolicyPresetNames, - }); - let policyPresets = pruneDisabledMessagingPolicyPresets( - clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), - options.disabledChannels, - ); - const recordedPolicyPresetsNeedReconcile = - Array.isArray(options.recordedPolicyPresets) && - policyPresets.length !== options.recordedPolicyPresets.length; - const appliedPolicyPresetsForSupport = deps.policies - .clampSetupPolicyPresetNames( - appliedPolicyPresets, - selectablePolicyPresets, - supportOptions, - customPolicyPresetNames, - ) - .filter((name) => !isStaleBuiltinBrave(name)); - const disabledMessagingPolicyPresetApplied = hasDisabledMessagingPolicyPreset( - appliedPolicyPresetsForSupport, - options.disabledChannels, - ); - policyPresets = mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - policyPresets, - appliedPolicyPresetsForSupport, - options.disabledChannels, - ); - if (Array.isArray(options.recordedPolicyPresets)) { - policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { - enabledChannels: options.enabledChannels, - hermesToolGateways: options.hermesToolGateways, - agent: options.agent, - knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), - env: options.env, - }); - } - - return { - policyPresets, - recordedPolicyPresetsNeedReconcile, - disabledMessagingPolicyPresetApplied, - }; -} +export { preparePolicyPresetResumeSelection } from "./policy-resume-selection"; export async function setupPoliciesWithSelection( deps: SetupPolicySelectionDeps, @@ -352,6 +279,10 @@ async function setupPoliciesWithSelectionInner( customPresetNames, ) : null; + // Resume (selectedPresets !== null) keeps the recorded tier so stale + // suppressed presets from that tier still get filtered; fresh onboarding + // below uses the newly-selected `tierName` from `selectPolicyTier()`. + const recordedTierName = deps.getRecordedPolicyTier?.(sandboxName) ?? null; if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { @@ -360,6 +291,7 @@ async function setupPoliciesWithSelectionInner( agent, knownPresetNames: knownSelectablePresets, env: deps.env, + tierName: recordedTierName, }); chosen = pruneDisabledPresets(chosen); } @@ -390,6 +322,7 @@ async function setupPoliciesWithSelectionInner( env: deps.env, }), ); + const suppressedNames = emitSuppressedAgentRequiredPresetsNote(tierName, agent, deps.note); if (deps.isNonInteractive()) { const policyMode = (deps.env?.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); @@ -431,6 +364,7 @@ async function setupPoliciesWithSelectionInner( agent, knownPresetNames: knownPresets, env: deps.env, + tierName, }); chosen = pruneDisabledPresets(chosen); @@ -442,18 +376,22 @@ async function setupPoliciesWithSelectionInner( if (!isAuthoritative) { const chosenSet = new Set(chosen); - const preserved: string[] = []; + // `kept` is the subset of `appliedForPreservation` that actually carries + // forward — chosen-set duplicates, stale built-in brave, and + // tier-suppressed agent-required presets (e.g. restricted's + // openclaw-pricing / openclaw-diagnostics-otel-local) are intentionally + // excluded so suppression survives the preservation pass. + const kept: string[] = []; for (const name of appliedForPreservation) { if (chosenSet.has(name)) continue; if (isStaleBuiltinBrave(name)) continue; + if (suppressedNames.has(name)) continue; chosen.push(name); chosenSet.add(name); - preserved.push(name); + kept.push(name); } - if (preserved.length > 0) { - deps.note( - ` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`, - ); + if (kept.length > 0) { + deps.note(` [non-interactive] Preserving previously-applied presets: ${kept.join(", ")}`); } } @@ -486,6 +424,7 @@ async function setupPoliciesWithSelectionInner( agent, knownPresetNames: knownNames, env: deps.env, + tierName, }, ), ); diff --git a/src/lib/onboard/policy-tier-suppression.ts b/src/lib/onboard/policy-tier-suppression.ts new file mode 100644 index 00000000000..08d01f4be8f --- /dev/null +++ b/src/lib/onboard/policy-tier-suppression.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + isOpenclawAgent, + OPENCLAW_OTEL_LOCAL_POLICY_PRESET, + requiredOpenclawOtelPolicyPresets, +} from "./openclaw-otel-policy-presets"; + +export const RESTRICTED_TIER_NAME = "restricted"; + +export function agentRequiredPresetAdditions( + agent: string | null | undefined, + env: NodeJS.ProcessEnv, +): string[] { + if (!isOpenclawAgent(agent)) return []; + return ["openclaw-pricing", ...requiredOpenclawOtelPolicyPresets(agent, env)]; +} + +function restrictedIncompatibleAgentRequiredPresets(agent: string | null | undefined): string[] { + if (!isOpenclawAgent(agent)) return []; + return ["openclaw-pricing", OPENCLAW_OTEL_LOCAL_POLICY_PRESET]; +} + +/** + * Invalid state: OpenClaw onboarding adds `openclaw-pricing` (and, when + * `NEMOCLAW_OPENCLAW_OTEL=1` with a local endpoint, `openclaw-diagnostics-otel-local`) + * to every sandbox as agent-required presets, but the Restricted tier + * description promises "no third-party network access beyond inference and core + * agent tooling". The pricing fetch reaches LiteLLM/OpenRouter and the OTEL + * preset opens host-local OTLP egress, so on Restricted both additions + * contradict the tier description and the linked issue's zero-applied-preset + * acceptance. The OTEL preset is restricted-incompatible whenever it is live, + * not only when the current process has `NEMOCLAW_OPENCLAW_OTEL` set — a + * restricted re-onboard with OTEL disabled must still classify a previously + * applied `openclaw-diagnostics-otel-local` as suppressed so the + * preservation / resume paths remove it instead of leaving stale host-local + * OTLP egress on a restricted sandbox. + * + * Source boundary: the agent-required additions list is hardcoded in this + * module (and `openclaw-otel-policy-presets.ts`) rather than declared in + * `nemoclaw-blueprint/policies/tiers.yaml`. Tier YAML can express a tier's + * default presets but cannot express "this preset is conditionally added by + * the active agent, except when the tier explicitly suppresses it" — so the + * suppression must live alongside the addition. The suggestion / addition + * gate stays env-conditioned via `agentRequiredPresetAdditions()`; the + * suppression gate is env-independent via + * `restrictedIncompatibleAgentRequiredPresets()` so live cleanup catches + * presets applied by a prior process with a different env. + * + * Source-fix constraint: tier YAML has no schema for agent-conditional or + * tier-conditional preset gating, and `requiredOpenclawOtelPolicyPresets()` + * itself takes `agent` and `env` (OTEL endpoint locality) inputs that the YAML + * cannot evaluate at parse time. + * + * Regression test: `test/policy-tiers-onboard.test.ts` exercises + * `setupPoliciesWithSelection` end-to-end for restricted + OpenClaw across + * fresh-onboard, preservation, resume, and OTEL-enabled / OTEL-disabled paths, + * including stale-applied OTEL-local cleanup with the current env disabled; + * `test/onboard-policy-suggestions.test.ts` covers + * `suppressedAgentRequiredPresets` (env-independent) and + * `computeSetupPresetSuggestions` (env-gated) directly. + * + * Removal condition: when the agent-required addition list moves into per-agent + * declarative metadata (per-preset application-source records in the registry, + * or per-agent YAML under `nemoclaw-blueprint/policies/`) so the tier filter + * can be applied at the metadata layer, this module — together with the + * `tierName` plumbing through `mergeRequiredSetupPolicyPresets()` — can be + * removed in one pass. + * + * Operator escape hatch (defense-in-depth note): suppression is a security + * boundary, not a default — an operator who explicitly needs `openclaw-pricing` + * or `openclaw-diagnostics-otel-local` on a restricted sandbox can re-apply + * either preset on demand via `nemoclaw policy-add `, + * which the onboard notice emitted by `setupPoliciesWithSelectionInner` also + * surfaces inline. The env-independent suppression list specifically catches + * stale presets applied by a prior process with `NEMOCLAW_OPENCLAW_OTEL=1` so + * the next restricted reconciliation removes them even when the current + * process has OTEL disabled. + */ +export function suppressedAgentRequiredPresets( + tierName: string, + agent: string | null | undefined, +): string[] { + if (tierName !== RESTRICTED_TIER_NAME) return []; + return restrictedIncompatibleAgentRequiredPresets(agent); +} + +export function filterSuppressedAgentRequiredPresets( + presetNames: string[], + tierName: string | null | undefined, + agent: string | null | undefined, +): string[] { + if (!tierName) return presetNames; + const suppressed = new Set(suppressedAgentRequiredPresets(tierName, agent)); + if (suppressed.size === 0) return presetNames; + return presetNames.filter((name) => !suppressed.has(name)); +} + +export function emitSuppressedAgentRequiredPresetsNote( + tierName: string, + agent: string | null | undefined, + note: (message: string) => void, +): Set { + const suppressed = suppressedAgentRequiredPresets(tierName, agent); + if (suppressed.length > 0) { + note( + ` Restricted tier suppresses agent-required preset(s): ${suppressed.join(", ")}. Apply later with 'nemoclaw policy-add ' if needed.`, + ); + } + return new Set(suppressed); +} diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index 6684c1942c0..989e03a8719 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -101,6 +101,7 @@ describe("prepareSandboxCreatePlan", () => { dockerGpuPatch: false, additionalPresets: ["github"], agentName: "langchain-deepagents-code", + policyTier: null, }, ); expect(result.createArgs).toEqual([ diff --git a/src/lib/onboard/sandbox-create-plan.ts b/src/lib/onboard/sandbox-create-plan.ts index c0b2207cc12..43ca7a17cec 100644 --- a/src/lib/onboard/sandbox-create-plan.ts +++ b/src/lib/onboard/sandbox-create-plan.ts @@ -10,6 +10,29 @@ import type { MessagingChannel } from "./messaging-state"; import { resolveQrSelectedChannels } from "./messaging-state"; import { buildSandboxGpuCreateArgs, type SandboxGpuCreateConfig } from "./sandbox-gpu-create"; +// Known canonical policy tier names. Kept inline so the create-time path +// validates the env value without pulling `../policy/tiers` (which transitively +// requires `runner.ts` and breaks vitest source resolution for this module's +// tests). The list mirrors `nemoclaw-blueprint/policies/tiers.yaml`; adding a +// tier there requires updating this set so an explicit tier env value reaches +// the create-time policy decision. +const KNOWN_POLICY_TIER_NAMES = new Set(["restricted", "balanced", "open"]); + +function readPolicyTierEnv(): string | null { + // Only trust the env value in non-interactive mode. Interactive flows let the + // operator override the tier via the selector after sandbox creation; if the + // env said balanced but the operator picks restricted, an interactive trust + // of the env would have already let create-time OTEL through. Fail closed: + // interactive mode returns null so the OTEL preset is deferred to the + // post-boot policy step. + const isNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + if (!isNonInteractive) return null; + const raw = process.env.NEMOCLAW_POLICY_TIER; + if (typeof raw !== "string") return null; + const trimmed = raw.trim().toLowerCase(); + return KNOWN_POLICY_TIER_NAMES.has(trimmed) ? trimmed : null; +} + type MessagingTokenDef = { name?: string; envKey: string; @@ -49,6 +72,7 @@ export type PrepareSandboxCreatePlanInput = { getMessagingChannelForEnvKey(envKey: string): string | null; getHermesToolGatewayProviderName(sandboxName: string): string; agentName?: string | null; + policyTier?: string | null; deps?: SandboxCreatePlanDeps; }; @@ -205,6 +229,7 @@ export function prepareSandboxCreatePlan({ getMessagingChannelForEnvKey, getHermesToolGatewayProviderName, agentName, + policyTier = readPolicyTierEnv(), deps = {}, }: PrepareSandboxCreatePlanInput): SandboxCreatePlan { const enabledMessagingTokenDefs = filterMessagingTokenDefsByEnabledChannel( @@ -234,6 +259,7 @@ export function prepareSandboxCreatePlan({ dockerGpuPatch: useDockerGpuPatch, additionalPresets: hermesToolGateways, agentName, + policyTier, }); const createArgs = [ "--from", diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index 930665dc2b1..1828b6132af 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -27,6 +27,10 @@ import { requirePolicyPresetNumber, } from "./network-policy-interactive.ts"; import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts"; +import { + ensureDockerAvailable, + runRestrictedOnboardWithRetry, +} from "./restricted-onboard-helpers.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); @@ -38,6 +42,7 @@ const PERMISSIVE_POLICY = path.join( "openclaw-sandbox-permissive.yaml", ); const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? `e2e-net-policy-${process.pid}`; +const SUPPRESSION_SANDBOX_NAME = `${SANDBOX_NAME}-suppression`; const RUN_NETWORK_POLICY_TEST = shouldRunLiveE2E() ? test : test.skip; const TEST_TIMEOUT_MS = 65 * 60_000; @@ -510,6 +515,35 @@ RUN_NETWORK_POLICY_TEST( } expect(onboard?.exitCode, onboard ? text(onboard) : "onboard did not run").toBe(0); + // Invalid state: prior bugs left `openclaw-pricing` (and, under + // `NEMOCLAW_OPENCLAW_OTEL=1` with a local endpoint, + // `openclaw-diagnostics-otel-local`) live on restricted OpenClaw sandboxes + // even though the restricted tier promises zero third-party network access. + // Source boundary: live OpenShell `policy-list` after a successful + // restricted onboard and before any operator mutation (`policy-add brew`). + // This scenario enables `NEMOCLAW_WEB_SEARCH_ENABLED=1` so the later brave + // probe has a preset to allow, so the assertion below only proves the two + // OpenClaw-agent suppressed presets are absent. The authoritative + // source-of-truth for the linked issue's literal "zero applied presets" + // clause is the dedicated `restricted-openclaw-policy-suppression` + // scenario below — it onboards a default restricted sandbox (no + // web-search, no OpenClaw OTEL) and asserts the `policy-list` output has + // no `●`-bulleted entries; that scenario must remain the gate even if + // this scenario's assertion is ever weakened. + const policyListAfterOnboard = await runNemoclaw(host, [SANDBOX_NAME, "policy-list"], { + artifactName: "tc-net-01-policy-list-after-onboard", + timeoutMs: SANDBOX_EXEC_TIMEOUT_MS, + }); + expect(policyListAfterOnboard.exitCode, text(policyListAfterOnboard)).toBe(0); + expect( + policyListAfterOnboard.stdout, + `restricted onboard must not leave openclaw-pricing applied: ${text(policyListAfterOnboard)}`, + ).not.toMatch(/^[\s]*●[\s]+openclaw-pricing\b/m); + expect( + policyListAfterOnboard.stdout, + `restricted onboard must not leave openclaw-diagnostics-otel-local applied: ${text(policyListAfterOnboard)}`, + ).not.toMatch(/^[\s]*●[\s]+openclaw-diagnostics-otel-local\b/m); + const denyDefault = await fetchStatus( sandbox, "https://example.com/", @@ -871,3 +905,126 @@ nemoclaw-start node /tmp/nemoclaw-web-fetch-e2e.mjs 'http://host.openshell.inter }); }, ); + +// Invalid state: a default restricted OpenClaw onboard (no web-search, no +// OpenClaw OTEL) used to leave `openclaw-pricing` applied, contradicting the +// linked issue's "zero presets" acceptance clause. Source boundary: live +// OpenShell `policy-list` after onboard and before any operator mutation. +// Source-fix constraint: unit/handler tests stub policy APIs and the +// brave-enabled `network-policy` scenario above probes the suppressed +// preset names only, so neither proves the post-onboard applied set is +// literally empty. Regression test: this scenario onboards a default +// restricted OpenClaw sandbox and asserts `policy-list` shows no `●` +// bullets. Removal condition: when the agent-required addition list moves +// into per-agent declarative metadata so tier filtering happens at the +// metadata layer (see `src/lib/onboard/policy-tier-suppression.ts`). +// +// Acceptance note (`NEMOCLAW_OPENCLAW_OTEL=1`): the OTEL-enabled live +// variant is deferred to a follow-up nightly extension to keep this +// scenario's wall-clock to a single onboard. The OTEL suppression contract +// is covered by `test/policy-tiers-onboard.test.ts` and +// `test/policy-tiers-onboard-restricted-stale-otel.test.ts` against the +// real CLI through a stubbed policy API, and by the brave-enabled scenario +// above which proves `openclaw-diagnostics-otel-local` is absent through the +// live OpenShell `policy-list`. A regression in `requiredOpenclawOtelPolicyPresets()` +// or the merge boundary would surface in both layers. +// +// Acceptance note (`policy-add` escape hatch): the documented escape hatch — +// `nemoclaw policy-add ` to re-apply a suppressed preset on +// a restricted sandbox — does not change behavior in this PR. `policy-add` +// invokes `policies.applyPreset` directly and is independent of the onboarding +// suggestion / preservation / resume paths the suppression module touches, so +// existing CLI coverage for `policy-add` continues to gate it. A dedicated +// live re-add scenario was considered but deferred to keep this scenario's +// wall-clock to a single onboard; if the escape hatch ever stops working on +// restricted, a regression would surface in the CLI `policy-add` tests rather +// than here. +RUN_NETWORK_POLICY_TEST( + "network-policy: default restricted OpenClaw onboard leaves policy-list with zero active presets", + { timeout: TEST_TIMEOUT_MS }, + async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { + await artifacts.writeJson("scenario.json", { + id: "restricted-openclaw-policy-suppression", + runner: "vitest", + boundary: "live-sandbox-network-policy", + contracts: ["restricted tier applies zero presets"], + }); + + expect( + fs.existsSync(CLI_DIST_ENTRYPOINT), + "run `npm run build:cli` before live repo CLI scenarios", + ).toBe(true); + + await ensureDockerAvailable({ + host, + artifactName: "prereq-docker-info-restricted-zero-presets", + skip, + scenarioLabel: "restricted-zero-presets", + }); + + const openshellVersion = await host.command("openshell", ["--version"], { + artifactName: "prereq-openshell-version-restricted-zero-presets", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }); + expect(openshellVersion.exitCode, text(openshellVersion)).toBe(0); + + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + expect(apiKey.startsWith("nvapi-"), "NVIDIA_INFERENCE_API_KEY must start with nvapi-").toBe( + true, + ); + + cleanup.add(`destroy restricted-zero-presets sandbox ${SUPPRESSION_SANDBOX_NAME}`, async () => { + await runNemoclaw(host, [SUPPRESSION_SANDBOX_NAME, "destroy", "--yes"], { + artifactName: "cleanup-nemoclaw-destroy-restricted-zero-presets", + env: baseEnv(), + timeoutMs: 120_000, + }); + await sandbox.openshell(["sandbox", "delete", SUPPRESSION_SANDBOX_NAME], { + artifactName: "cleanup-openshell-delete-restricted-zero-presets", + env: baseEnv(), + timeoutMs: 60_000, + }); + }); + + await runNemoclaw(host, [SUPPRESSION_SANDBOX_NAME, "destroy", "--yes"], { + artifactName: "pre-cleanup-nemoclaw-destroy-restricted-zero-presets", + env: baseEnv(), + timeoutMs: 120_000, + }); + + const onboard = await runRestrictedOnboardWithRetry({ + host, + artifacts, + skip, + sandboxName: SUPPRESSION_SANDBOX_NAME, + apiKey, + scenarioLabel: "restricted-zero-presets", + scenarioSlug: "restricted-zero-presets", + preCleanupArtifactPrefix: "pre-cleanup-nemoclaw-destroy-restricted-zero-presets", + onboardArtifactPrefix: "onboard-restricted-zero-presets", + onboardTimeoutMs: ONBOARD_TIMEOUT_MS, + preCleanupTimeoutMs: 120_000, + runNemoclaw, + baseEnv, + }); + expect(onboard.exitCode, text(onboard)).toBe(0); + + const policyListAfterOnboard = await runNemoclaw( + host, + [SUPPRESSION_SANDBOX_NAME, "policy-list"], + { + artifactName: "restricted-zero-presets-policy-list-after-onboard", + timeoutMs: SANDBOX_EXEC_TIMEOUT_MS, + }, + ); + expect(policyListAfterOnboard.exitCode, text(policyListAfterOnboard)).toBe(0); + const activeBullets = (policyListAfterOnboard.stdout.match(/^[\s]*●[\s]+(\S+)/gm) ?? []).map( + (line) => line.replace(/^[\s]*●[\s]+/, "").trim(), + ); + expect( + activeBullets, + `restricted tier must apply zero presets; got ${JSON.stringify(activeBullets)} from:\n${text(policyListAfterOnboard)}`, + ).toEqual([]); + }, +); diff --git a/test/e2e/live/restricted-onboard-helpers.ts b/test/e2e/live/restricted-onboard-helpers.ts new file mode 100644 index 00000000000..f8a042bf3de --- /dev/null +++ b/test/e2e/live/restricted-onboard-helpers.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ArtifactSink } from "../fixtures/artifacts.ts"; +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts"; + +export type SkipFn = (reason: string) => never; + +export function liveOnboardAttempts(): number { + return process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" ? 3 : 1; +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function ensureDockerAvailable(opts: { + host: HostCliClient; + artifactName: string; + skip: SkipFn; + scenarioLabel: string; +}): Promise { + const docker = await opts.host.command("docker", ["info"], { + artifactName: opts.artifactName, + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }); + if (docker.exitCode === 0) return; + const text = [docker.stdout, docker.stderr].filter(Boolean).join("\n"); + if (process.env.GITHUB_ACTIONS === "true") { + throw new Error(`Docker is required for ${opts.scenarioLabel} live E2E: ${text}`); + } + opts.skip(`Docker is required for ${opts.scenarioLabel} live E2E`); +} + +export type RestrictedOnboardOptions = { + host: HostCliClient; + artifacts: ArtifactSink; + skip: SkipFn; + sandboxName: string; + apiKey: string; + scenarioLabel: string; + scenarioSlug: string; + preCleanupArtifactPrefix: string; + onboardArtifactPrefix: string; + extraOnboardEnv?: Record; + onboardTimeoutMs: number; + preCleanupTimeoutMs: number; + runNemoclaw: ( + host: HostCliClient, + args: string[], + options: { + artifactName: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + redactionValues?: string[]; + }, + ) => Promise; + baseEnv: (extra?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; +}; + +async function transientSkipArtifact(opts: { + artifacts: ArtifactSink; + attempts: number; +}): Promise { + await opts.artifacts.writeJson("transient-provider-validation.skip.json", { + reason: "transient NVIDIA Endpoints validation failure after retries", + attempts: opts.attempts, + sourceBoundary: "external NVIDIA Endpoints provider availability", + removalCondition: + "remove once CI endpoint validation is stable for a release cycle or covered by a hermetic provider-validation fixture", + }); +} + +async function attemptRestrictedOnboardOnce( + options: RestrictedOnboardOptions, + attempt: number, +): Promise { + if (attempt > 1) { + await options.runNemoclaw(options.host, [options.sandboxName, "destroy", "--yes"], { + artifactName: `${options.preCleanupArtifactPrefix}-attempt-${attempt}`, + env: options.baseEnv(), + timeoutMs: options.preCleanupTimeoutMs, + }); + } + const onboardEnv = options.baseEnv({ + NVIDIA_INFERENCE_API_KEY: options.apiKey, + NEMOCLAW_SANDBOX_NAME: options.sandboxName, + NEMOCLAW_RECREATE_SANDBOX: "1", + NEMOCLAW_POLICY_TIER: "restricted", + ...(options.extraOnboardEnv ?? {}), + }); + const artifactName = + attempt === 1 + ? options.onboardArtifactPrefix + : `${options.onboardArtifactPrefix}-attempt-${attempt}`; + return options.runNemoclaw( + options.host, + ["onboard", "--non-interactive", "--yes-i-accept-third-party-software"], + { + artifactName, + env: onboardEnv, + redactionValues: [options.apiKey], + timeoutMs: options.onboardTimeoutMs, + }, + ); +} + +export async function runRestrictedOnboardWithRetry( + options: RestrictedOnboardOptions, +): Promise { + const attempts = liveOnboardAttempts(); + let lastResult: ShellProbeResult | null = null; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const result = await attemptRestrictedOnboardOnce(options, attempt); + lastResult = result; + if (result.exitCode === 0) return result; + const transient = isTransientProviderValidationFailure(result); + if (transient && attempt < attempts) { + await sleep(10_000 * attempt); + continue; + } + if (transient && process.env.GITHUB_ACTIONS === "true") { + await transientSkipArtifact({ artifacts: options.artifacts, attempts }); + options.skip( + `NVIDIA Endpoints validation hit a transient upstream/rate-limit failure after ${attempts} attempts`, + ); + } + return result; + } + return lastResult as ShellProbeResult; +} diff --git a/test/helpers/onboard-final-flow-phases.ts b/test/helpers/onboard-final-flow-phases.ts index 9a3d8e69c79..442ee19c88e 100644 --- a/test/helpers/onboard-final-flow-phases.ts +++ b/test/helpers/onboard-final-flow-phases.ts @@ -209,6 +209,7 @@ export function createPhases( policyPresets: ["balanced"], recordedPolicyPresetsNeedReconcile: false, disabledMessagingPolicyPresetApplied: false, + suppressedAgentRequiredPresetsLive: false, }), arePolicyPresetsApplied: () => false, skippedStepMessage: vi.fn(), diff --git a/test/helpers/policy-tier-onboard-script.ts b/test/helpers/policy-tier-onboard-script.ts new file mode 100644 index 00000000000..38bb6d1453e --- /dev/null +++ b/test/helpers/policy-tier-onboard-script.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const policyTierOnboardScriptRepoRoot = path.join(import.meta.dirname, "..", ".."); + +export function runPolicyTierOnboardScript( + 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: policyTierOnboardScriptRepoRoot, + encoding: "utf-8", + env, + timeout: 15000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return result; +} + +export function buildPolicyTierOnboardPreamble({ + tierEnv = "balanced", + policyMode = "skip", + policyPresets = "", + stubOpenshellBin = false, + runCaptureReturn = "", +}: { + tierEnv?: string; + policyMode?: string; + policyPresets?: string; + stubOpenshellBin?: boolean; + runCaptureReturn?: string; +} = {}): string { + const repoRoot = policyTierOnboardScriptRepoRoot; + const credPath = JSON.stringify(path.join(repoRoot, "src", "lib", "credentials", "store.ts")); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const resolveOpenshellPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "adapters", "openshell", "resolve.ts"), + ); + + const openshellStub = stubOpenshellBin + ? `require(${resolveOpenshellPath}).resolveOpenshell = () => "/usr/bin/true";` + : ""; + + return String.raw` +const credentials = require(${credPath}); +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +Object.defineProperty(process, "platform", { value: "darwin" }); + +credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + msg); }; +credentials.ensureApiKey = async () => {}; +credentials.getCredential = () => null; +runner.run = () => {}; +runner.runCapture = (command) => { + const text = Array.isArray(command) ? command.join(" ") : String(command); + if (text.includes("sandbox list")) return "test-sb Ready"; + return ${JSON.stringify(runCaptureReturn)}; +}; +${openshellStub} + +const updates = []; +registry.registerSandbox = () => true; +registry.updateSandbox = (_name, fields) => { updates.push(fields); return true; }; +registry.getSandbox = () => ({ name: "test-sb", model: null, provider: null }); + +process.env.NEMOCLAW_POLICY_TIER = ${JSON.stringify(tierEnv)}; +process.env.NEMOCLAW_POLICY_MODE = ${JSON.stringify(policyMode)}; +process.env.NEMOCLAW_POLICY_PRESETS = ${JSON.stringify(policyPresets)}; + +const { selectPolicyTier, setupPoliciesWithSelection } = require(${onboardPath}); +`; +} diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index c448eaedc01..07e8b8cef40 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -29,6 +29,55 @@ const { computeSetupPresetSuggestions, filterSetupPolicyPresets, getSuggestedPol env?: NodeJS.ProcessEnv; }) => string[]; }; +const { mergeRequiredSetupPolicyPresets, suppressedAgentRequiredPresets } = + require("../src/lib/onboard/policy-selection") as { + mergeRequiredSetupPolicyPresets: ( + policyPresets: string[], + options?: { + enabledChannels?: string[] | null; + hermesToolGateways?: string[] | null; + agent?: string | null; + knownPresetNames?: string[] | Set | null; + env?: NodeJS.ProcessEnv; + tierName?: string | null; + }, + ) => string[]; + suppressedAgentRequiredPresets: ( + tierName: string, + agent: string | null | undefined, + ) => string[]; + }; +const { agentRequiredPresetAdditions, filterSuppressedAgentRequiredPresets } = + require("../src/lib/onboard/policy-tier-suppression") as { + agentRequiredPresetAdditions: ( + agent: string | null | undefined, + env: NodeJS.ProcessEnv, + ) => string[]; + filterSuppressedAgentRequiredPresets: ( + presetNames: string[], + tierName: string | null | undefined, + agent: string | null | undefined, + ) => string[]; + }; + +function setOrUnset(key: string, value: string | undefined): void { + value === undefined ? delete process.env[key] : (process.env[key] = value); +} + +function withOpenclawOtelEnv(value: string | undefined, body: () => T): T { + const otelKey = "NEMOCLAW_OPENCLAW_OTEL"; + const endpointKey = "NEMOCLAW_OPENCLAW_OTEL_ENDPOINT"; + const originalOtel = process.env[otelKey]; + const originalEndpoint = process.env[endpointKey]; + setOrUnset(otelKey, value); + delete process.env[endpointKey]; + try { + return body(); + } finally { + setOrUnset(otelKey, originalOtel); + setOrUnset(endpointKey, originalEndpoint); + } +} const { filterSetupPolicyPresetsForAgent } = require("../src/lib/onboard/agent-policy-presets") as { filterSetupPolicyPresetsForAgent: ( presets: T[], @@ -441,4 +490,230 @@ describe("onboard policy preset suggestions", () => { expect(suggestions).not.toContain("slack"); expect(suggestions).not.toContain("discord"); }); + + describe("restricted tier suppresses agent-required preset additions", () => { + const knownWithPricing = [...known, "openclaw-pricing", "openclaw-diagnostics-otel-local"]; + + it("does not auto-add openclaw-pricing for an OpenClaw sandbox on the restricted tier", () => { + const suggestions = computeSetupPresetSuggestions("restricted", { + agent: "openclaw", + knownPresetNames: knownWithPricing, + }); + expect(suggestions).toEqual([]); + }); + + it("still auto-adds openclaw-pricing for an OpenClaw sandbox on the balanced tier", () => { + const suggestions = computeSetupPresetSuggestions("balanced", { + agent: "openclaw", + knownPresetNames: knownWithPricing, + }); + expect(suggestions).toContain("openclaw-pricing"); + }); + + it("does not auto-add the local OTEL preset on the restricted tier even when OTEL is enabled", () => { + withOpenclawOtelEnv("1", () => { + const suggestions = computeSetupPresetSuggestions("restricted", { + agent: "openclaw", + knownPresetNames: knownWithPricing, + env: process.env, + }); + expect(suggestions).not.toContain("openclaw-diagnostics-otel-local"); + }); + }); + + it("treats a null agent as OpenClaw and still suppresses openclaw-pricing on restricted", () => { + const suggestions = computeSetupPresetSuggestions("restricted", { + agent: null, + knownPresetNames: knownWithPricing, + }); + expect(suggestions).not.toContain("openclaw-pricing"); + }); + + it("leaves Hermes sandboxes on restricted unchanged (no OpenClaw-only suppression needed)", () => { + const suggestions = computeSetupPresetSuggestions("restricted", { + agent: "hermes", + knownPresetNames: knownWithPricing, + }); + expect(suggestions).toEqual([]); + }); + }); + + describe("suppressedAgentRequiredPresets", () => { + it("reports openclaw-pricing and openclaw-diagnostics-otel-local as suppressed on restricted + openclaw", () => { + expect(suppressedAgentRequiredPresets("restricted", "openclaw")).toEqual([ + "openclaw-pricing", + "openclaw-diagnostics-otel-local", + ]); + }); + + it("reports the same suppression list when OTEL is currently enabled", () => { + withOpenclawOtelEnv("1", () => { + expect(suppressedAgentRequiredPresets("restricted", "openclaw")).toEqual([ + "openclaw-pricing", + "openclaw-diagnostics-otel-local", + ]); + }); + }); + + it("still reports openclaw-diagnostics-otel-local when OTEL is currently disabled", () => { + withOpenclawOtelEnv(undefined, () => { + expect(suppressedAgentRequiredPresets("restricted", "openclaw")).toContain( + "openclaw-diagnostics-otel-local", + ); + }); + withOpenclawOtelEnv("0", () => { + expect(suppressedAgentRequiredPresets("restricted", "openclaw")).toContain( + "openclaw-diagnostics-otel-local", + ); + }); + }); + + it("returns no suppressed presets for balanced or open tiers", () => { + expect(suppressedAgentRequiredPresets("balanced", "openclaw")).toEqual([]); + expect(suppressedAgentRequiredPresets("open", "openclaw")).toEqual([]); + }); + + it("returns no suppressed presets for non-OpenClaw agents on restricted", () => { + expect(suppressedAgentRequiredPresets("restricted", "hermes")).toEqual([]); + }); + + it("treats a null agent on restricted as OpenClaw and reports the full suppression list", () => { + expect(suppressedAgentRequiredPresets("restricted", null)).toEqual([ + "openclaw-pricing", + "openclaw-diagnostics-otel-local", + ]); + }); + }); + + describe("filterSuppressedAgentRequiredPresets (interactive preservation safeguard)", () => { + it("removes openclaw-pricing and openclaw-diagnostics-otel-local from a restricted preset list", () => { + expect( + filterSuppressedAgentRequiredPresets( + ["npm", "openclaw-pricing", "openclaw-diagnostics-otel-local", "pypi"], + "restricted", + "openclaw", + ), + ).toEqual(["npm", "pypi"]); + }); + + it("returns the input unchanged for balanced and open tiers", () => { + expect( + filterSuppressedAgentRequiredPresets(["openclaw-pricing", "npm"], "balanced", "openclaw"), + ).toEqual(["openclaw-pricing", "npm"]); + expect( + filterSuppressedAgentRequiredPresets(["openclaw-pricing", "npm"], "open", "openclaw"), + ).toEqual(["openclaw-pricing", "npm"]); + }); + + it("returns the input unchanged when tierName is null or undefined", () => { + expect( + filterSuppressedAgentRequiredPresets(["openclaw-pricing", "npm"], null, "openclaw"), + ).toEqual(["openclaw-pricing", "npm"]); + expect( + filterSuppressedAgentRequiredPresets(["openclaw-pricing", "npm"], undefined, "openclaw"), + ).toEqual(["openclaw-pricing", "npm"]); + }); + + it("does not suppress for non-OpenClaw agents on restricted", () => { + expect( + filterSuppressedAgentRequiredPresets( + ["openclaw-pricing", "hermes-tool"], + "restricted", + "hermes", + ), + ).toEqual(["openclaw-pricing", "hermes-tool"]); + }); + }); + + describe("mergeRequiredSetupPolicyPresets tier plumbing", () => { + it("suppresses openclaw-pricing only when tierName is restricted", () => { + expect( + mergeRequiredSetupPolicyPresets(["npm", "openclaw-pricing"], { + agent: "openclaw", + tierName: "restricted", + }), + ).toEqual(["npm"]); + expect( + mergeRequiredSetupPolicyPresets(["npm", "openclaw-pricing"], { + agent: "openclaw", + tierName: "balanced", + }), + ).toEqual(["npm", "openclaw-pricing"]); + expect( + mergeRequiredSetupPolicyPresets(["npm", "openclaw-pricing"], { + agent: "openclaw", + tierName: "open", + }), + ).toEqual(["npm", "openclaw-pricing"]); + }); + + it("returns the input unchanged when tierName is omitted (covers the fresh-onboard call site that passes a freshly-selected tierName and the resume call site that passes the recorded tierName — passing null means no tier filter applies)", () => { + expect( + mergeRequiredSetupPolicyPresets(["npm", "openclaw-pricing"], { agent: "openclaw" }), + ).toEqual(["npm", "openclaw-pricing"]); + expect( + mergeRequiredSetupPolicyPresets(["npm", "openclaw-pricing"], { + agent: "openclaw", + tierName: null, + }), + ).toEqual(["npm", "openclaw-pricing"]); + }); + + it("preserves balanced presets when the originally recorded tier was restricted (tier-switch upgrade path)", () => { + // Caller would pass `tierName: ` for fresh onboarding + // and `tierName: ` on resume. Either way, the function + // applies suppression based on the tier *passed in* — so a tier upgrade + // from restricted → balanced never re-suppresses the balanced presets. + const balancedPresets = ["npm", "pypi", "huggingface"]; + expect( + mergeRequiredSetupPolicyPresets(balancedPresets, { + agent: "openclaw", + tierName: "balanced", + }), + ).toEqual(balancedPresets); + }); + }); + + describe("restricted suppression list ⊇ env-gated additions (drift invariant)", () => { + // The restricted suppression list is hardcoded so live cleanup catches + // presets applied by a prior process with a different env. The env-gated + // addition list (`agentRequiredPresetAdditions`) is what actually gets + // added during fresh onboarding. The two can drift if a new OpenClaw + // agent-required preset is added to the addition path without also being + // added to the suppression set — leaving stale presets uncleaned on + // restricted re-onboarding. Lock the invariant: every preset the env-gated + // additions can produce for OpenClaw must also appear in the restricted + // suppression list. + function unionAdditionsAcrossOtelStates(): Set { + const otelKey = "NEMOCLAW_OPENCLAW_OTEL"; + const endpointKey = "NEMOCLAW_OPENCLAW_OTEL_ENDPOINT"; + const original = { otel: process.env[otelKey], endpoint: process.env[endpointKey] }; + const union = new Set(); + try { + delete process.env[otelKey]; + delete process.env[endpointKey]; + for (const name of agentRequiredPresetAdditions("openclaw", process.env)) union.add(name); + process.env[otelKey] = "1"; + delete process.env[endpointKey]; + for (const name of agentRequiredPresetAdditions("openclaw", process.env)) union.add(name); + process.env[endpointKey] = "https://otel.example.com:4318"; + for (const name of agentRequiredPresetAdditions("openclaw", process.env)) union.add(name); + } finally { + setOrUnset(otelKey, original.otel); + setOrUnset(endpointKey, original.endpoint); + } + return union; + } + + it("includes every env-gated OpenClaw agent-required preset in the restricted suppression list", () => { + const additionsUnion = unionAdditionsAcrossOtelStates(); + const restrictedSet = new Set(suppressedAgentRequiredPresets("restricted", "openclaw")); + for (const preset of additionsUnion) { + expect( + restrictedSet.has(preset), + `addition '${preset}' missing from restricted suppression list`, + ).toBe(true); + } + }); + }); }); diff --git a/test/policy-add-remove-session-sync.test.ts b/test/policy-add-remove-session-sync.test.ts index 2a0b3b101da..5ee0ce987e2 100644 --- a/test/policy-add-remove-session-sync.test.ts +++ b/test/policy-add-remove-session-sync.test.ts @@ -343,4 +343,43 @@ const ctx = module.exports; assert.deepEqual(payload.calls.apply, [{ sandboxName: "test-sb", presetName: "github" }]); assert.deepEqual(payload.sessionUpdates, []); }); + + // Restricted-tier suppression (see src/lib/onboard/policy-tier-suppression.ts) + // only filters agent-required presets at the onboarding boundary + // (suggestions / preservation / resume). The documented operator escape + // hatch — `nemoclaw policy-add ` — bypasses the + // suppression module and goes directly through `policies.applyPreset`. + // This regression pins down that contract: the restricted-incompatible + // presets can still be applied on demand. + it("policy-add openclaw-pricing succeeds independently of restricted-tier suppression (escape hatch contract)", () => { + const script = `${buildPreamble({ + presetNamesAvailable: ["npm", "openclaw-pricing"], + sessionSandboxName: "test-sb", + sessionPolicyPresets: [], + })} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.addSandboxPolicy("test-sb", { preset: "openclaw-pricing", yes: true }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + calls: ctx.calls, + sessionUpdates: ctx.sessionUpdates, + finalSession: ctx.getSessionState(), + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.calls.apply, [ + { sandboxName: "test-sb", presetName: "openclaw-pricing" }, + ]); + assert.deepEqual(payload.sessionUpdates[0].policyPresets, ["openclaw-pricing"]); + }); }); diff --git a/test/policy-tiers-onboard-restricted-stale-otel.test.ts b/test/policy-tiers-onboard-restricted-stale-otel.test.ts new file mode 100644 index 00000000000..385ef82eb89 --- /dev/null +++ b/test/policy-tiers-onboard-restricted-stale-otel.test.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "vitest"; + +import { + buildPolicyTierOnboardPreamble as buildPreamble, + policyTierOnboardScriptRepoRoot as repoRoot, + runPolicyTierOnboardScript as runScript, +} from "./helpers/policy-tier-onboard-script"; + +const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + +function buildRestrictedOpenclawScript({ + applied, + selectedPresets, + resumeTier, +}: { + applied: string[]; + selectedPresets?: string[]; + resumeTier?: boolean; +}): string { + const resumePreamble = resumeTier + ? `\nregistry.getSandbox = () => ({ name: "test-sb", policyTier: "restricted" });\n` + : ""; + const callOpts = + selectedPresets !== undefined ? `, selectedPresets: ${JSON.stringify(selectedPresets)}` : ""; + return ( + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw`${resumePreamble} +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ${JSON.stringify(applied)}; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw"${callOpts} }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +` + ); +} + +const otelDisabledEnv = { + NEMOCLAW_OPENCLAW_OTEL: undefined, + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, +}; + +describe("restricted tier reconciles stale openclaw-diagnostics-otel-local with OTEL disabled", () => { + it("non-interactive path removes a previously-applied openclaw-diagnostics-otel-local", () => { + const script = buildRestrictedOpenclawScript({ + applied: ["openclaw-diagnostics-otel-local"], + }); + const result = runScript(script, otelDisabledEnv); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-diagnostics-otel-local"), + `restricted reconciliation must exclude stale openclaw-diagnostics-otel-local when OTEL is disabled; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + payload.removedCalls.includes("openclaw-diagnostics-otel-local"), + `restricted reconciliation must call removePreset for stale openclaw-diagnostics-otel-local when OTEL is disabled; got: ${JSON.stringify(payload.removedCalls)}`, + ); + }); + + it("resume path excludes a previously-applied openclaw-diagnostics-otel-local", () => { + const script = buildRestrictedOpenclawScript({ + applied: ["openclaw-diagnostics-otel-local"], + selectedPresets: [], + resumeTier: true, + }); + const result = runScript(script, otelDisabledEnv); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-diagnostics-otel-local"), + `resume target must exclude stale openclaw-diagnostics-otel-local on restricted when OTEL is disabled; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + payload.removedCalls.includes("openclaw-diagnostics-otel-local"), + `restricted resume must call removePreset for stale openclaw-diagnostics-otel-local when OTEL is disabled; got: ${JSON.stringify(payload.removedCalls)}`, + ); + }); +}); + +describe("restricted recordedTierName plumbing through resume", () => { + it("resume against an originally-restricted sandbox filters openclaw-pricing from the operator's selected presets", () => { + const script = buildRestrictedOpenclawScript({ + applied: [], + selectedPresets: ["openclaw-pricing", "npm"], + resumeTier: true, + }); + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-pricing"), + `resume target must filter openclaw-pricing when recordedTierName='restricted'; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + !payload.appliedCalls.includes("openclaw-pricing"), + `resume must not call applyPreset/applyPresets for openclaw-pricing on restricted recordedTierName; got: ${JSON.stringify(payload.appliedCalls)}`, + ); + }); + + it("resume against an originally-balanced sandbox preserves operator-selected openclaw-pricing", () => { + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +registry.getSandbox = () => ({ name: "test-sb", policyTier: "balanced" }); + +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + agent: "openclaw", + selectedPresets: ["openclaw-pricing", "npm"], + }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + payload.applied.includes("openclaw-pricing"), + `resume target must keep openclaw-pricing when recordedTierName='balanced'; got: ${JSON.stringify(payload.applied)}`, + ); + }); +}); diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index c60be156855..c8ddd4e8e4e 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -964,6 +964,401 @@ console.log = () => {}; `tier-name hint should not appear for non-tier values, stderr: ${result.stderr}`, ); }); + + it("setupPoliciesWithSelection restricted tier applies zero presets for OpenClaw in non-interactive suggested mode", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw" }); + process.stdout.write(JSON.stringify({ applied, appliedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.deepEqual(payload.applied, [], `applied set must be empty for restricted OpenClaw`); + assert.deepEqual( + payload.appliedCalls, + [], + `no policy preset should be applied on restricted OpenClaw`, + ); + }); + + it("setupPoliciesWithSelection restricted tier does not re-add openclaw-diagnostics-otel-local when NEMOCLAW_OPENCLAW_OTEL=1", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw" }); + process.stdout.write(JSON.stringify({ applied, appliedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script, { + NEMOCLAW_OPENCLAW_OTEL: "1", + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-diagnostics-otel-local"), + `applied set must not contain openclaw-diagnostics-otel-local; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + !payload.applied.includes("openclaw-pricing"), + `applied set must not contain openclaw-pricing; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + !payload.appliedCalls.includes("openclaw-diagnostics-otel-local"), + `policies.applyPreset/applyPresets must not be called for openclaw-diagnostics-otel-local; got: ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.ok( + !payload.appliedCalls.includes("openclaw-pricing"), + `policies.applyPreset/applyPresets must not be called for openclaw-pricing; got: ${JSON.stringify(payload.appliedCalls)}`, + ); + }); + + it("setupPoliciesWithSelection restricted tier note matches the final applied presets when agent-required presets are suppressed", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +const lines = []; +const origLog = console.log; +console.log = (...args) => lines.push(args.join(" ")); + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw" }); + console.log = origLog; + origLog(JSON.stringify({ applied, appliedCalls, lines })); + } catch (err) { + console.log = origLog; + origLog(JSON.stringify({ error: err.message, lines })); + } +})(); +`; + const result = runScript(script, { + NEMOCLAW_OPENCLAW_OTEL: "1", + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + const noteLine: string | undefined = payload.lines.find((l: string) => + l.includes("Restricted tier suppresses agent-required preset"), + ); + assert.ok( + noteLine, + `suppression note must be printed, lines: ${JSON.stringify(payload.lines)}`, + ); + const noteMentions = (name: string) => noteLine!.includes(name); + assert.ok( + noteMentions("openclaw-pricing"), + `note must mention openclaw-pricing, got: ${noteLine}`, + ); + assert.ok( + noteMentions("openclaw-diagnostics-otel-local"), + `note must mention openclaw-diagnostics-otel-local when OTEL is enabled, got: ${noteLine}`, + ); + for (const name of ["openclaw-pricing", "openclaw-diagnostics-otel-local"]) { + assert.ok( + !payload.applied.includes(name), + `note says ${name} is suppressed but final applied still contains it: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + !payload.appliedCalls.includes(name), + `note says ${name} is suppressed but applyPreset/applyPresets was still called: ${JSON.stringify(payload.appliedCalls)}`, + ); + } + }); + + it("setupPoliciesWithSelection restricted tier removes previously-applied openclaw-pricing instead of preserving it", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["openclaw-pricing"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw" }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-pricing"), + `applied target must exclude previously-applied openclaw-pricing on restricted; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + payload.removedCalls.includes("openclaw-pricing"), + `restricted reconciliation must call removePreset for openclaw-pricing; got: ${JSON.stringify(payload.removedCalls)}`, + ); + }); + + it("setupPoliciesWithSelection restricted tier removes previously-applied openclaw-diagnostics-otel-local when NEMOCLAW_OPENCLAW_OTEL=1", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["openclaw-diagnostics-otel-local", "openclaw-pricing"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw" }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script, { + NEMOCLAW_OPENCLAW_OTEL: "1", + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + for (const name of ["openclaw-pricing", "openclaw-diagnostics-otel-local"]) { + assert.ok( + !payload.applied.includes(name), + `restricted reconciliation must exclude ${name}; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + payload.removedCalls.includes(name), + `restricted reconciliation must call removePreset for ${name}; got: ${JSON.stringify(payload.removedCalls)}`, + ); + } + }); + + it("setupPoliciesWithSelection restricted resume with empty recorded presets keeps target empty", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +registry.getSandbox = () => ({ name: "test-sb", policyTier: "restricted" }); + +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + agent: "openclaw", + selectedPresets: [], + }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-pricing"), + `resume target must not be expanded back to openclaw-pricing on restricted; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + !payload.appliedCalls.includes("openclaw-pricing"), + `resume must not call applyPreset/applyPresets for openclaw-pricing on restricted; got: ${JSON.stringify(payload.appliedCalls)}`, + ); + }); + + it("setupPoliciesWithSelection restricted resume removes previously-applied openclaw-pricing", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +registry.getSandbox = () => ({ name: "test-sb", policyTier: "restricted" }); + +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["openclaw-pricing"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + agent: "openclaw", + selectedPresets: [], + }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-pricing"), + `resume target must exclude previously-applied openclaw-pricing on restricted; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + payload.removedCalls.includes("openclaw-pricing"), + `restricted resume must call removePreset for openclaw-pricing; got: ${JSON.stringify(payload.removedCalls)}`, + ); + }); + + it("setupPoliciesWithSelection restricted resume with NEMOCLAW_OPENCLAW_OTEL=1 excludes openclaw-diagnostics-otel-local", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "restricted", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +registry.getSandbox = () => ({ name: "test-sb", policyTier: "restricted" }); + +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["openclaw-diagnostics-otel-local"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + agent: "openclaw", + selectedPresets: [], + }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script, { + NEMOCLAW_OPENCLAW_OTEL: "1", + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("openclaw-diagnostics-otel-local"), + `resume target must exclude openclaw-diagnostics-otel-local on restricted; got: ${JSON.stringify(payload.applied)}`, + ); + assert.ok( + payload.removedCalls.includes("openclaw-diagnostics-otel-local"), + `restricted resume must call removePreset for openclaw-diagnostics-otel-local; got: ${JSON.stringify(payload.removedCalls)}`, + ); + }); }); describe("selectTierPresetsAndAccess", () => { @@ -993,18 +1388,8 @@ ${body} `; } - function run(body: string): SpawnSyncReturns { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-presets-")); - const scriptPath = path.join(tmpDir, "script.js"); - fs.writeFileSync(scriptPath, buildPresetsScript(body)); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { ...process.env, HOME: tmpDir, NEMOCLAW_NON_INTERACTIVE: "1" }, - timeout: 10000, - }); - fs.rmSync(tmpDir, { recursive: true, force: true }); - return result; + function run(body: string) { + return runScript(buildPresetsScript(body)); } it("returns tier presets with their default access levels", () => {