Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/network-policy/customize-network-policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ Available presets:
| `jira` | Atlassian Jira API |
| `local-inference` | Local Ollama and vLLM through the host gateway |
| `npm` | npm and Yarn registries |
| `openclaw-pricing` | OpenClaw model-pricing reference fetch (LiteLLM and OpenRouter) |
| `outlook` | Microsoft 365 and Outlook |
| `pypi` | Python Package Index |
| `slack` | Slack API and webhooks |
Expand Down
18 changes: 18 additions & 0 deletions docs/network-policy/integration-policy-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ NemoClaw ships maintained policy presets for common services in `nemoclaw-bluepr
| Hugging Face Hub and Inference API | `huggingface` |
| Jira and Atlassian Cloud | `jira` |
| Local Ollama or vLLM through the host gateway | `local-inference` |
| OpenClaw model-pricing reference fetch | `openclaw-pricing` |
| npm and Yarn packages | `npm` |
| Microsoft 365, Outlook, and Graph API | `outlook` |
| Python Package Index | `pypi` |
Expand Down Expand Up @@ -252,6 +253,23 @@ $ nemoclaw my-assistant exec -- brew install <formula>

You do not need to bootstrap Homebrew, install build dependencies, or source `brew shellenv` inside the sandbox.

## Model Pricing

OpenClaw's gateway fetches reference pricing from LiteLLM and OpenRouter on every start so it can populate `usage.cost` in session JSONL records.
The default-strict egress policy denies both hosts.
The fetch fails closed, the gateway logs `[gateway/model-pricing] LiteLLM pricing fetch failed: TypeError: fetch failed` (and the matching OpenRouter line) on every startup, and every session record records `usage.cost = 0` even though the input and output token counts populate correctly.
Tools that read the session log to display per-turn cost (audit dashboards, compliance review surfaces) cannot distinguish a real free run from this silent failure.

Apply the `openclaw-pricing` preset to allow both pricing endpoints.
The preset pins each host to a single read-only path so it does not widen egress beyond the pricing fetch:

```console
$ nemoclaw my-assistant policy-add openclaw-pricing --dry-run
$ nemoclaw my-assistant policy-add openclaw-pricing --yes
```

After the next gateway restart the WARN entries stop and `usage.cost` populates from the fetched pricing tables.

## Local Inference

Use `local-inference` when the sandbox needs access to host-side local inference services such as Ollama or vLLM through the OpenShell host gateway.
Expand Down
39 changes: 39 additions & 0 deletions nemoclaw-blueprint/policies/presets/openclaw-pricing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# OpenClaw's gateway/model-pricing subsystem fetches reference pricing from
# LiteLLM and OpenRouter on every gateway start. With default-strict egress
# both fetches fail closed with `TypeError: fetch failed`, the gateway
# emits a WARN every startup, and every session JSONL row records
# `usage.cost = 0` even though token counts populate correctly. Tools
# reading the session log to display per-turn cost (audit dashboards,
# compliance review surfaces) cannot distinguish a real free run from
# the silent failure mode. Enabling this preset allowlists the two
# specific pricing endpoints, pinned to GET-only on a single path each.
preset:
name: openclaw-pricing
description: "OpenClaw model-pricing reference fetch (LiteLLM + OpenRouter)"
network_policies:
openclaw-pricing:
name: openclaw-pricing
endpoints:
# LiteLLM publishes the upstream pricing table on raw.githubusercontent.com.
# Path is pinned to the single JSON file the fetch reads, GET only.
- host: raw.githubusercontent.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow:
method: GET
path: "/BerriAI/litellm/main/model_prices_and_context_window.json"
# OpenRouter exposes its model catalogue (with cost fields) under
# /api/v1/models. The fetch is read-only, so POST is not allowed.
- host: openrouter.ai
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/api/v1/models" }
binaries:
- { path: /usr/local/bin/node }
- { path: /usr/bin/node }
33 changes: 33 additions & 0 deletions src/lib/onboard/machine/handlers/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,37 @@ describe("handlePoliciesState", () => {
expect.objectContaining({ selectedPresets: ["npm", "github"] }),
);
});

it("forwards 'openclaw' to setupPoliciesWithSelection when agent is null (default OpenClaw)", async () => {
const { deps, calls } = createDeps();

await handlePoliciesState({ ...baseOptions(deps), agent: null });

expect(calls.setupPolicies).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ agent: "openclaw" }),
);
});

it("forwards 'hermes' to setupPoliciesWithSelection when agent.name is hermes", async () => {
const { deps, calls } = createDeps();

await handlePoliciesState({ ...baseOptions(deps), agent: { name: "hermes" } });

expect(calls.setupPolicies).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ agent: "hermes" }),
);
});

it("treats whitespace-only agent.name as default OpenClaw", async () => {
const { deps, calls } = createDeps();

await handlePoliciesState({ ...baseOptions(deps), agent: { name: " " } });

expect(calls.setupPolicies).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ agent: "openclaw" }),
);
});
});
14 changes: 14 additions & 0 deletions src/lib/onboard/machine/handlers/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

import type { Session, SessionUpdates } from "../../../state/onboard-session";

// Inlined to avoid pulling sandbox-agent's transitive runner.ts deps into
// the generic state handler. Matches normalizeSandboxAgentName: trim,
// default null/blank/"openclaw" to "openclaw".
function normalizeAgentName(name: string | null | undefined): string {
const trimmed = typeof name === "string" ? name.trim() : "";
return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw";
}

export interface PolicyPresetEntry {
name: string;
[key: string]: unknown;
Expand Down Expand Up @@ -75,6 +83,7 @@ export interface PoliciesStateOptions<Agent, WebSearchConfig> {
disabledChannels?: string[] | null;
webSearchConfig: WebSearchConfig | null;
provider: string;
agent?: string | null;
webSearchSupported: boolean;
hermesToolGateways: string[];
onSelection: (policyPresets: string[]) => void;
Expand Down Expand Up @@ -177,6 +186,11 @@ export async function handlePoliciesState<Agent, WebSearchConfig>({
disabledChannels: activeSandbox?.disabledChannels,
webSearchConfig,
provider,
// selectOnboardAgent returns null for the default OpenClaw path (no
// --agent flag, no recorded agent). Normalise null/blank/whitespace
// to "openclaw" so the auto-suggest gate still fires; explicit
// Hermes runs keep their own name.
agent: normalizeAgentName((agent as { name?: string } | null)?.name),
webSearchSupported,
hermesToolGateways,
onSelection: (policyPresets) => {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/onboard/policy-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,25 @@ export interface SuggestedPolicyPresetOptions {
enabledChannels?: string[] | null;
webSearchConfig?: WebSearchConfig | null;
provider?: string | null;
agent?: string | null;
isNonInteractive?: () => boolean;
}

export function getSuggestedPolicyPresets({
enabledChannels = null,
webSearchConfig = null,
provider = null,
agent = null,
isNonInteractive,
}: SuggestedPolicyPresetOptions = {}): string[] {
const suggestions = ["pypi", "npm"];

if (provider && LOCAL_INFERENCE_PROVIDERS.includes(provider)) {
suggestions.push("local-inference");
}
if (agent === "openclaw") {
suggestions.push("openclaw-pricing");
}
const usesExplicitMessagingSelection = Array.isArray(enabledChannels);
const nonInteractive =
isNonInteractive?.() ?? process.env.NEMOCLAW_NON_INTERACTIVE === "1";
Expand Down
12 changes: 11 additions & 1 deletion src/lib/onboard/policy-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type SetupPresetSuggestionOptions = {
enabledChannels?: string[] | null;
webSearchConfig?: WebSearchConfig | null;
provider?: string | null;
agent?: string | null;
knownPresetNames?: string[] | null;
webSearchSupported?: boolean | null;
hermesToolGateways?: string[] | null;
Expand All @@ -48,6 +49,7 @@ export type SetupPolicySelectionOptions = {
webSearchConfig?: WebSearchConfig | null;
enabledChannels?: string[] | null;
provider?: string | null;
agent?: string | null;
knownPresetNames?: string[];
webSearchSupported?: boolean | null;
hermesToolGateways?: string[] | null;
Expand Down Expand Up @@ -127,7 +129,12 @@ export function computeSetupPresetSuggestions(
tierName: string,
options: SetupPresetSuggestionOptions = {},
): string[] {
const { enabledChannels = null, webSearchConfig = null, provider = null } = options;
const {
enabledChannels = null,
webSearchConfig = null,
provider = null,
agent = null,
} = options;
const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null;
const supportOptions = { webSearchSupported: options.webSearchSupported };
const suggestions = deps.tiers
Expand All @@ -144,6 +151,7 @@ export function computeSetupPresetSuggestions(
};
if (webSearchConfig) add("brave");
if (provider && deps.localInferenceProviders.includes(provider)) add("local-inference");
if (agent === "openclaw") add("openclaw-pricing");
if (Array.isArray(enabledChannels)) {
for (const channel of enabledChannels) add(channel);
for (const preset of requiredMessagingChannelPolicyPresets(enabledChannels)) add(preset);
Expand Down Expand Up @@ -237,6 +245,7 @@ export async function setupPoliciesWithSelection(
const webSearchConfig = options.webSearchConfig || null;
const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null;
const provider = options.provider || null;
const agent = options.agent || null;
const hermesToolGateways = Array.isArray(options.hermesToolGateways)
? options.hermesToolGateways
: null;
Expand Down Expand Up @@ -315,6 +324,7 @@ export async function setupPoliciesWithSelection(
enabledChannels,
webSearchConfig,
provider,
agent,
knownPresetNames: allPresets.map((preset) => preset.name),
webSearchSupported: options.webSearchSupported,
hermesToolGateways,
Expand Down
43 changes: 43 additions & 0 deletions test/onboard-policy-suggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
enabledChannels?: string[] | null;
knownPresetNames: string[];
provider?: string | null;
agent?: string | null;
webSearchConfig?: { fetchEnabled?: boolean; provider?: string | null } | null;
webSearchSupported?: boolean | null;
},
Expand All @@ -25,6 +26,7 @@ const {
getSuggestedPolicyPresets: (options?: {
enabledChannels?: string[] | null;
provider?: string | null;
agent?: string | null;
}) => string[];
};

Expand Down Expand Up @@ -103,6 +105,47 @@ describe("onboard policy preset suggestions", () => {
expect(getSuggestedPolicyPresets({})).not.toContain("local-inference");
});

it("suggests openclaw-pricing preset only for the openclaw agent", () => {
expect(getSuggestedPolicyPresets({ agent: "openclaw" })).toContain("openclaw-pricing");
expect(getSuggestedPolicyPresets({ agent: "hermes" })).not.toContain("openclaw-pricing");
expect(getSuggestedPolicyPresets({ agent: null })).not.toContain("openclaw-pricing");
expect(getSuggestedPolicyPresets({})).not.toContain("openclaw-pricing");
});

it("adds openclaw-pricing to tier suggestions when agent is openclaw", () => {
const knownWithPricing = [...known, "openclaw-pricing"];
const openclawSuggestions = computeSetupPresetSuggestions("balanced", {
enabledChannels: [],
knownPresetNames: knownWithPricing,
agent: "openclaw",
});
expect(openclawSuggestions).toContain("openclaw-pricing");

const hermesSuggestions = computeSetupPresetSuggestions("balanced", {
enabledChannels: [],
knownPresetNames: knownWithPricing,
agent: "hermes",
});
expect(hermesSuggestions).not.toContain("openclaw-pricing");

// Defence-in-depth: the suggestion gate must not fire for raw null
// or omitted-agent cases either. The handler normalises null to
// "openclaw" upstream, but anything that bypasses that normalisation
// (third-party callers, tests) should default to safe-no-add.
const nullAgentSuggestions = computeSetupPresetSuggestions("balanced", {
enabledChannels: [],
knownPresetNames: knownWithPricing,
agent: null,
});
expect(nullAgentSuggestions).not.toContain("openclaw-pricing");

const omittedAgentSuggestions = computeSetupPresetSuggestions("balanced", {
enabledChannels: [],
knownPresetNames: knownWithPricing,
});
expect(omittedAgentSuggestions).not.toContain("openclaw-pricing");
});

it("returns balanced tier defaults without messaging presets when no channels enabled", () => {
const suggestions = computeSetupPresetSuggestions("balanced", {
enabledChannels: [],
Expand Down
44 changes: 42 additions & 2 deletions test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ selectFromList(items, options)

describe("policies", () => {
describe("listPresets", () => {
it("returns all 19 presets", () => {
it("returns all 20 presets", () => {
const presets = policies.listPresets();
expect(presets.length).toBe(19);
expect(presets.length).toBe(20);
});

it("each preset has name and description", () => {
Expand Down Expand Up @@ -173,6 +173,7 @@ describe("policies", () => {
"nous-image",
"nous-web",
"npm",
"openclaw-pricing",
"outlook",
"pypi",
"slack",
Expand Down Expand Up @@ -313,6 +314,45 @@ describe("policies", () => {
}
});

it("openclaw-pricing preset pins LiteLLM and OpenRouter reference fetches to GET-only paths", () => {
// OpenClaw's gateway/model-pricing subsystem fetches the LiteLLM
// pricing table and the OpenRouter model catalogue on every start.
// Both endpoints are read-only metadata fetches, so the preset must
// expose exactly one GET rule per host on the specific path each
// fetch reads, with no wildcards that could widen into a general
// raw.githubusercontent.com or openrouter.ai escape hatch.
const parsed = parsePresetYaml("openclaw-pricing");
const endpoints: Array<Record<string, unknown>> =
parsed?.network_policies?.["openclaw-pricing"]?.endpoints ?? [];
expect(endpoints).toHaveLength(2);

const litellm = endpoints.find((item) => item.host === "raw.githubusercontent.com");
if (!litellm) throw new Error("expected raw.githubusercontent.com endpoint");
expect(litellm.port).toBe(443);
expect(litellm.protocol).toBe("rest");
expect(litellm.enforcement).toBe("enforce");
expect(litellm.rules).toEqual([
{
allow: {
method: "GET",
path: "/BerriAI/litellm/main/model_prices_and_context_window.json",
},
},
]);

const openrouter = endpoints.find((item) => item.host === "openrouter.ai");
if (!openrouter) throw new Error("expected openrouter.ai endpoint");
expect(openrouter.port).toBe(443);
expect(openrouter.protocol).toBe("rest");
expect(openrouter.enforcement).toBe("enforce");
expect(openrouter.rules).toEqual([{ allow: { method: "GET", path: "/api/v1/models" } }]);

const binaries: Array<{ path: string }> =
parsed?.network_policies?.["openclaw-pricing"]?.binaries ?? [];
const binaryPaths = binaries.map((entry) => entry.path).sort();
expect(binaryPaths).toEqual(["/usr/bin/node", "/usr/local/bin/node"]);
});

it("local-inference preset includes openclaw and common tool binaries", () => {
const content = requirePresetContent(policies.loadPreset("local-inference"));
expect(content).toContain("/usr/local/bin/openclaw");
Expand Down
Loading