diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index e9c5c710a47..e4808a7f9b8 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -2205,6 +2205,7 @@ jobs: # OpenClaw balanced/open and Hermes open common-egress agent-turn # coverage against real sandbox policy and live hosted inference. env: + BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} run: | set -euo pipefail diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 07251e261c9..45806a92615 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -80,7 +80,7 @@ On DGX Spark, DGX Station, and Windows WSL, an interactive installer offers expr Express install switches onboarding to non-interactive mode, allows `sudo` password prompts for required host changes, and selects the managed local inference path for that platform. On DGX Spark and DGX Station, express install selects managed vLLM; DGX Spark uses `qwen3.6-35b-a3b-nvfp4` by default, and DGX Station uses `deepseek-v4-flash` by default. Unless `NEMOCLAW_POLICY_TIER` is set, it applies sandbox policy in `suggested` mode with the `balanced` tier by default. -That tier uses the base sandbox policy plus supported package, model, web-search, local-inference, and read-only weather presets. +That tier uses the base sandbox policy plus supported package, model, web-search, and local-inference presets. On DGX Spark, express install uses `my-spark-assistant` as the sandbox name unless `NEMOCLAW_SANDBOX_NAME` is already set. On WSL, express install selects the Windows-host Ollama setup path. Set `NEMOCLAW_NO_EXPRESS=1` to skip the express prompt, or set `NEMOCLAW_PROVIDER` before launching the installer when you want to choose a provider yourself. @@ -195,7 +195,8 @@ Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabl After the sandbox image builds and OpenClaw starts inside the sandbox, NemoClaw asks which network policy tier to apply. Web search and messaging selections happen before this point so the sandbox image and the policy suggestions stay aligned. -The default **Balanced** tier includes common development presets such as npm, PyPI, Hugging Face, Homebrew, read-only weather lookups, and Brave Search when the selected agent supports web search. +The default **Balanced** tier includes common development presets such as npm, PyPI, Hugging Face, Homebrew, and Brave Search when the selected agent supports web search. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. +OpenClaw sandboxes also receive the `openclaw-pricing` preset automatically so session-cost records can populate without manual configuration. Use the arrow keys or `j` and `k` to move, Space to select, and Enter to confirm. The preset selector lets you include more destinations, such as GitHub, Jira, Slack, Telegram, or local inference. diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index edda943ecf2..d26125bc355 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -250,7 +250,8 @@ The Brave Search API key is still configured separately during onboarding or thr ## Weather and Public Reference Lookups Use the `weather` preset when the agent needs read-only weather or geocoding lookups. -The Balanced and Open tiers include it by default. +The Open tier includes it by default. +Balanced and Restricted sandboxes require the explicit `policy-add weather` step below. The preset covers read-only public weather and geocoding lookups, including current conditions, forecasts, geocoding, and weather alerts, without enabling messaging or productivity APIs. ```bash diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index a88fc147eeb..29bb76e2602 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -787,6 +787,15 @@ This catches desync caused by external edits to the gateway policy or stale regi Preset summaries come only from the YAML `preset.description` field. NemoClaw does not render network-policy rule bodies as prose in `policy-list` output. +Each active preset is annotated with its provenance so you can tell why it is applied: + +- `[from tier]` — the preset name matches an entry in the sandbox's current tier definition (see [Policy Tiers](../reference/network-policies#policy-tiers)). +- `[from openclaw agent]` or `[from hermes agent]` — the preset name matches a NemoClaw-managed agent preset and the active agent matches that label (for example `openclaw-pricing` on an OpenClaw sandbox, or Nous-hosted gateway presets on a Hermes sandbox). +- `[user-added]` — anything else: presets applied later through `policy-add`, presets that match no tier or agent default, or presets that match the opposite agent's reserved names on a sandbox running the other agent. +- `[source unverified]` — the row is active but the local registry and live gateway state disagree. When the gateway cannot be queried, this renders as `[source unverified (gateway unreachable)]`. The provenance check is suppressed in these trust-degraded states because the source cannot be confirmed against both halves of the sandbox policy view. + +Provenance tags are inferred from the sandbox's current tier and agent metadata at display time and are not persisted per preset. A preset whose name appears in the sandbox's current tier YAML is labelled `[from tier]` even when an operator added it manually with `policy-add` after onboarding, and OpenClaw-only or Hermes-only preset names are only labelled `[from agent]` when the active agent matches that label. + ```bash nemohermes my-assistant policy-list ``` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d1ee4695f80..4fcb9bfc301 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1062,6 +1062,15 @@ This catches desync caused by external edits to the gateway policy or stale regi Preset summaries come only from the YAML `preset.description` field. NemoClaw does not render network-policy rule bodies as prose in `policy-list` output. +Each active preset is annotated with its provenance so you can tell why it is applied: + +- `[from tier]` — the preset name matches an entry in the sandbox's current tier definition (see [Policy Tiers](../reference/network-policies#policy-tiers)). +- `[from openclaw agent]` or `[from hermes agent]` — the preset name matches a NemoClaw-managed agent preset and the active agent matches that label (for example `openclaw-pricing` on an OpenClaw sandbox, or Nous-hosted gateway presets on a Hermes sandbox). +- `[user-added]` — anything else: presets applied later through `policy-add`, presets that match no tier or agent default, or presets that match the opposite agent's reserved names on a sandbox running the other agent. +- `[source unverified]` — the row is active but the local registry and live gateway state disagree. When the gateway cannot be queried, this renders as `[source unverified (gateway unreachable)]`. The provenance check is suppressed in these trust-degraded states because the source cannot be confirmed against both halves of the sandbox policy view. + +Provenance tags are inferred from the sandbox's current tier and agent metadata at display time and are not persisted per preset. A preset whose name appears in the sandbox's current tier YAML is labelled `[from tier]` even when an operator added it manually with `policy-add` after onboarding, and OpenClaw-only or Hermes-only preset names are only labelled `[from agent]` when the active agent matches that label. + ```bash $$nemoclaw my-assistant policy-list ``` diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 3caee82c096..dbcd8a48ee1 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -69,7 +69,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. | -| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather` | Full dev tooling, read-only weather lookups, and web search for agents that support web search. No messaging platform access. | +| 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. | After selecting a tier, a combined preset and access-mode screen lets you include or exclude individual presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. @@ -77,6 +77,11 @@ Tier-default presets are pre-selected; additional presets can be added from the NemoClaw filters tier defaults by the active agent's supported integrations. For example, Hermes onboarding omits the Brave Search preset because Hermes does not use NemoClaw's OpenClaw web-search configuration. Hermes managed-tool gateway selections can add Hermes-specific presets, such as Nous-hosted web, image, audio, browser, or code tools, without applying unsupported OpenClaw-only presets. +OpenClaw onboarding also adds the `openclaw-pricing` preset on top of tier defaults so session-cost records can populate from LiteLLM and OpenRouter without manual configuration. +When the OpenClaw OTEL diagnostics feature is enabled with a local endpoint, NemoClaw adds the `openclaw-diagnostics-otel-local` preset on the same basis. +The applied set therefore reflects the chosen tier *plus* any agent-required presets, so `policy-list` may show one or more presets that do not appear in the tier table above. +The `policy-list` provenance tags are inferred from the current tier YAML and the active agent at display time and are not persisted per preset. +A preset whose name matches an entry in the sandbox's current tier definition is labelled `[from tier]` even when an operator added it manually with `policy-add` after onboarding; OpenClaw-only and Hermes-only preset names are only labelled `[from agent]` when the active agent matches. Claude Code direct egress is not included in any policy tier. If you install and run the Claude Code CLI inside the sandbox with its own credentials, apply the `claude-code` preset explicitly. Normal NemoClaw Anthropic inference still routes through the OpenShell gateway. diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 85b98e334c0..fdd175b28ea 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -19,18 +19,17 @@ tiers: - name: balanced label: Balanced - description: Full dev tooling, web search, and core weather lookups. Package installs, model downloads, and inference. No messaging platform access. + description: Full dev tooling and web search. Package installs, model downloads, and inference. No messaging platform access. presets: - { name: npm, access: read-write } - { name: pypi, access: read-write } - { name: huggingface, access: read-write } - { name: brew, access: read-write } - { name: brave, access: read-write } - - { name: weather, access: read } - name: open label: Open - description: Broad access across third-party services including messaging and productivity. User accepts responsibility for the expanded scope. + description: Broad access across third-party services including messaging, productivity, and core weather lookups. User accepts responsibility for the expanded scope. presets: - { name: npm, access: read-write } - { name: pypi, access: read-write } diff --git a/src/lib/actions/sandbox/policy-channel-list.test.ts b/src/lib/actions/sandbox/policy-channel-list.test.ts new file mode 100644 index 00000000000..19fd9b43fde --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-list.test.ts @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +type PresetInfo = { + name: string; + description?: string; +}; + +const moduleMocks = vi.hoisted(() => ({ + getSandbox: vi.fn<(sandboxName: string) => Record | null>(), + getCustomPolicies: vi.fn<(sandboxName: string) => PresetInfo[]>(), + listPresets: vi.fn<() => PresetInfo[]>(), + listCustomPresets: vi.fn<(sandboxName: string) => PresetInfo[]>(), + getAppliedPresets: vi.fn<(sandboxName: string) => string[]>(), + getGatewayPresets: vi.fn<(sandboxName: string) => string[] | null>(), +})); + +vi.mock("../../state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: moduleMocks.getSandbox, + getCustomPolicies: moduleMocks.getCustomPolicies, +})); + +vi.mock("../../policy", async (importOriginal) => ({ + ...(await importOriginal()), + listPresets: moduleMocks.listPresets, + listCustomPresets: moduleMocks.listCustomPresets, + getAppliedPresets: moduleMocks.getAppliedPresets, + getGatewayPresets: moduleMocks.getGatewayPresets, +})); + +import { listSandboxPolicies } from "./policy-channel"; + +const POLICY_PRESETS: PresetInfo[] = [ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + { name: "discord", description: "Discord API access" }, + { name: "openclaw-pricing", description: "OpenClaw pricing lookup" }, + { name: "nous-web", description: "Nous Portal managed web search gateway" }, +]; + +let logSpy: MockInstance; +let errSpy: MockInstance; + +function printedText(): string { + return [...logSpy.mock.calls, ...errSpy.mock.calls] + .map((call) => call.map(String).join(" ")) + .join("\n"); +} + +function arrangeListing({ + appliedNames, + gatewayNames, + tier, + agent, +}: { + appliedNames: string[]; + gatewayNames: string[] | null; + tier: string | null; + agent: string | null; +}): void { + moduleMocks.getSandbox.mockReturnValue({ + name: "test-sandbox", + agent, + policyTier: tier, + policies: appliedNames, + }); + moduleMocks.getAppliedPresets.mockReturnValue(appliedNames); + moduleMocks.getGatewayPresets.mockReturnValue(gatewayNames); +} + +beforeEach(() => { + vi.clearAllMocks(); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + moduleMocks.getCustomPolicies.mockReturnValue([]); + moduleMocks.listPresets.mockReturnValue(POLICY_PRESETS); + moduleMocks.listCustomPresets.mockReturnValue([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("listSandboxPolicies provenance", () => { + it("tags active tier-default presets with their tier provenance (#5774)", () => { + arrangeListing({ + appliedNames: ["npm", "pypi"], + gatewayNames: ["npm", "pypi"], + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).toContain("● npm [from balanced tier]"); + expect(output).toContain("● pypi [from balanced tier]"); + }); + + it("keeps tier attribution when a custom registry entry shadows a tier preset (#5774)", () => { + moduleMocks.getCustomPolicies.mockReturnValue([ + { name: "npm", description: "sandbox-scoped custom npm policy" }, + ]); + arrangeListing({ + appliedNames: ["npm"], + gatewayNames: ["npm"], + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).toContain("● npm [from balanced tier]"); + expect(output).not.toContain("● npm [user-added]"); + }); + + it("tags openclaw-pricing as an OpenClaw agent preset (#5774)", () => { + arrangeListing({ + appliedNames: ["openclaw-pricing"], + gatewayNames: ["openclaw-pricing"], + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + expect(printedText()).toContain("● openclaw-pricing [from openclaw agent]"); + }); + + it("tags nous-* presets as Hermes agent presets on Hermes (#5774)", () => { + arrangeListing({ + appliedNames: ["nous-web"], + gatewayNames: ["nous-web"], + tier: "open", + agent: "hermes", + }); + + listSandboxPolicies("test-sandbox"); + + expect(printedText()).toContain("● nous-web [from hermes agent]"); + }); + + it("tags presets outside the tier and agent defaults as user-added (#5774)", () => { + arrangeListing({ + appliedNames: ["discord"], + gatewayNames: ["discord"], + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + expect(printedText()).toContain("● discord [user-added]"); + }); + + it("omits the provenance tag for inactive presets (#5774)", () => { + arrangeListing({ + appliedNames: ["npm"], + gatewayNames: ["npm"], + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).toMatch(/○ pypi —/); + expect(output).not.toMatch(/○ pypi \[/); + }); + + it.each([ + { + agent: "hermes", + preset: "openclaw-pricing", + forbidden: "[from openclaw agent]", + }, + { agent: "openclaw", preset: "nous-web", forbidden: "[from hermes agent]" }, + ])("does not use another agent's provenance for $preset (#5774)", ({ + agent, + preset, + forbidden, + }) => { + arrangeListing({ + appliedNames: [preset], + gatewayNames: [preset], + tier: "balanced", + agent, + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).toContain(`● ${preset} [user-added]`); + expect(output).not.toContain(forbidden); + }); + + it("falls back to user-added when policyTier is missing (#5774)", () => { + arrangeListing({ + appliedNames: ["npm"], + gatewayNames: ["npm"], + tier: null, + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).toContain("● npm [user-added]"); + expect(output).not.toContain("[from balanced tier]"); + }); + + it("does not trust tier provenance for gateway-only desync (#5774)", () => { + arrangeListing({ + appliedNames: [], + gatewayNames: ["npm"], + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).not.toContain("● npm [from balanced tier]"); + expect(output).toContain( + "● npm [source unverified] — npm and Yarn registry access (active on gateway, missing from local state)", + ); + }); + + it("marks registry-only provenance as gateway-unreachable (#5774)", () => { + arrangeListing({ + appliedNames: ["npm"], + gatewayNames: null, + tier: "balanced", + agent: "openclaw", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).not.toContain("● npm [from balanced tier]"); + expect(output).toContain("● npm [source unverified (gateway unreachable)]"); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 8b5574eed18..5e471864f75 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -37,6 +37,7 @@ const onboardProviders = require("../../onboard/providers"); import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets"; import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; import * as policies from "../../policy"; +import { formatPolicyListPresetRow } from "../../policy/policy-list-display"; const onboardSession = require("../../state/onboard-session") as typeof import("../../state/onboard-session"); @@ -269,30 +270,25 @@ export function listSandboxPolicies(sandboxName: string) { // array of matched preset names when reachable (possibly empty). const gatewayPresets = policies.getGatewayPresets(sandboxName); + const sandboxEntry = registry.getSandbox(sandboxName); + const provenanceContext = { + tierName: sandboxEntry?.policyTier ?? null, + agentName: sandboxEntry?.agent ?? null, + }; + console.log(""); console.log(` Policy presets for sandbox '${sandboxName}':`); allPresets.forEach((p: { name: string; description: string }) => { const inRegistry = registryPresets.includes(p.name); const inGateway = gatewayPresets ? gatewayPresets.includes(p.name) : null; - - let marker; - let suffix = ""; - if (inGateway === null) { - // Gateway unreachable — fall back to registry-only display - marker = inRegistry ? "●" : "○"; - } else if (inRegistry && inGateway) { - marker = "●"; - } else if (!inRegistry && !inGateway) { - marker = "○"; - } else if (inGateway && !inRegistry) { - marker = "●"; - suffix = " (active on gateway, missing from local state)"; - } else { - // inRegistry && !inGateway - marker = "○"; - suffix = " (recorded locally, not active on gateway)"; - } - console.log(` ${marker} ${p.name} — ${p.description}${suffix}`); + console.log( + formatPolicyListPresetRow({ + preset: p, + provenanceContext, + inRegistry, + inGateway, + }), + ); }); if (gatewayPresets === null) { diff --git a/src/lib/onboard/agent-policy-presets.ts b/src/lib/onboard/agent-policy-presets.ts index 98c7abb7b25..4e579a6f1d0 100644 --- a/src/lib/onboard/agent-policy-presets.ts +++ b/src/lib/onboard/agent-policy-presets.ts @@ -2,12 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { HERMES_TOOL_GATEWAY_PRESET_NAMES } from "./hermes-managed-tools"; -import { isOpenclawAgent, OPENCLAW_OTEL_LOCAL_POLICY_PRESET } from "./openclaw-otel-policy-presets"; +import { isOpenclawAgent, OPENCLAW_ONLY_POLICY_PRESETS } from "./openclaw-otel-policy-presets"; -const OPENCLAW_ONLY_POLICY_PRESETS = new Set([ - "openclaw-pricing", - OPENCLAW_OTEL_LOCAL_POLICY_PRESET, -]); +export { OPENCLAW_ONLY_POLICY_PRESETS }; function isHermesAgent(agent: string | null | undefined): boolean { return typeof agent === "string" && agent.trim().toLowerCase() === "hermes"; diff --git a/src/lib/onboard/hermes-managed-tools.test.ts b/src/lib/onboard/hermes-managed-tools.test.ts new file mode 100644 index 00000000000..dc83393960e --- /dev/null +++ b/src/lib/onboard/hermes-managed-tools.test.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { stringSetsEqual } from "./hermes-managed-tools"; + +describe("stringSetsEqual", () => { + it("accepts equal selections regardless of order or duplicates", () => { + expect(stringSetsEqual(["nous-web", "nous-code"], ["nous-code", "nous-web"])).toBe(true); + expect(stringSetsEqual(["nous-web", "nous-web"], ["nous-web"])).toBe(true); + }); + + it("rejects selections with a missing or additional gateway", () => { + expect(stringSetsEqual(["nous-web"], ["nous-web", "nous-code"])).toBe(false); + expect(stringSetsEqual(["nous-web", "nous-code"], ["nous-web"])).toBe(false); + }); +}); diff --git a/src/lib/onboard/openclaw-otel-policy-presets.ts b/src/lib/onboard/openclaw-otel-policy-presets.ts index 3714cf19928..4f2d4800e67 100644 --- a/src/lib/onboard/openclaw-otel-policy-presets.ts +++ b/src/lib/onboard/openclaw-otel-policy-presets.ts @@ -8,6 +8,11 @@ const LOCAL_OPENCLAW_OTEL_PORT = "4318"; export const OPENCLAW_OTEL_LOCAL_POLICY_PRESET = "openclaw-diagnostics-otel-local"; +export const OPENCLAW_ONLY_POLICY_PRESETS = new Set([ + "openclaw-pricing", + OPENCLAW_OTEL_LOCAL_POLICY_PRESET, +]); + export function isOpenclawOtelEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const raw = env.NEMOCLAW_OPENCLAW_OTEL; return ( diff --git a/src/lib/policy/policy-list-display.ts b/src/lib/policy/policy-list-display.ts new file mode 100644 index 00000000000..733dcfda5dc --- /dev/null +++ b/src/lib/policy/policy-list-display.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { formatPresetProvenanceSuffix, type PresetProvenanceContext } from "./preset-provenance"; + +interface PolicyListPresetRowOptions { + preset: { name: string; description: string }; + inRegistry: boolean; + inGateway: boolean | null; + provenanceContext: PresetProvenanceContext; +} + +/** Render one policy-list row from reconciled registry and gateway state. */ +export function formatPolicyListPresetRow(options: PolicyListPresetRowOptions): string { + const { preset, inRegistry, inGateway, provenanceContext } = options; + let marker: "●" | "○"; + let stateSuffix = ""; + if (inGateway === null) { + marker = inRegistry ? "●" : "○"; + } else if (inRegistry && inGateway) { + marker = "●"; + } else if (!inRegistry && !inGateway) { + marker = "○"; + } else if (inGateway) { + marker = "●"; + stateSuffix = " (active on gateway, missing from local state)"; + } else { + marker = "○"; + stateSuffix = " (recorded locally, not active on gateway)"; + } + + const provenanceSuffix = formatPresetProvenanceSuffix(preset.name, provenanceContext, { + active: marker === "●", + inRegistry, + inGateway, + }); + return ` ${marker} ${preset.name}${provenanceSuffix} — ${preset.description}${stateSuffix}`; +} diff --git a/src/lib/policy/preset-provenance.test.ts b/src/lib/policy/preset-provenance.test.ts new file mode 100644 index 00000000000..05d4a1a4c98 --- /dev/null +++ b/src/lib/policy/preset-provenance.test.ts @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +interface FakeTierPreset { + name: string; + access: string; +} +interface FakeTier { + name: string; + label: string; + description: string; + presets: FakeTierPreset[]; +} + +const { TIER_FIXTURES } = vi.hoisted(() => { + const fixtures: Record = { + balanced: { + name: "balanced", + label: "Balanced", + description: "balanced fixture", + presets: [ + { name: "npm", access: "read-write" }, + { name: "pypi", access: "read-write" }, + { name: "huggingface", access: "read-write" }, + { name: "brew", access: "read-write" }, + { name: "brave", access: "read-write" }, + ], + }, + open: { + name: "open", + label: "Open", + description: "open fixture", + presets: [ + { name: "npm", access: "read-write" }, + { name: "slack", access: "read-write" }, + { name: "weather", access: "read" }, + ], + }, + }; + return { TIER_FIXTURES: fixtures }; +}); + +vi.mock("./tiers", () => ({ + getTier: (name: string): FakeTier | undefined => TIER_FIXTURES[name], +})); + +import { + classifyPresetProvenance, + formatPresetProvenanceSuffix, + formatPresetProvenanceTag, +} from "./preset-provenance"; + +describe("classifyPresetProvenance", () => { + it("gives current tier-name matches precedence over fallback sources", () => { + expect(classifyPresetProvenance("npm", { tierName: "balanced" })).toEqual({ + source: "tier", + tier: "balanced", + }); + expect(classifyPresetProvenance("brave", { tierName: "balanced" })).toEqual({ + source: "tier", + tier: "balanced", + }); + }); + + it("documents current-tier attribution when a user-added preset shadows a tier name", () => { + const customPresetRegistry = { + getCustomPolicies: vi.fn(() => [ + { name: "npm", description: "sandbox-scoped custom npm policy" }, + ]), + }; + const [shadowingCustomPreset] = customPresetRegistry.getCustomPolicies(); + + // Application history is not persisted, so the display can only infer + // provenance from the current tier. Keep that limitation explicit until + // the policy registry stores per-preset source history. + expect(classifyPresetProvenance(shadowingCustomPreset.name, { tierName: "balanced" })).toEqual({ + source: "tier", + tier: "balanced", + }); + expect(customPresetRegistry.getCustomPolicies).toHaveBeenCalledOnce(); + }); + + it("classifies tier-default presets under the Open tier too", () => { + expect(classifyPresetProvenance("slack", { tierName: "open" })).toEqual({ + source: "tier", + tier: "open", + }); + }); + + it("classifies openclaw-pricing as agent-sourced for openclaw sandboxes", () => { + expect( + classifyPresetProvenance("openclaw-pricing", { + tierName: "balanced", + agentName: "openclaw", + }), + ).toEqual({ source: "agent", agent: "openclaw" }); + }); + + it("classifies openclaw-diagnostics-otel-local as openclaw-agent-sourced on openclaw sandboxes", () => { + expect( + classifyPresetProvenance("openclaw-diagnostics-otel-local", { + tierName: "balanced", + agentName: "openclaw", + }), + ).toEqual({ source: "agent", agent: "openclaw" }); + }); + + it("classifies nous-* gateway presets as hermes-agent-sourced on hermes sandboxes", () => { + expect(classifyPresetProvenance("nous-web", { tierName: "open", agentName: "hermes" })).toEqual( + { + source: "agent", + agent: "hermes", + }, + ); + expect(classifyPresetProvenance("nous-code", { agentName: "hermes" })).toEqual({ + source: "agent", + agent: "hermes", + }); + }); + + it("does not label openclaw-only presets as agent-sourced on hermes sandboxes", () => { + expect( + classifyPresetProvenance("openclaw-pricing", { + tierName: "open", + agentName: "hermes", + }), + ).toEqual({ source: "user" }); + }); + + it("does not label hermes-only presets as agent-sourced on openclaw sandboxes", () => { + expect( + classifyPresetProvenance("nous-web", { + tierName: "balanced", + agentName: "openclaw", + }), + ).toEqual({ source: "user" }); + }); + + it("does not label agent-only presets without a known agentName", () => { + expect(classifyPresetProvenance("openclaw-pricing", {})).toEqual({ source: "user" }); + expect(classifyPresetProvenance("nous-web", { agentName: null })).toEqual({ + source: "user", + }); + }); + + it("falls back to user-source for non-tier, non-agent presets", () => { + expect(classifyPresetProvenance("custom-private", { tierName: "balanced" })).toEqual({ + source: "user", + }); + }); + + it("treats missing tier context as no tier match", () => { + expect(classifyPresetProvenance("npm", {})).toEqual({ source: "user" }); + expect(classifyPresetProvenance("npm", { tierName: null })).toEqual({ + source: "user", + }); + }); + + it("normalises preset, tier, and agent casing", () => { + expect( + classifyPresetProvenance("OPENCLAW-PRICING", { + tierName: " BALANCED ", + agentName: "OpenClaw", + }), + ).toEqual({ + source: "agent", + agent: "openclaw", + }); + expect(classifyPresetProvenance("NPM", { tierName: " BALANCED " })).toEqual({ + source: "tier", + tier: "balanced", + }); + }); +}); + +describe("formatPresetProvenanceTag", () => { + it("renders the tier source as 'from tier'", () => { + expect(formatPresetProvenanceTag({ source: "tier", tier: "balanced" })).toBe( + "from balanced tier", + ); + }); + + it("renders the agent source as 'from agent'", () => { + expect(formatPresetProvenanceTag({ source: "agent", agent: "openclaw" })).toBe( + "from openclaw agent", + ); + expect(formatPresetProvenanceTag({ source: "agent", agent: "hermes" })).toBe( + "from hermes agent", + ); + }); + + it("renders the user source as 'user-added'", () => { + expect(formatPresetProvenanceTag({ source: "user" })).toBe("user-added"); + }); +}); + +describe("formatPresetProvenanceSuffix", () => { + it("only reports inferred provenance for registry and gateway agreement", () => { + expect( + formatPresetProvenanceSuffix( + "npm", + { tierName: "balanced" }, + { active: true, inRegistry: true, inGateway: true }, + ), + ).toBe(" [from balanced tier]"); + expect( + formatPresetProvenanceSuffix( + "npm", + { tierName: "balanced" }, + { active: true, inRegistry: false, inGateway: true }, + ), + ).toBe(" [source unverified]"); + expect( + formatPresetProvenanceSuffix( + "npm", + { tierName: "balanced" }, + { active: true, inRegistry: true, inGateway: null }, + ), + ).toBe(" [source unverified (gateway unreachable)]"); + expect( + formatPresetProvenanceSuffix( + "npm", + { tierName: "balanced" }, + { active: false, inRegistry: true, inGateway: false }, + ), + ).toBe(""); + }); +}); diff --git a/src/lib/policy/preset-provenance.ts b/src/lib/policy/preset-provenance.ts new file mode 100644 index 00000000000..3ad059e2329 --- /dev/null +++ b/src/lib/policy/preset-provenance.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { HERMES_TOOL_GATEWAY_PRESET_NAMES } from "../onboard/hermes-managed-tools"; +import { OPENCLAW_ONLY_POLICY_PRESETS } from "../onboard/openclaw-otel-policy-presets"; +import { getTier } from "./tiers"; + +export type PresetProvenance = + | { source: "tier"; tier: string } + | { source: "agent"; agent: "openclaw" | "hermes" } + | { source: "user" }; + +export interface PresetProvenanceContext { + tierName?: string | null; + agentName?: string | null; +} + +export interface PresetVerificationState { + active: boolean; + inRegistry: boolean; + inGateway: boolean | null; +} + +/** + * Infer display-only provenance from the sandbox's current tier and agent. + * A current tier-name match takes precedence over agent and user fallbacks; + * application history is not persisted, so a later user-added preset that + * shadows a tier name is intentionally displayed as tier-derived. + */ +export function classifyPresetProvenance( + presetName: string, + context: PresetProvenanceContext = {}, +): PresetProvenance { + const name = presetName.trim().toLowerCase(); + const tierName = context.tierName?.trim().toLowerCase() || null; + const agentName = context.agentName?.trim().toLowerCase() ?? null; + if (tierName) { + const tierDef = getTier(tierName); + if (tierDef?.presets.some((preset) => preset.name === name)) { + return { source: "tier", tier: tierDef.name }; + } + } + if (agentName === "openclaw" && OPENCLAW_ONLY_POLICY_PRESETS.has(name)) { + return { source: "agent", agent: "openclaw" }; + } + if (agentName === "hermes" && HERMES_TOOL_GATEWAY_PRESET_NAMES.has(name)) { + return { source: "agent", agent: "hermes" }; + } + return { source: "user" }; +} + +export function formatPresetProvenanceTag(provenance: PresetProvenance): string { + switch (provenance.source) { + case "tier": + return `from ${provenance.tier} tier`; + case "agent": + return `from ${provenance.agent} agent`; + case "user": + return "user-added"; + } +} + +/** Format the display suffix without claiming provenance for unverified state. */ +export function formatPresetProvenanceSuffix( + presetName: string, + context: PresetProvenanceContext, + state: PresetVerificationState, +): string { + if (!state.active) return ""; + if (state.inRegistry && state.inGateway === true) { + return ` [${formatPresetProvenanceTag(classifyPresetProvenance(presetName, context))}]`; + } + return state.inGateway === null + ? " [source unverified (gateway unreachable)]" + : " [source unverified]"; +} diff --git a/test/e2e/live/common-egress-agent.test.ts b/test/e2e/live/common-egress-agent.test.ts index d5ca999f827..bdec78a04a8 100644 --- a/test/e2e/live/common-egress-agent.test.ts +++ b/test/e2e/live/common-egress-agent.test.ts @@ -25,6 +25,7 @@ import { import { shouldRunLiveE2E } from "../fixtures/live-project-gate.ts"; import type { SecretStore } from "../fixtures/secrets.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { stripAnsi } from "./json-envelope.ts"; import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts"; // @@ -89,6 +90,11 @@ interface CleanupAttempt { outputTail: string; } +interface ActivePolicyPreset { + name: string; + provenance: string; +} + function text(result: Pick): string { return [result.stdout, result.stderr].filter(Boolean).join("\n"); } @@ -352,6 +358,8 @@ async function runOnboard( sandboxName: string; skip: SkipFn; tier: "balanced" | "open"; + extraEnv?: NemoEnv; + extraRedactionValues?: string[]; }, ): Promise { const onboard = await host.command( @@ -368,12 +376,13 @@ async function runOnboard( cwd: REPO_ROOT, env: commandEnv({ ...args.hosted.env, + ...args.extraEnv, NEMOCLAW_AGENT: args.agent, NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_POLICY_TIER: args.tier, NEMOCLAW_SANDBOX_NAME: args.sandboxName, }), - redactionValues: [args.hosted.apiKey], + redactionValues: [args.hosted.apiKey, ...(args.extraRedactionValues ?? [])], timeoutMs: ONBOARD_TIMEOUT_MS, }, ); @@ -440,6 +449,46 @@ async function assertPolicyAbsent( expect(text(policy), `${label}: unexpected policy entry ${needle}`).not.toContain(needle); } +async function listActivePolicyPresets( + host: HostCliClient, + sandboxName: string, + label: string, +): Promise { + const result = await host.command("node", [CLI_ENTRYPOINT, sandboxName, "policy-list"], { + artifactName: `policy-list-${label}`, + env: commandEnv(), + timeoutMs: 60_000, + }); + expect(result.exitCode, text(result)).toBe(0); + return stripAnsi(text(result)) + .split(/\r?\n/u) + .flatMap((line) => { + const match = line.match(/^\s*●\s+([a-z0-9-]+)\s+\[([^\]]+)\]/iu); + return match?.[1] && match[2] + ? [{ name: match[1].toLowerCase(), provenance: match[2].toLowerCase() }] + : []; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +async function addPolicyPreset( + host: HostCliClient, + sandboxName: string, + preset: string, +): Promise { + const result = await host.command( + "node", + [CLI_ENTRYPOINT, sandboxName, "policy-add", preset, "--yes"], + { + artifactName: `policy-add-${preset}`, + env: commandEnv(), + timeoutMs: 60_000, + }, + ); + expect(result.exitCode, text(result)).toBe(0); + await sleep(2_000); +} + async function runOpenClawAgentAssertion( host: HostCliClient, sandbox: SandboxClient, @@ -696,17 +745,19 @@ test("common-egress agent classifies pre-contract provider validation skips", () describe.sequential("common-egress agent live targets", () => { openClawTest( - "C1 OpenClaw balanced permits a verified wttr.in curl", + "C1 OpenClaw balanced excludes weather until explicitly added, then permits a verified wttr.in curl", { timeout: TEST_TIMEOUT_MS }, async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { const hosted = await assertPrerequisites(host, secrets, skip); const apiKey = hosted.apiKey; + const braveApiKey = secrets.required("BRAVE_API_KEY"); await artifacts.writeJson("target.json", { id: "common-egress-agent", case: "openclaw-balanced-weather", sandboxName: OPENCLAW_BALANCED_SANDBOX, contract: [ - "OpenClaw balanced onboarding applies weather common-egress endpoints", + "OpenClaw balanced onboarding applies exactly six expected presets without weather", + "explicit policy-add weather applies the weather common-egress endpoints", "balanced scope does not include the broader restcountries public-reference endpoint", "a real OpenClaw agent turn validates one wttr.in response and leaves its body as proof", ], @@ -719,7 +770,39 @@ describe.sequential("common-egress agent live targets", () => { sandboxName: OPENCLAW_BALANCED_SANDBOX, skip, tier: "balanced", + extraEnv: { BRAVE_API_KEY: braveApiKey }, + extraRedactionValues: [braveApiKey], }); + + expect( + await listActivePolicyPresets(host, OPENCLAW_BALANCED_SANDBOX, "c1-balanced-initial"), + ).toEqual([ + { name: "brave", provenance: "from balanced tier" }, + { name: "brew", provenance: "from balanced tier" }, + { name: "huggingface", provenance: "from balanced tier" }, + { name: "npm", provenance: "from balanced tier" }, + { name: "openclaw-pricing", provenance: "from openclaw agent" }, + { name: "pypi", provenance: "from balanced tier" }, + ]); + await assertPolicyAbsent( + sandbox, + OPENCLAW_BALANCED_SANDBOX, + "c1-weather-before-add", + "wttr.in", + ); + + await addPolicyPreset(host, OPENCLAW_BALANCED_SANDBOX, "weather"); + expect( + await listActivePolicyPresets(host, OPENCLAW_BALANCED_SANDBOX, "c1-after-weather-add"), + ).toEqual([ + { name: "brave", provenance: "from balanced tier" }, + { name: "brew", provenance: "from balanced tier" }, + { name: "huggingface", provenance: "from balanced tier" }, + { name: "npm", provenance: "from balanced tier" }, + { name: "openclaw-pricing", provenance: "from openclaw agent" }, + { name: "pypi", provenance: "from balanced tier" }, + { name: "weather", provenance: "user-added" }, + ]); await assertPolicyContains(sandbox, OPENCLAW_BALANCED_SANDBOX, "c1-policy", [ "api.open-meteo.com", "geocoding-api.open-meteo.com", diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index a7204dea4fe..c448eaedc01 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -178,6 +178,26 @@ describe("onboard policy preset suggestions", () => { } }); + it("balanced OpenClaw with web search returns exactly brave brew huggingface npm openclaw-pricing pypi and excludes weather", () => { + const knownWithPricing = [...known, "openclaw-pricing"]; + const suggestions = computeSetupPresetSuggestions("balanced", { + enabledChannels: [], + knownPresetNames: knownWithPricing, + agent: "openclaw", + webSearchConfig: { fetchEnabled: true }, + webSearchSupported: true, + }); + expect([...suggestions].sort()).toEqual([ + "brave", + "brew", + "huggingface", + "npm", + "openclaw-pricing", + "pypi", + ]); + expect(suggestions).not.toContain("weather"); + }); + it("adds openclaw-pricing to tier suggestions when agent is openclaw", () => { const knownWithPricing = [...known, "openclaw-pricing"]; const openclawSuggestions = computeSetupPresetSuggestions("balanced", { @@ -251,7 +271,7 @@ describe("onboard policy preset suggestions", () => { enabledChannels: [], knownPresetNames: known, }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); }); it("adds Brave to balanced tier defaults only when web search is configured", () => { @@ -261,7 +281,7 @@ describe("onboard policy preset suggestions", () => { webSearchConfig: { fetchEnabled: true }, webSearchSupported: true, }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave", "weather"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]); }); it("filters tier defaults to known presets for agent-specific onboarding", () => { @@ -269,7 +289,7 @@ describe("onboard policy preset suggestions", () => { enabledChannels: [], knownPresetNames: known.filter((name) => name !== "brave"), }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); }); it("omits Brave when web search is unsupported", () => { @@ -290,7 +310,7 @@ describe("onboard policy preset suggestions", () => { knownPresetNames: known, webSearchSupported: false, }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); }); it("adds all Hermes Nous tool policy presets for Hermes open tier only", () => { diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index 60b58d3e01b..be0c09420d7 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -224,7 +224,6 @@ console.log = () => {}; "npm", "openclaw-pricing", "pypi", - "weather", ]); }); @@ -308,7 +307,6 @@ console.log = () => {}; "npm", "openclaw-pricing", "pypi", - "weather", ]); }); diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 79dcbc75a38..c60be156855 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -280,7 +280,7 @@ console.log = () => {}; assert.equal(payload.presets.length, 0); }); - it("balanced tier resolves dev presets read-write and weather read-only", () => { + it("balanced tier resolves exactly the five dev presets read-write without weather", () => { const tiersPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "tiers.ts")); const script = buildPreamble({ tierEnv: "balanced" }) + @@ -297,14 +297,18 @@ console.log = () => {}; assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.tier, "balanced"); - assert.ok(payload.presets.length >= 6, "balanced tier must have at least 6 presets"); + const names: string[] = payload.presets.map((p: { name: string }) => p.name); + assert.deepEqual( + [...names].sort(), + ["brave", "brew", "huggingface", "npm", "pypi"], + "balanced tier must resolve exactly brave, brew, huggingface, npm, pypi", + ); const accessByName = new Map( payload.presets.map((p: { name: string; access: string }) => [p.name, p.access]), ); for (const name of ["npm", "pypi", "huggingface", "brew", "brave"]) { assert.equal(accessByName.get(name), "read-write", `${name} should be read-write`); } - assert.equal(accessByName.get("weather"), "read", "weather should be read-only"); }); it("open tier resolves presets including at least one social/messaging preset", () => { @@ -1016,14 +1020,10 @@ ${body} const names = resolved.map((p) => p.name); assert.ok(names.includes("npm"), "npm should be included"); assert.ok(names.includes("brave"), "brave should be included"); - assert.ok(names.includes("weather"), "weather should be included"); + assert.ok(!names.includes("weather"), "weather should not be a balanced tier default"); assert.ok(!names.includes("slack"), "slack should not be included in balanced"); for (const p of resolved) { - if (p.name === "weather") { - assert.equal(p.access, "read", `${p.name} should default to read`); - } else { - assert.equal(p.access, "read-write", `${p.name} should default to read-write`); - } + assert.equal(p.access, "read-write", `${p.name} should default to read-write`); } }); @@ -1080,7 +1080,7 @@ ${body} assert.equal(result.status, 0, result.stderr); const resolved: Array<{ name: string }> = JSON.parse(result.stdout.trim()); const names = resolved.map((p) => p.name); - const tierNames = ["npm", "pypi", "huggingface", "brew", "brave", "weather"]; + const tierNames = ["npm", "pypi", "huggingface", "brew", "brave"]; const lastTierIdx = Math.max(...tierNames.map((n) => names.indexOf(n))); const slackIdx = names.indexOf("slack"); assert.ok(slackIdx > lastTierIdx, "non-tier preset (slack) should appear after tier presets"); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index df79f318903..6f5124b5cae 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -119,28 +119,26 @@ describe("tiers", () => { }); describe("tier: balanced", () => { - it("includes npm, pypi, huggingface, brew, brave, and weather", () => { + it("includes exactly npm, pypi, huggingface, brew, and brave", () => { const names = mustGetTier("balanced").presets.map((preset: TierPreset) => preset.name); - expect(names).toContain("npm"); - expect(names).toContain("pypi"); - expect(names).toContain("huggingface"); - expect(names).toContain("brew"); - expect(names).toContain("brave"); - expect(names).toContain("weather"); + expect(names).toEqual( + expect.arrayContaining(["npm", "pypi", "huggingface", "brew", "brave"]), + ); + expect(names).toHaveLength(5); }); - it("has at least 6 presets", () => { - expect(mustGetTier("balanced").presets.length).toBeGreaterThanOrEqual(6); + it("does not include the weather preset", () => { + const names = mustGetTier("balanced").presets.map((preset: TierPreset) => preset.name); + expect(names).not.toContain("weather"); }); - it("keeps dev presets read-write and weather read-only", () => { + it("keeps dev presets read-write", () => { const accessByName = new Map( mustGetTier("balanced").presets.map((preset: TierPreset) => [preset.name, preset.access]), ); for (const name of ["npm", "pypi", "huggingface", "brew", "brave"]) { expect(accessByName.get(name)).toBe("read-write"); } - expect(accessByName.get("weather")).toBe("read"); }); it("does not include messaging presets (slack, discord, telegram, wechat, whatsapp)", () => { @@ -221,12 +219,12 @@ describe("tiers", () => { describe("resolveTierPresets", () => { it("returns default presets for balanced with no overrides", () => { const resolved: TierPreset[] = resolveTierPresets("balanced"); - expect(resolved.length).toBeGreaterThanOrEqual(6); + expect(resolved.length).toBe(5); const accessByName = new Map(resolved.map((preset) => [preset.name, preset.access])); for (const name of ["npm", "pypi", "huggingface", "brew", "brave"]) { expect(accessByName.get(name)).toBe("read-write"); } - expect(accessByName.get("weather")).toBe("read"); + expect(accessByName.has("weather")).toBe(false); }); it("applies access override for a specific preset", () => {