Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 }
22 changes: 22 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,26 @@ 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" }),
);
});
});
6 changes: 6 additions & 0 deletions src/lib/onboard/machine/handlers/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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 +178,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 that to "openclaw" so
// openclaw-pricing auto-suggestion still fires; explicit Hermes runs
// keep their own name and are left alone.
agent: (agent as { name?: string } | null)?.name ?? "openclaw",
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
26 changes: 26 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,30 @@ 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");
});

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