From afba216b208d1630a5b256eb9fa2da3298465ece Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 1 Jul 2026 19:28:47 +0700 Subject: [PATCH 01/16] refactor(policy): move messaging policies into channels --- agents/hermes/policy-additions.yaml | 228 --- ci/platform-matrix.json | 2 +- ci/test-file-size-budget.json | 2 +- docs/reference/platform-support.mdx | 2 +- package.json | 1 + schemas/policy-preset.schema.json | 2 +- scripts/find-source-shape-tests.ts | 1 + scripts/validate-configs.ts | 48 +- .../sandbox/policy-channel-agent-gate.test.ts | 12 +- .../sandbox/policy-channel-policy.test.ts | 6 + .../sandbox/policy-channel-refresh.test.ts | 4 + src/lib/actions/sandbox/policy-channel.ts | 6 +- src/lib/messaging/AGENTS.md | 2 +- src/lib/messaging/README.md | 2 +- .../channels/discord/policy/hermes.yaml | 57 + .../channels/discord/policy/openclaw.yaml | 0 src/lib/messaging/channels/index.ts | 1 + src/lib/messaging/channels/policy.test.ts | 60 + src/lib/messaging/channels/policy.ts | 110 ++ .../channels/slack/policy/hermes.yaml | 55 + .../channels/slack/policy/openclaw.yaml | 0 .../channels/teams/policy/hermes.yaml | 86 ++ .../channels/teams/policy/openclaw.yaml | 0 .../channels/telegram/policy/hermes.yaml | 23 + .../channels/telegram/policy/openclaw.yaml | 0 .../channels/wechat/policy/hermes.yaml | 29 + .../channels/wechat/policy/openclaw.yaml | 0 .../channels/whatsapp/policy/hermes.yaml | 0 .../channels/whatsapp/policy/openclaw.yaml | 69 + .../messaging-network-policy-flow.md | 1266 +++++++++++++++++ src/lib/onboard/initial-policy.test.ts | 4 +- src/lib/onboard/initial-policy.ts | 17 +- src/lib/policy/context.test.ts | 5 + src/lib/policy/context.ts | 9 +- src/lib/policy/failure-classifier.test.ts | 5 + src/lib/policy/index.ts | 75 +- .../channels-add-deepagents-rejection.test.ts | 1 + test/channels-add-preset.test.ts | 2 +- test/onboard-messaging.test.ts | 2 +- .../cli/policy-dispatch.test.ts | 1 + test/policies.test.ts | 157 +- test/policy-add-remove-session-sync.test.ts | 1 + test/policy-channel-yaml-contract.test.ts | 142 ++ test/pr-review-advisor.test.ts | 6 +- test/validate-blueprint.test.ts | 6 +- test/validate-config-schemas.test.ts | 29 +- tools/pr-review-advisor/analyze.mts | 1 + 47 files changed, 2089 insertions(+), 448 deletions(-) create mode 100644 src/lib/messaging/channels/discord/policy/hermes.yaml rename nemoclaw-blueprint/policies/presets/discord.yaml => src/lib/messaging/channels/discord/policy/openclaw.yaml (100%) create mode 100644 src/lib/messaging/channels/policy.test.ts create mode 100644 src/lib/messaging/channels/policy.ts create mode 100644 src/lib/messaging/channels/slack/policy/hermes.yaml rename nemoclaw-blueprint/policies/presets/slack.yaml => src/lib/messaging/channels/slack/policy/openclaw.yaml (100%) create mode 100644 src/lib/messaging/channels/teams/policy/hermes.yaml rename nemoclaw-blueprint/policies/presets/teams.yaml => src/lib/messaging/channels/teams/policy/openclaw.yaml (100%) create mode 100644 src/lib/messaging/channels/telegram/policy/hermes.yaml rename nemoclaw-blueprint/policies/presets/telegram.yaml => src/lib/messaging/channels/telegram/policy/openclaw.yaml (100%) create mode 100644 src/lib/messaging/channels/wechat/policy/hermes.yaml rename nemoclaw-blueprint/policies/presets/wechat.yaml => src/lib/messaging/channels/wechat/policy/openclaw.yaml (100%) rename nemoclaw-blueprint/policies/presets/whatsapp.yaml => src/lib/messaging/channels/whatsapp/policy/hermes.yaml (100%) create mode 100644 src/lib/messaging/channels/whatsapp/policy/openclaw.yaml create mode 100644 src/lib/messaging/messaging-network-policy-flow.md create mode 100644 test/policy-channel-yaml-contract.test.ts diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 4b602692c5b..704b1a0757a 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -149,231 +149,3 @@ network_policies: - { path: /usr/local/bin/curl } - { path: /usr/bin/python3* } - { path: /opt/hermes/.venv/bin/python } - - # ── Messaging policy templates ───────────────────────────────── - # These entries are agent-specific channel templates. During sandbox - # creation, NemoClaw filters out entries for messaging channels that were not - # selected, so a Discord-only Hermes sandbox does not retain Telegram, Slack, - # or WeChat egress. - telegram: - name: telegram - endpoints: - - host: api.telegram.org - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/bot*/**" } - - allow: { method: POST, path: "/bot*/**" } - - allow: { method: GET, path: "/file/bot*/**" } - binaries: - - { path: /usr/local/bin/node } - - { path: /usr/bin/python3* } - - { path: /opt/hermes/.venv/bin/python } - - discord: - name: discord - endpoints: - - host: discord.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - allow: { method: GET, path: "/gateway*" } - - allow: { method: GET, path: "/api/v*/gateway/bot" } - - allow: { method: GET, path: "/api/v*/applications/@me" } - - allow: { method: PUT, path: "/api/v*/applications/*/commands" } - - allow: { method: PUT, path: "/api/v*/channels/*/messages/*/reactions/*/@me" } - - allow: { method: PATCH, path: "/api/v*/applications/*" } - - allow: { method: PATCH, path: "/api/v*/applications/*/commands/*" } - - allow: { method: PATCH, path: "/api/v*/channels/*/messages/*" } - - allow: { method: PATCH, path: "/api/v*/webhooks/*/*/messages/*" } - - allow: { method: DELETE, path: "/api/v*/applications/*/commands/*" } - - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*" } - - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*/reactions/*/*" } - - allow: { method: DELETE, path: "/api/v*/webhooks/*/*/messages/*" } - - host: gateway.discord.gg - port: 443 - protocol: websocket - enforcement: enforce - websocket_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - - host: "*.discord.gg" - port: 443 - protocol: websocket - enforcement: enforce - websocket_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - - host: cdn.discordapp.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - binaries: - - { path: /usr/local/bin/node } - - { path: /usr/bin/python3* } - - { path: /opt/hermes/.venv/bin/python } - - slack: - name: slack - endpoints: - - host: slack.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: api.slack.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: hooks.slack.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: wss-primary.slack.com - port: 443 - protocol: websocket - enforcement: enforce - websocket_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - - host: wss-backup.slack.com - port: 443 - protocol: websocket - enforcement: enforce - websocket_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - binaries: - - { path: /usr/local/bin/hermes } - - { path: /usr/bin/python3* } - - { path: /opt/hermes/.venv/bin/python } - - teams: - name: teams - endpoints: - - host: login.microsoftonline.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: login.botframework.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: api.botframework.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - # The SDK follows Bot Connector serviceUrl values from inbound Teams - # activities, so this host remains method-scoped while Graph/media hosts - # stay read-only. - - host: smba.trafficmanager.net - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - allow: { method: PUT, path: "/**" } - - allow: { method: DELETE, path: "/**" } - - host: graph.microsoft.com - port: 443 - protocol: rest - enforcement: enforce - request_body_credential_rewrite: true - rules: - - allow: { method: GET, path: "/**" } - - host: teams.microsoft.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - host: teams.cdn.office.net - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - host: statics.teams.cdn.office.net - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - host: "*.sharepoint.com" - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - host: 1drv.ms - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - binaries: - - { path: /usr/local/bin/hermes } - - { path: /usr/bin/python3* } - - { path: /opt/hermes/.venv/bin/python } - - # WeChat (personal) via Tencent's iLink Bot API. The Hermes adapter uses - # HTTP long-polling (no WebSocket). WEIXIN_TOKEN is L7-resolved at egress - # from WECHAT_BOT_TOKEN (same credential slot OpenClaw's bridge uses) via - # manifest hook render outputs. See nemoclaw-blueprint/policies/presets/wechat.yaml - # for the shared host set. - wechat_bridge: - name: wechat_bridge - endpoints: - - host: ilinkai.weixin.qq.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: ilinkai.wechat.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - binaries: - - { path: /usr/local/bin/hermes } - - { path: /usr/bin/python3* } - - { path: /opt/hermes/.venv/bin/python } diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 69823f9b65b..3783a331041 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -301,7 +301,7 @@ { "name": "WhatsApp", "status": "caveated", - "notes": "Supported by both OpenClaw and Hermes through the channel manifest `supportedAgents` declaration in `src/lib/messaging/channels/whatsapp/manifest.ts`. Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `nemoclaw-blueprint/policies/presets/whatsapp.yaml`. No Meta Business API integration today; that path is out of scope for this matrix." + "notes": "Supported by both OpenClaw and Hermes through the channel manifest `supportedAgents` declaration in `src/lib/messaging/channels/whatsapp/manifest.ts`. Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `src/lib/messaging/channels/whatsapp/policy/openclaw.yaml` and `src/lib/messaging/channels/whatsapp/policy/hermes.yaml`. No Meta Business API integration today; that path is out of scope for this matrix." }, { "name": "Microsoft Teams", diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index ee67851ca93..d34928b5849 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -12,6 +12,6 @@ "test/onboard-messaging.test.ts": 2062, "test/onboard-selection.test.ts": 6867, "test/onboard.test.ts": 4774, - "test/policies.test.ts": 2489 + "test/policies.test.ts": 2346 } } diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 4596529e4d2..1e7dbc01356 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -117,7 +117,7 @@ NemoClaw configures messaging channels during onboarding. The OpenShell gateway | Discord | Tested | Configured through an OpenShell-managed channel during onboarding. Sandbox egress allowed by the `discord` policy preset. | | Telegram | Tested | Configured through an OpenShell-managed channel during onboarding. | | WeChat | Tested with limitations | Channel hook available. Verify regional account access before relying on this path. | -| WhatsApp | Tested with limitations | Supported by both OpenClaw and Hermes through the channel manifest `supportedAgents` declaration in `src/lib/messaging/channels/whatsapp/manifest.ts`. Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `nemoclaw-blueprint/policies/presets/whatsapp.yaml`. No Meta Business API integration today; that path is out of scope for this matrix. | +| WhatsApp | Tested with limitations | Supported by both OpenClaw and Hermes through the channel manifest `supportedAgents` declaration in `src/lib/messaging/channels/whatsapp/manifest.ts`. Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `src/lib/messaging/channels/whatsapp/policy/openclaw.yaml` and `src/lib/messaging/channels/whatsapp/policy/hermes.yaml`. No Meta Business API integration today; that path is out of scope for this matrix. | | Microsoft Teams | Experimental | Supported by both OpenClaw and Hermes through the manifest-first messaging channel contract. Requires Bot Framework app credentials, a tenant ID, and a public HTTPS endpoint that reaches the sandbox webhook path `/api/messages`. Sandbox egress goes through the `teams` policy preset, and only one active Teams sandbox can use a given local `MSTEAMS_PORT` forward. | {/* integration-status:end */} diff --git a/package.json b/package.json index d387f2ca3d2..17996f48da1 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ ".version", "bin/", "dist/", + "src/lib/messaging/channels/**/policy/*.yaml", "nemoclaw/dist/", "nemoclaw/openclaw.plugin.json", "nemoclaw/package.json", diff --git a/schemas/policy-preset.schema.json b/schemas/policy-preset.schema.json index bcf674942ff..cd30ce52444 100644 --- a/schemas/policy-preset.schema.json +++ b/schemas/policy-preset.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/NVIDIA/NemoClaw/schemas/policy-preset.schema.json", "title": "NemoClaw Policy Preset", - "description": "Schema for policy presets (nemoclaw-blueprint/policies/presets/*.yaml) — named network policy bundles that can be merged into the base sandbox policy.", + "description": "Schema for policy presets (nemoclaw-blueprint/policies/presets/*.yaml and src/lib/messaging/channels/*/policy/*.yaml) — named network policy bundles that can be merged into the base sandbox policy.", "type": "object", "required": ["preset", "network_policies"], "additionalProperties": false, diff --git a/scripts/find-source-shape-tests.ts b/scripts/find-source-shape-tests.ts index fffe5cb82c5..d547bb1573b 100755 --- a/scripts/find-source-shape-tests.ts +++ b/scripts/find-source-shape-tests.ts @@ -133,6 +133,7 @@ function looksLikeDeclarativeConfigPath(text: string): boolean { return ( /nemoclaw-blueprint\/blueprint\.yaml/.test(normalized) || /nemoclaw-blueprint\/policies\//.test(normalized) || + /src\/lib\/messaging\/channels\/[^/]+\/policy\/[^/]+\.yaml/.test(normalized) || /nemoclaw-blueprint\/provider-profiles\//.test(normalized) || /nemoclaw-blueprint\/router\/pool-config\.yaml/.test(normalized) || /nemoclaw-blueprint\/model-specific-setup\//.test(normalized) || diff --git a/scripts/validate-configs.ts b/scripts/validate-configs.ts index cd24310fd43..8e4799146fe 100755 --- a/scripts/validate-configs.ts +++ b/scripts/validate-configs.ts @@ -107,26 +107,48 @@ function discoverTargets(): ConfigTarget[] { // Discover all preset YAML files dynamically. const presetsDir = join(REPO_ROOT, "nemoclaw-blueprint/policies/presets"); + const presetFiles: string[] = []; try { - const presetFiles = readdirSync(presetsDir) - .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) - .map((f) => `nemoclaw-blueprint/policies/presets/${f}`); - if (presetFiles.length > 0) { - targets.push({ - schema: "schemas/policy-preset.schema.json", - files: presetFiles, - }); - } else { - console.warn( - "WARN: presets directory exists but contains no .yaml/.yml files — no preset validation performed", - ); - } + presetFiles.push( + ...readdirSync(presetsDir) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .map((f) => `nemoclaw-blueprint/policies/presets/${f}`), + ); } catch (err) { const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined; if (code !== "ENOENT" && code !== "ENOTDIR") throw err; // presets directory may not exist — not an error } + const channelPoliciesDir = join(REPO_ROOT, "src/lib/messaging/channels"); + try { + const walkChannelPolicies = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + walkChannelPolicies(abs); + } else if (entry.isFile() && /\.ya?ml$/.test(entry.name)) { + const repoPath = pathRelativeToRepo(abs); + if (/(^|\/)policy\/[^/]+\.ya?ml$/.test(repoPath)) presetFiles.push(repoPath); + } + } + }; + walkChannelPolicies(channelPoliciesDir); + } catch (err) { + const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined; + if (code !== "ENOENT" && code !== "ENOTDIR") throw err; + // channel policy directories may not exist — not an error + } + + if (presetFiles.length > 0) { + targets.push({ + schema: "schemas/policy-preset.schema.json", + files: presetFiles.sort(), + }); + } else { + console.warn("WARN: no preset .yaml/.yml files discovered — no preset validation performed"); + } + return targets; } diff --git a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts index 9fc5c1e9f2b..8e83abed79a 100644 --- a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts +++ b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts @@ -41,7 +41,7 @@ let upsertMock: MockInstance; let updateSandboxMock: MockInstance; let runOpenshellMock: MockInstance; let applyPresetMock: MockInstance; -let loadPresetMock: MockInstance; +let loadPresetForSandboxMock: MockInstance; let saveCredentialMock: MockInstance; let getCredentialMock: MockInstance; let promptMock: MockInstance; @@ -68,8 +68,8 @@ beforeEach(() => { runOpenshellMock = vi .spyOn(runtime, "runOpenshell") .mockReturnValue({ status: 0, stdout: "", stderr: "" }); - loadPresetMock = vi - .spyOn(policy, "loadPreset") + loadPresetForSandboxMock = vi + .spyOn(policy, "loadPresetForSandbox") .mockReturnValue("network_policies:\n stub: {}\n"); vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["stub"]); vi.spyOn(policy, "listPresets").mockReturnValue([]); @@ -106,7 +106,7 @@ describe("addSandboxChannel agent gate", () => { expect(errorText).toMatch(/Channel-supported agents: openclaw, hermes/); expect(errorText).toMatch(/Channels supported by agent 'custom-agent': \(none\)/); - expect(loadPresetMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); expect(applyPresetMock).not.toHaveBeenCalled(); expect(upsertMock).not.toHaveBeenCalled(); expect(updateSandboxMock).not.toHaveBeenCalled(); @@ -130,7 +130,7 @@ describe("addSandboxChannel agent gate", () => { } expect(exitCodeFromError(caught)).toBe(1); - expect(loadPresetMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); expect(applyPresetMock).not.toHaveBeenCalled(); expect(upsertMock).not.toHaveBeenCalled(); expect(updateSandboxMock).not.toHaveBeenCalled(); @@ -153,7 +153,7 @@ describe("addSandboxChannel agent gate", () => { .map((call) => call.map(String).join(" ")) .join("\n"); expect(errorText).not.toMatch(/does not support agent/); - expect(loadPresetMock).toHaveBeenCalled(); + expect(loadPresetForSandboxMock).toHaveBeenCalled(); void caught; void exitMock; void logSpy; diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 4caebf8857f..0f71fdb7a5e 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -113,6 +113,12 @@ beforeEach(() => { const presetName = String(name); return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`; }); + vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( + (_sandboxName: unknown, name: unknown) => { + const presetName = String(name); + return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`; + }, + ); applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true); }); diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 9d2c1f6ed0a..e7705a04ec4 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -96,6 +96,10 @@ beforeEach(() => { vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => { return `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`; }); + vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( + (_sandboxName: unknown, name: unknown) => + `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`, + ); applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true); applyPresetContentMock = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 5e471864f75..fd928621fdc 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -167,7 +167,7 @@ export async function addSandboxPolicy( } if (!answer) return; - const presetContent = policies.loadPreset(answer); + const presetContent = policies.loadPresetForSandbox(sandboxName, answer); if (!presetContent) return; const endpoints = policies.getPresetEndpoints(presetContent); @@ -945,7 +945,7 @@ export async function addSandboxChannel( process.exit(1); } - const presetContent = policies.loadPreset(canonical); + const presetContent = policies.loadPresetForSandbox(sandboxName, canonical); const presetPolicyKeys = presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); if (presetContent === null || presetPolicyKeys.length === 0) { @@ -1509,7 +1509,7 @@ export async function removeSandboxPolicy( // Resolve preset content: built-in first, then custom (persisted in // registry). Needed only for the endpoint preview below — removePreset() // itself re-resolves on the library side. - let presetContent: string | null = policies.loadPreset(answer); + let presetContent: string | null = policies.loadPresetForSandbox(sandboxName, answer); if (!presetContent) { const entry = customPresets.find((p: { name: string }) => p.name === answer); if (entry) { diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index a8f62ff3a7e..311fefcc749 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -54,7 +54,7 @@ Start with `channels//manifest.ts`. 3. Add hook implementations under `channels//hooks/` only for side effects or checks that cannot be represented as static manifest data. 4. Register hook handlers in the channel `hooks/index.ts` and in `hooks/builtins.ts`. 5. Add runtime preload assets under `channels//runtime/` only when the agent runtime needs boot/connect-time shims or diagnostics. -6. Add or update `nemoclaw-blueprint/policies/presets/.yaml` when the manifest declares a channel policy preset. +6. Add or update `src/lib/messaging/channels//policy/.yaml` when the manifest declares a channel policy preset. 7. Cover the behavior with manifest/compiler tests plus applier/onboard/channel CLI tests when host effects change. ## Where Changes Belong diff --git a/src/lib/messaging/README.md b/src/lib/messaging/README.md index 5d1954d6299..3675050e0cb 100644 --- a/src/lib/messaging/README.md +++ b/src/lib/messaging/README.md @@ -467,7 +467,7 @@ Add the channel through the manifest-first path. 5. Register template resolution in `channels/template-resolver.ts`. 6. Register hook handlers in `channels//hooks/index.ts` and `hooks/builtins.ts`. 7. Add runtime preload assets under `channels//runtime/` only when the agent runtime needs boot or connect-time shims. -8. Add `nemoclaw-blueprint/policies/presets/.yaml` when `policyPresets` declares a new policy preset. +8. Add `src/lib/messaging/channels//policy/.yaml` when `policyPresets` declares a new policy preset. 9. Add manifest, compiler, applier, lifecycle, build-applier, and policy tests for the behavior you changed. ## Invariants diff --git a/src/lib/messaging/channels/discord/policy/hermes.yaml b/src/lib/messaging/channels/discord/policy/hermes.yaml new file mode 100644 index 00000000000..6ade14673f2 --- /dev/null +++ b/src/lib/messaging/channels/discord/policy/hermes.yaml @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: discord + description: "Hermes Discord API, gateway, and CDN access" + +network_policies: + discord: + name: discord + endpoints: + - host: discord.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: GET, path: "/gateway*" } + - allow: { method: GET, path: "/api/v*/gateway/bot" } + - allow: { method: GET, path: "/api/v*/applications/@me" } + - allow: { method: PUT, path: "/api/v*/applications/*/commands" } + - allow: { method: PUT, path: "/api/v*/channels/*/messages/*/reactions/*/@me" } + - allow: { method: PATCH, path: "/api/v*/applications/*" } + - allow: { method: PATCH, path: "/api/v*/applications/*/commands/*" } + - allow: { method: PATCH, path: "/api/v*/channels/*/messages/*" } + - allow: { method: PATCH, path: "/api/v*/webhooks/*/*/messages/*" } + - allow: { method: DELETE, path: "/api/v*/applications/*/commands/*" } + - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*" } + - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*/reactions/*/*" } + - allow: { method: DELETE, path: "/api/v*/webhooks/*/*/messages/*" } + - host: gateway.discord.gg + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: "*.discord.gg" + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: cdn.discordapp.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/src/lib/messaging/channels/discord/policy/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/discord.yaml rename to src/lib/messaging/channels/discord/policy/openclaw.yaml diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts index 187cb7e14d5..2e4d80cc582 100644 --- a/src/lib/messaging/channels/index.ts +++ b/src/lib/messaging/channels/index.ts @@ -3,4 +3,5 @@ export * from "./built-ins"; export * from "./metadata"; +export * from "./policy"; export { createBuiltInRenderTemplateResolver } from "./template-resolver"; diff --git a/src/lib/messaging/channels/policy.test.ts b/src/lib/messaging/channels/policy.test.ts new file mode 100644 index 00000000000..a42ebd59e7d --- /dev/null +++ b/src/lib/messaging/channels/policy.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + listBuiltInMessagingChannelManifests, + listMessagingPolicyPresetMetadata, +} from "./metadata"; +import { + listMessagingChannelPolicyPresets, + loadMessagingChannelPolicyPreset, + resolveMessagingChannelPolicyPresetPath, +} from "./policy"; + +function policyKeys(content: string | null): string[] { + expect(content).toBeTruthy(); + const parsed = YAML.parse(content ?? ""); + return Object.keys(parsed?.network_policies ?? {}); +} + +describe("messaging channel policy presets", () => { + it("loads OpenClaw and Hermes channel-specific Telegram policy keys", () => { + expect(policyKeys(loadMessagingChannelPolicyPreset("telegram", { agent: "openclaw" }))).toEqual( + ["telegram_bot"], + ); + expect(policyKeys(loadMessagingChannelPolicyPreset("telegram", { agent: "hermes" }))).toEqual([ + "telegram", + ]); + }); + + it("lists operator-facing preset names from channel-owned policy files", () => { + const presets = listMessagingChannelPolicyPresets(); + expect(presets.map((preset) => preset.name).sort()).toEqual([ + "discord", + "slack", + "teams", + "telegram", + "wechat", + "whatsapp", + ]); + expect(presets.find((preset) => preset.name === "slack")?.file).toBe( + "src/lib/messaging/channels/slack/policy/openclaw.yaml", + ); + }); + + it("ships a policy file for every manifest-supported agent and preset", () => { + const missing: string[] = []; + for (const manifest of listBuiltInMessagingChannelManifests()) { + for (const agent of manifest.supportedAgents) { + for (const preset of listMessagingPolicyPresetMetadata({ manifests: [manifest], agent })) { + const resolved = resolveMessagingChannelPolicyPresetPath(preset.presetName, agent); + if (!resolved) missing.push(`${manifest.id}/${agent}/${preset.presetName}`); + } + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/src/lib/messaging/channels/policy.ts b/src/lib/messaging/channels/policy.ts new file mode 100644 index 00000000000..d508c61e40f --- /dev/null +++ b/src/lib/messaging/channels/policy.ts @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; + +import { ROOT } from "../../state/paths"; +import type { MessagingAgentId } from "../manifest"; +import { listMessagingPolicyPresetMetadata } from "./metadata"; + +const CHANNELS_ROOT = path.join(ROOT, "src", "lib", "messaging", "channels"); +const POLICY_FILE_BY_AGENT: Readonly> = { + openclaw: "openclaw.yaml", + hermes: "hermes.yaml", +}; + +export interface MessagingChannelPolicyPresetInfo { + readonly file: string; + readonly name: string; + readonly description: string; + readonly channelId: string; + readonly agent: MessagingAgentId; +} + +function normalizeAgent(agent: MessagingAgentId | string | null | undefined): MessagingAgentId { + return agent === "hermes" ? "hermes" : "openclaw"; +} + +function isSafeId(value: string): boolean { + return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(value); +} + +function channelPolicyPath(channelId: string, agent: MessagingAgentId): string | null { + if (!isSafeId(channelId)) return null; + return path.join(CHANNELS_ROOT, channelId, "policy", POLICY_FILE_BY_AGENT[agent]); +} + +function readPresetHeader(content: string): { name: string; description: string } | null { + const parsed = YAML.parse(content); + const preset = parsed?.preset; + if (!preset || typeof preset !== "object" || Array.isArray(preset)) return null; + const name = preset.name; + if (typeof name !== "string" || name.trim().length === 0) return null; + const description = typeof preset.description === "string" ? preset.description.trim() : ""; + return { name: name.trim(), description }; +} + +function readChannelPolicyInfo( + channelId: string, + expectedPresetName: string, + agent: MessagingAgentId, +): MessagingChannelPolicyPresetInfo | null { + const file = channelPolicyPath(channelId, agent); + if (!file || !fs.existsSync(file)) return null; + const content = fs.readFileSync(file, "utf-8"); + const header = readPresetHeader(content); + if (!header || header.name !== expectedPresetName) return null; + return { + file: path.relative(ROOT, file).replaceAll(path.sep, "/"), + name: header.name, + description: header.description, + channelId, + agent, + }; +} + +export function resolveMessagingChannelPolicyPresetPath( + presetName: string, + agent: MessagingAgentId | string | null | undefined = "openclaw", +): string | null { + const normalizedAgent = normalizeAgent(agent); + for (const preset of listMessagingPolicyPresetMetadata()) { + if (preset.presetName !== presetName) continue; + const file = channelPolicyPath(preset.channelId, normalizedAgent); + if (file && fs.existsSync(file)) return file; + } + return null; +} + +export function loadMessagingChannelPolicyPreset( + presetName: string, + options: { readonly agent?: MessagingAgentId | string | null } = {}, +): string | null { + const file = resolveMessagingChannelPolicyPresetPath(presetName, options.agent); + if (!file) return null; + const content = fs.readFileSync(file, "utf-8"); + const header = readPresetHeader(content); + return header?.name === presetName ? content : null; +} + +export function listMessagingChannelPolicyPresets( + options: { readonly agent?: MessagingAgentId | string | null } = {}, +): MessagingChannelPolicyPresetInfo[] { + const agent = normalizeAgent(options.agent); + const result: MessagingChannelPolicyPresetInfo[] = []; + const seen = new Set(); + for (const preset of listMessagingPolicyPresetMetadata({ agent })) { + if (seen.has(preset.presetName)) continue; + const info = readChannelPolicyInfo(preset.channelId, preset.presetName, agent); + if (!info) continue; + result.push(info); + seen.add(preset.presetName); + } + return result; +} + +export function isMessagingChannelPolicyPreset(presetName: string): boolean { + return listMessagingPolicyPresetMetadata().some((preset) => preset.presetName === presetName); +} diff --git a/src/lib/messaging/channels/slack/policy/hermes.yaml b/src/lib/messaging/channels/slack/policy/hermes.yaml new file mode 100644 index 00000000000..a026778e700 --- /dev/null +++ b/src/lib/messaging/channels/slack/policy/hermes.yaml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: slack + description: "Hermes Slack API, Socket Mode, and webhooks access" + +network_policies: + slack: + name: slack + endpoints: + - host: slack.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.slack.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: hooks.slack.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: wss-primary.slack.com + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: wss-backup.slack.com + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/presets/slack.yaml b/src/lib/messaging/channels/slack/policy/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/slack.yaml rename to src/lib/messaging/channels/slack/policy/openclaw.yaml diff --git a/src/lib/messaging/channels/teams/policy/hermes.yaml b/src/lib/messaging/channels/teams/policy/hermes.yaml new file mode 100644 index 00000000000..e476c21371e --- /dev/null +++ b/src/lib/messaging/channels/teams/policy/hermes.yaml @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: teams + description: "Hermes Microsoft Teams Bot Framework and Graph API access" + +network_policies: + teams: + name: teams + endpoints: + - host: login.microsoftonline.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: login.botframework.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.botframework.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: smba.trafficmanager.net + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: DELETE, path: "/**" } + - host: graph.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - host: teams.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: teams.cdn.office.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: statics.teams.cdn.office.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: "*.sharepoint.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: 1drv.ms + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/presets/teams.yaml b/src/lib/messaging/channels/teams/policy/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/teams.yaml rename to src/lib/messaging/channels/teams/policy/openclaw.yaml diff --git a/src/lib/messaging/channels/telegram/policy/hermes.yaml b/src/lib/messaging/channels/telegram/policy/hermes.yaml new file mode 100644 index 00000000000..823d6ea4a1e --- /dev/null +++ b/src/lib/messaging/channels/telegram/policy/hermes.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: telegram + description: "Hermes Telegram Bot API access" + +network_policies: + telegram: + name: telegram + endpoints: + - host: api.telegram.org + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/bot*/**" } + - allow: { method: POST, path: "/bot*/**" } + - allow: { method: GET, path: "/file/bot*/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/presets/telegram.yaml b/src/lib/messaging/channels/telegram/policy/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/telegram.yaml rename to src/lib/messaging/channels/telegram/policy/openclaw.yaml diff --git a/src/lib/messaging/channels/wechat/policy/hermes.yaml b/src/lib/messaging/channels/wechat/policy/hermes.yaml new file mode 100644 index 00000000000..d4069e750e5 --- /dev/null +++ b/src/lib/messaging/channels/wechat/policy/hermes.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: wechat + description: "Hermes WeChat (personal) iLink API access" + +network_policies: + wechat_bridge: + name: wechat_bridge + endpoints: + - host: ilinkai.weixin.qq.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: ilinkai.wechat.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/presets/wechat.yaml b/src/lib/messaging/channels/wechat/policy/openclaw.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/wechat.yaml rename to src/lib/messaging/channels/wechat/policy/openclaw.yaml diff --git a/nemoclaw-blueprint/policies/presets/whatsapp.yaml b/src/lib/messaging/channels/whatsapp/policy/hermes.yaml similarity index 100% rename from nemoclaw-blueprint/policies/presets/whatsapp.yaml rename to src/lib/messaging/channels/whatsapp/policy/hermes.yaml diff --git a/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml b/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml new file mode 100644 index 00000000000..9119a926778 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/policy/openclaw.yaml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +preset: + name: whatsapp + description: "WhatsApp Web WebSocket and media access" +network_policies: + whatsapp: + name: whatsapp + endpoints: + # WhatsApp Web Noise-over-WebSocket. The /ws/chat upgrade requires + # HTTP/1.1; OpenShell's proxy negotiates h2 ALPN by default when it + # terminates TLS, and Meta's edge returns HTTP/2 405/400 because + # there is no 101 Switching Protocols flow over h2. OpenShell + # v0.0.15+ also auto-terminates TLS unconditionally on REST hosts, + # which would break the Noise handshake even if h1 were negotiated. + # Declaring the endpoint as a raw L4 CONNECT tunnel (`access: full, + # tls: skip`) tells the proxy to pass the encrypted bytes through + # unmodified, so Baileys negotiates h1 ALPN directly with Meta + # inside TLS and the Noise frames survive untouched. Falls back to + # numbered nodes (w1.web.whatsapp.com, w2.web.whatsapp.com, ...) + # when the primary connection drops; the wildcard covers them with + # the same shape. + - host: web.whatsapp.com + port: 443 + access: full + tls: skip + - host: "*.web.whatsapp.com" + port: 443 + access: full + tls: skip + # Baileys hits a handful of *.whatsapp.net subdomains during pairing + # and steady-state: mmg (media gateway), static (location maps and + # other static assets), cdn (CDN), pps (profile pictures), v + # (variants), e1/f/s (encrypted/file/signal media routes). All are + # Meta-controlled, so a wildcard keeps the preset future-proof without + # expanding trust beyond WhatsApp infrastructure. Follows the + # `*.atlassian.net` precedent in the jira preset. The apex is listed + # separately because OpenShell's wildcard matcher does not cover it. + - host: whatsapp.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: "*.whatsapp.net" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + # Baileys calls `fetchLatestBaileysVersion()` at session creation, + # fetching the current WhatsApp Web protocol version from the + # WhiskeySockets/Baileys master branch. Without this rule the fetch + # fails closed and Baileys advertises its bundled (stale) constant, + # which Meta now rejects with `` on pair. + # Scope is pinned to the single file the fetch reads, GET only. + - host: raw.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/WhiskeySockets/Baileys/master/src/Defaults/index.ts" + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/src/lib/messaging/messaging-network-policy-flow.md b/src/lib/messaging/messaging-network-policy-flow.md new file mode 100644 index 00000000000..e28481f045f --- /dev/null +++ b/src/lib/messaging/messaging-network-policy-flow.md @@ -0,0 +1,1266 @@ + + + +# Messaging Network Policy Technical Flow + +This note describes how NemoClaw messaging channel manifests interact with OpenShell network policy for OpenClaw and Hermes sandboxes. +It is an internal technical reference for maintainers and contributors. +It is not part of the published user documentation under `docs/`. + +The goal is to make the full lifecycle explicit: + +- Onboarding. +- Channel add. +- Channel remove. +- Channel stop. +- Channel start. +- Rebuild. +- `policy-add`. +- `policy-remove`. +- `policy-list`. +- Status and diagnostics paths that read the same state. + +## Executive Summary + +Messaging channel configuration and network policy are separate but linked state. +The channel manifest declares what the channel needs. +The compiled messaging plan records the desired channel state. +OpenShell network policy decides whether the sandbox can reach the messaging provider. + +The most important invariant is: + +```text +Active channel config must not be built without the policy path that lets the channel reach its upstream API. +Disabled channels must not contribute render output, host forwards, runtime setup, or required policy entries. +Raw channel secrets must not be serialized into plans, registry state, Docker build args, policy files, or agent config. +``` + +The current implementation is manifest-first, but not every policy mutation is plan-applier-first. +The plan contains manifest-derived `networkPolicy.entries`, and `MessagingSetupApplier.applyPolicyAtOpenShell()` can apply those entries. +However, the live channel command paths currently call the generic policy helpers with the channel-named preset directly. +This works today because every built-in messaging channel has a matching built-in preset name. +If a future channel needs `channelId !== presetName`, the channel command paths must stop assuming the canonical channel ID is also the preset name. + +## Primary Source Files + +### Manifest And Plan Contracts + +| File | Responsibility | +|------|----------------| +| `src/lib/messaging/manifest/types.ts` | Defines `ChannelManifest`, `SandboxMessagingPlan`, `SandboxMessagingNetworkPolicyPlan`, hook phases, credential bindings, render entries, build steps, runtime setup, state updates, and workflow names. | +| `src/lib/messaging/manifest/registry.ts` | Provides the channel manifest registry interface. | +| `src/lib/messaging/channels/built-ins.ts` | Registers current built-in channel manifests. | +| `src/lib/messaging/channels/metadata.ts` | Derives legacy metadata from manifests, including policy preset maps, credential metadata, agent policy key aliases, and validation warnings. | +| `src/lib/messaging/compiler/workflow-planner.ts` | Builds workflow-specific plans for onboard, add, remove, stop, start, and rebuild. | +| `src/lib/messaging/compiler/manifest-compiler.ts` | Compiles manifests into serializable plan sections. | +| `src/lib/messaging/compiler/engines/policy-resolver.ts` | Converts manifest `policyPresets` into plan `networkPolicy.entries`. | + +### Host Appliers + +| File | Responsibility | +|------|----------------| +| `src/lib/messaging/applier/setup-applier.ts` | Encodes, decodes, reads, writes, and applies messaging plans. | +| `src/lib/messaging/applier/policy.ts` | Applies active plan policy entries through an injected `applyPresets` callback. | +| `src/lib/messaging/applier/openshell-provider.ts` | Creates, updates, and attaches OpenShell providers for channel credentials. | +| `src/lib/messaging/applier/host-state-applier.ts` | Persists the durable compact messaging plan in the sandbox registry. | +| `src/lib/messaging/applier/plan-filter.ts` | Filters plan entries to active, non-disabled channels. | +| `src/lib/messaging/applier/build/messaging-build-applier.mts` | Applies the messaging plan inside the sandbox image build. | + +### Policy Helpers + +| File | Responsibility | +|------|----------------| +| `src/lib/policy/index.ts` | Loads built-in and custom presets, merges policy YAML, applies and removes presets with `openshell policy set --wait`, and computes `policy-list` gateway matches. | +| `src/lib/onboard/initial-policy.ts` | Builds the initial sandbox create policy, including create-time messaging presets and Hermes inactive-message-policy pruning. | +| `src/lib/onboard/messaging-policy-presets.ts` | Maps selected or disabled channels to policy presets using manifest-derived metadata. | +| `src/lib/onboard/policy-selection.ts` | Merges tier defaults, enabled channel presets, required channel presets, and agent-required presets. | +| `src/lib/onboard/policy-preset-sync.ts` | Reconciles live policy to the target policy preset set by applying and removing presets. | +| `src/lib/onboard/policy-resume-selection.ts` | Reconciles policy selection during resume, including disabled messaging cleanup. | + +### Lifecycle Coordinators + +| File | Responsibility | +|------|----------------| +| `src/lib/onboard/messaging-channel-setup.ts` | Selects channels during onboarding and writes `NEMOCLAW_MESSAGING_PLAN_B64`. | +| `src/lib/onboard/messaging-prep.ts` | Prepares OpenShell provider definitions for sandbox creation. | +| `src/lib/onboard/sandbox-create-plan.ts` | Computes active messaging channels, initial policy, create args, and providers for sandbox creation. | +| `src/lib/onboard/dockerfile-patch.ts` | Injects the encoded messaging plan into the staged Dockerfile build arg. | +| `src/lib/onboard/machine/handlers/policies.ts` | Connects active channels and disabled channels into policy selection and resume handling. | +| `src/lib/actions/sandbox/policy-channel.ts` | Implements `policy-add`, `policy-remove`, `policy-list`, and `channels add/remove/stop/start`. | +| `src/lib/actions/sandbox/rebuild.ts` | Stages messaging plans before rebuild, restores policy presets, reapplies OpenClaw messaging render after doctor, and verifies host forwards. | +| `src/lib/actions/sandbox/channel-status.ts` | Reads channel runtime status and plan state. | +| `src/lib/actions/sandbox/doctor-messaging.ts` | Reads messaging state for diagnostics. | + +### Agent Policy Inputs + +| File | Responsibility | +|------|----------------| +| `nemoclaw-blueprint/policies/openclaw-sandbox.yaml` | Shared OpenClaw baseline sandbox policy. Messaging provider endpoints are not in this baseline. | +| `agents/hermes/policy-additions.yaml` | Hermes baseline policy. Messaging provider endpoints are not in this baseline. | +| `src/lib/messaging/channels//policy/openclaw.yaml` | OpenClaw channel-owned network policy preset YAML for a messaging channel. | +| `src/lib/messaging/channels//policy/hermes.yaml` | Hermes channel-owned network policy preset YAML for a messaging channel. | +| `nemoclaw-blueprint/policies/presets/.yaml` | Built-in operator-facing policy presets for non-messaging integrations. | +| `agents/openclaw/manifest.yaml` | OpenClaw agent manifest. Its legacy policy path points to `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. | +| `agents/hermes/manifest.yaml` | Hermes agent manifest. Its policy additions path is `agents/hermes/policy-additions.yaml`. | + +## Data Model + +### Channel Manifest + +Each built-in channel manifest lives at: + +```text +src/lib/messaging/channels//manifest.ts +``` + +Policy-relevant manifest fields are: + +| Field | Meaning | +|-------|---------| +| `supportedAgents` | Agents that may use this channel. Current built-ins list `openclaw` and `hermes`. Unsupported agents are rejected before policy, provider, registry, or rebuild mutation. | +| `policyPresets` | Operator-facing policy preset declarations needed when the channel is active. | +| `policyPresets[].name` | Preset name users see and pass to `policy-add`. Current built-ins use the channel ID. | +| `policyPresets[].policyKeys` | Concrete `network_policies` keys for the default policy source. | +| `policyPresets[].agentPolicyKeys` | Concrete `network_policies` keys for specific agents. Telegram maps `telegram` to Hermes key `telegram`. | +| `policyPresets[].requiredAtCreate` | Whether onboarding should force the preset into initial create-time policy and effective policy selection. Slack currently sets this. | +| `policyPresets[].validationWarningLines` | Extra warnings shown when a user applies the preset directly. Discord uses this to steer validation away from `curl`. | +| `credentials` | Provider binding declarations. The plan gets placeholders and availability, not raw tokens. | +| `render` | Agent config render entries for OpenClaw and Hermes. These are filtered to active channels. | +| `hostForward` | Inbound webhook port metadata. Teams declares this. This is not a network policy entry. | +| `runtime` | Runtime visibility, env aliasing, preload, and secret-scan metadata. | +| `agentPackages` | Agent package/plugin installs for active channels. | +| `hooks` | Enrollment, pre-enable, reachability, post-install, health, status, and diagnostic hooks. | + +### Sandbox Messaging Plan + +The compiled plan is `SandboxMessagingPlan`. +It is serialized through: + +```text +NEMOCLAW_MESSAGING_PLAN_B64 +``` + +The plan contains: + +| Plan section | Meaning | +|--------------|---------| +| `channels` | Requested channels and whether each is active, selected, configured, or disabled. | +| `disabledChannels` | Channels configured but explicitly stopped. | +| `credentialBindings` | Provider names, env keys, placeholders, availability, and optional non-secret hashes. | +| `networkPolicy` | Manifest-derived preset names and concrete policy keys. | +| `agentRender` | OpenClaw JSON fragments, Hermes env lines, and Hermes YAML fragments. | +| `buildSteps` | Package installs, build args, and build-file hook outputs. | +| `runtimeSetup` | Runtime preloads, env aliases, and secret scans. | +| `stateUpdates` | Persisted non-secret channel state and rebuild hydration metadata. | +| `healthChecks` | Post-rebuild health checks. | + +Plans are JSON-compatible. +They must not contain functions, class instances, or raw secrets. + +### Registry State + +The sandbox registry stores two independent policy-related concepts: + +```text +registry.sandboxes..messaging.plan +registry.sandboxes..policies +registry.sandboxes..customPolicies +``` + +`messaging.plan` is the desired channel state. +`policies` is the list of built-in preset names NemoClaw believes are applied. +`customPolicies` is the list of custom policy presets applied with `policy-add --from-file` or `policy-add --from-dir`. + +These can drift. +For example, an active channel can exist while its policy preset has been manually removed. +Conversely, `policy-add telegram` can open egress without enabling Telegram channel configuration. + +`policy-list` intentionally displays both local registry state and gateway-enforced state so drift is visible. + +## Current Built-In Channel Policy Mapping + +| Channel | Manifest preset name | OpenClaw concrete policy key | Hermes concrete policy key | OpenClaw YAML | Hermes YAML | +|---------|----------------------|------------------------------|----------------------------|---------------|-------------| +| `telegram` | `telegram` | `telegram_bot` | `telegram` | `src/lib/messaging/channels/telegram/policy/openclaw.yaml` | `src/lib/messaging/channels/telegram/policy/hermes.yaml` | +| `discord` | `discord` | `discord` | `discord` | `src/lib/messaging/channels/discord/policy/openclaw.yaml` | `src/lib/messaging/channels/discord/policy/hermes.yaml` | +| `slack` | `slack` | `slack` | `slack` | `src/lib/messaging/channels/slack/policy/openclaw.yaml` | `src/lib/messaging/channels/slack/policy/hermes.yaml` | +| `teams` | `teams` | `teams` | `teams` | `src/lib/messaging/channels/teams/policy/openclaw.yaml` | `src/lib/messaging/channels/teams/policy/hermes.yaml` | +| `wechat` | `wechat` | `wechat_bridge` | `wechat_bridge` | `src/lib/messaging/channels/wechat/policy/openclaw.yaml` | `src/lib/messaging/channels/wechat/policy/hermes.yaml` | +| `whatsapp` | `whatsapp` | `whatsapp` | `whatsapp` | `src/lib/messaging/channels/whatsapp/policy/openclaw.yaml` | `src/lib/messaging/channels/whatsapp/policy/hermes.yaml` | + +Important details: + +- Telegram's built-in preset name is `telegram`, but the OpenClaw concrete policy key is `telegram_bot`. +- Telegram's Hermes concrete policy key is `telegram`, selected through `agentPolicyKeys` and policy key aliases. +- WeChat's preset name is `wechat`, but the concrete policy key is `wechat_bridge`. +- Slack is marked `requiredAtCreate` in its manifest. +- Teams declares both a policy preset and a host forward. + The forward handles inbound Bot Framework webhook traffic and is separate from outbound sandbox egress policy. +- WhatsApp has no host-side token provider. + Pairing state is created inside the sandbox and policy only opens the external WhatsApp Web and media endpoints. + +## Policy Loading And Agent Overrides + +The user-visible preset name is resolved by `src/lib/policy/index.ts`. + +For a built-in preset: + +1. `loadPreset(presetName)` first checks whether the preset is a messaging channel preset. +2. Messaging channel presets resolve from `src/lib/messaging/channels//policy/openclaw.yaml` by default. +3. `loadPresetForSandbox(sandboxName, presetName)` checks the sandbox agent and resolves Hermes messaging presets from `src/lib/messaging/channels//policy/hermes.yaml`. +4. Non-messaging presets still resolve from `nemoclaw-blueprint/policies/presets/.yaml`. +5. Legacy agent policy additions remain a fallback only for non-messaging agent-specific overrides. + +This means `policy-add telegram` is not necessarily the same YAML for OpenClaw and Hermes. +OpenClaw gets the `telegram_bot` entry from `telegram/policy/openclaw.yaml`. +Hermes gets the `telegram` entry from `telegram/policy/hermes.yaml`. + +The agent policy key alias map comes from manifests through: + +```text +getMessagingPolicyKeyAliases() +``` + +This keeps agent override lookup tied to channel manifests instead of hard-coded policy tables. + +## Onboarding Flow + +### 1. Channel Selection + +Entry point: + +```text +src/lib/onboard/messaging-channel-setup.ts +``` + +`setupMessagingChannels()`: + +1. Reads built-in manifests from `createBuiltInChannelManifestRegistry()`. +2. Filters channels through the selected agent's supported channel set. +3. In non-interactive mode, detects channels whose required manifest inputs are complete in env or credential store. +4. In interactive mode, renders a channel selector and seeds already-configured channels. +5. Calls `setupSelectedMessagingChannels()`. + +`setupSelectedMessagingChannels()`: + +1. Normalizes selected channel IDs. +2. Builds an `onboard` plan through `MessagingWorkflowPlanner.buildPlan()`. +3. Runs manifest enrollment hooks when interactive. +4. Writes the plan into `NEMOCLAW_MESSAGING_PLAN_B64`. +5. Deletes inactive selected channels from the enabled set. +6. Prints in-sandbox QR guidance for channels such as WhatsApp. + +At this point, channel configuration is planned. +OpenShell policy is not fully reconciled yet. + +### 2. Conflict And Preflight Checks + +Relevant files: + +```text +src/lib/onboard/sandbox-messaging-preflight.ts +src/lib/onboard/messaging-conflict-guard.ts +``` + +The preflight reads the staged plan and checks for conflicts before sandbox creation. +It respects `disabledChannels`. + +Conflict checks include: + +- Generic credential hash overlap between sandboxes. +- Channel-owned `pre-enable` hooks. +- Slack Socket Mode ownership. +- Teams host-forward port ownership. + +Unsupported agents are blocked earlier through manifest support checks. +DeepAgents-style stale plans are stripped or skipped at action and rebuild boundaries. + +### 3. Provider Preparation + +Entry point: + +```text +src/lib/onboard/messaging-prep.ts +``` + +`prepareCreateSandboxMessaging()`: + +1. Derives token definitions from manifest credential metadata. +2. Filters token definitions to selected channels when a selected-channel list is available. +3. Removes token definitions for disabled channels. +4. Registers additional placeholder providers for available secrets. +5. Detects reusable providers that already exist in OpenShell. +6. Returns `messagingTokenDefs`, reusable provider names, reusable channel names, and disabled channel names. + +Provider records are separate from network policy. +Providers attach secrets to the sandbox through OpenShell. +Policy allows outbound traffic to provider APIs. + +### 4. Active Channel Derivation For Sandbox Create + +Entry point: + +```text +src/lib/onboard/sandbox-create-plan.ts +``` + +`prepareSandboxCreatePlan()` computes `activeMessagingChannels`. +A channel is active for create if it is not disabled and one of these holds: + +- Its primary credential token is available. +- Its provider is reusable. +- It is selected and uses QR or in-sandbox pairing semantics. + +The active channel list feeds: + +- Initial sandbox policy preparation. +- Provider attachment to `openshell sandbox create`. +- Policy selection later in onboarding. + +### 5. Initial Sandbox Create Policy + +Entry point: + +```text +src/lib/onboard/initial-policy.ts +``` + +`prepareInitialSandboxCreatePolicy(basePolicyPath, activeMessagingChannels, options)` builds the policy file passed to: + +```text +openshell sandbox create --policy +``` + +#### OpenClaw + +OpenClaw's baseline is `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. +It does not contain messaging provider endpoints. + +The create-time policy adds: + +- Messaging presets whose manifest sets `requiredAtCreate`. +- Other create-time additions such as some OpenClaw OTEL cases when not suppressed by policy tier. +- Agent or tool gateway additions passed through `additionalPresets`. + +Currently Slack is the messaging preset marked `requiredAtCreate`. +Telegram, Discord, Teams, WeChat, and WhatsApp are generally applied later through the policy selection step or explicit `policy-add`. + +#### Hermes + +Hermes uses `agents/hermes/policy-additions.yaml` as its base policy. +That file contains baseline Hermes entries only. + +Before create, `prepareInitialSandboxCreatePolicy()` treats all active Hermes messaging channel presets as create-time presets and merges them from channel-owned Hermes policy files. +`filterHermesInactiveMessagingPolicies()` remains as compatibility cleanup for older Hermes policy files that still contain embedded messaging templates. +The mapping from channel to Hermes policy keys is still derived from manifests through `getMessagingPolicyKeysByChannel({ agent: "hermes" })`. + +This prevents a Hermes sandbox from getting Telegram, Discord, Slack, Teams, WeChat, or WhatsApp egress merely because the Hermes baseline file exists. + +If an active Hermes channel's policy entry is already present in the filtered base policy, `prepareInitialSandboxCreatePolicy()` records it as already applied. +If an active create-time preset is absent from the base policy, the initial-policy helper merges channel-owned Hermes preset YAML by name. + +Current consequence: + +- Hermes Slack, Telegram, Discord, Teams, WeChat, and WhatsApp use policy files under `src/lib/messaging/channels//policy/hermes.yaml`. +- Hermes baseline policy can change independently from messaging channel egress. + +### 6. Dockerfile Plan Injection + +Entry point: + +```text +src/lib/onboard/dockerfile-patch.ts +``` + +`patchStagedDockerfile()` reads `NEMOCLAW_MESSAGING_PLAN_B64`. +If a plan exists, it hydrates derived plan fields and replaces: + +```text +ARG NEMOCLAW_MESSAGING_PLAN_B64=... +``` + +in the staged Dockerfile. + +If the Dockerfile lacks that arg, patching fails. +This prevents a selected channel from silently disappearing from the image build. + +### 7. Build-Time Applier + +Entry point: + +```text +src/lib/messaging/applier/build/messaging-build-applier.mts +``` + +The build applier reads `NEMOCLAW_MESSAGING_PLAN_B64` and validates that it matches the target agent. +It filters all build work to active, non-disabled channels. + +For OpenClaw, it can: + +- Install declared OpenClaw plugins. +- Run `openclaw doctor --fix` with messaging credential placeholder env overrides. +- Render `openclaw.json` channel and plugin fragments. +- Apply post-agent-install build-file hook outputs. +- Write the reduced runtime plan artifact. + +For Hermes, it can: + +- Render `~/.hermes/.env` lines. +- Render `~/.hermes/config.yaml` fragments. +- Validate trusted Hermes `uv` package specs before root-time installation. +- Write the reduced runtime plan artifact. + +Build-time validation treats `NEMOCLAW_MESSAGING_PLAN_B64` as a derived artifact, not root authority. +Hermes package specs are rechecked against trusted built-in manifests for active channels. + +### 8. Policy Selection + +Entry points: + +```text +src/lib/onboard/machine/handlers/policies.ts +src/lib/onboard/policy-selection.ts +src/lib/onboard/policy-preset-sync.ts +``` + +The policy state handler gathers: + +- Channels selected in the current onboarding run. +- Channels recorded in the onboard session messaging plan. +- Channels active in the sandbox registry messaging plan. +- Channels disabled in the registry plan. + +`mergePolicyMessagingChannels()` merges selected, recorded, and active channels, then excludes disabled channels. + +`setupPoliciesWithSelection()` then computes the target preset set from: + +- Tier defaults. +- Enabled messaging channels. +- Required messaging channel presets. +- Web search configuration. +- Local inference. +- Hermes managed tool gateways. +- Agent-required additions. +- Previously applied presets that should be preserved. + +Disabled messaging channel presets are pruned. +Restricted tier suppression can remove some agent-required presets. + +Important behavior: + +- Open policy tier can include messaging presets even if no channel is configured. + Policy egress is allowed, but no channel bridge exists unless onboarding or `channels add` configured it. +- An operator can remove a non-required channel preset from the policy selector. + The channel config can remain in the image, but its upstream egress will fail until the preset is re-applied. +- Required create-time channel presets are merged back into the effective selection. + +The final target set is reconciled by `syncPresetSelection()`: + +1. Remove applied presets not in the target. +2. Apply newly selected presets. +3. Use `applyPresets()` for batches of built-in presets when possible. +4. Use `applyPreset()` otherwise. +5. Persist the effective set back into the registry when reconciliation touched the live set. + +## Channel Add Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +addSandboxChannel() +``` + +### Add Preconditions + +`channels add ` validates before prompting for tokens or mutating state: + +1. A channel argument is required. +2. The channel manifest must exist. +3. The sandbox agent must support the manifest. +4. The built-in preset YAML named by the canonical channel ID must exist. +5. That preset YAML must contain parseable `network_policies` entries. + +If preset YAML is missing or malformed, the command exits before: + +- Token prompt. +- Provider registration. +- Registry write. +- Policy mutation. +- Rebuild prompt. + +Current gotcha: + +```text +channels add currently validates and applies a preset named after the canonical channel ID. +``` + +This is aligned with current built-ins. +It is not general enough for future channels whose manifest `policyPresets[].name` differs from the channel ID. + +### Add Plan Creation + +`planSandboxChannelAdd()`: + +1. Hydrates non-secret stored config from the onboard session and registry. +2. Builds an `add-channel` plan through `MessagingWorkflowPlanner.buildChannelAddPlanFromSandboxEntry()`. +3. Merges the incoming channel plan with any existing registry plan. +4. Writes the plan to `NEMOCLAW_MESSAGING_PLAN_B64`. + +### Add Conflict Checks + +After planning, `channels add` runs: + +- Generic credential hash conflict checks. +- Channel-owned `pre-enable` hooks. + +Failure behavior: + +- In interactive mode, the user can continue unless the hook failure is not a conflict error. +- In non-interactive mode, conflicts abort unless `--force` is used where supported. +- If the user aborts, no provider, policy, registry, or rebuild mutation has happened yet. + +### Add Active Plan Assertion + +`assertAddChannelPlanActive()` verifies the target channel is active. +If required secret or config inputs are missing, it prints the missing manifest input IDs and exits. + +This matters for host-QR channels such as WeChat. +A cached token without required account metadata is not enough to build an active plan. + +### Add Token Or Host-QR Channels + +Token and host-QR channels currently include: + +- Telegram. +- Discord. +- Slack. +- Microsoft Teams. +- WeChat. + +The flow is: + +1. Collect manifest credentials from env or credential store. +2. Persist acquired channel tokens locally for this run. +3. Register or update OpenShell bridge providers. +4. Apply the channel-named policy preset with `applyChannelPresetIfAvailable()`. +5. Persist the messaging plan to the sandbox registry. +6. Prompt for rebuild. +7. If rebuild runs immediately, verify host forwards and run manifest health checks. + +Policy application happens before plan persistence. +If policy application fails on a fresh add after provider registration, `rollbackChannelAdd()` attempts to: + +- Clear staged channel tokens. +- Detach and delete bridge providers. +- Restore prior local credential state when rotating an existing channel. +- Warn about residual gateway provider state when cleanup cannot be proven. + +This prevents a sandbox from advertising an enabled channel while the policy preset failed to apply. + +### Add In-Sandbox QR Channels + +WhatsApp is the current in-sandbox QR channel. + +The flow is: + +1. Apply the channel-named policy preset. +2. Register no host-side channel provider because there is no host-side token. +3. Persist the active messaging plan. +4. Print pairing guidance. +5. Prompt for rebuild. +6. Pair inside the rebuilt sandbox. + +Policy is applied before plan persistence so the channel is not rebuilt active without upstream egress. + +### Add Rebuild Deferral + +If the operator declines the rebuild: + +- Providers may already be registered. +- The policy preset may already be applied. +- The registry messaging plan records the channel. +- The running sandbox image still has the old channel config until rebuild. + +This is intentional. +The queued state is durable and `rebuild` later applies it to the image. + +## Channel Remove Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +removeSandboxChannel() +``` + +### Remove Preconditions + +The command validates: + +- A channel argument is present. +- The channel exists in the legacy channel facade from `src/lib/sandbox/channels.ts`. + +Removal still uses the compatibility channel facade because provider token keys and QR state helpers predate the manifest-only shape. + +### QR State Cleanup First + +For QR-paired channels that store auth state inside the sandbox, currently WhatsApp, removal starts by clearing durable in-sandbox state. + +This happens before provider, registry, or policy mutation. +If cleanup fails and the channel has residue in registry, policy, session, or live applied presets, the command exits. + +This ordering prevents rebuild backup and restore from preserving an auth blob after the operator asked to remove the channel. + +Cleanup paths are agent-derived: + +- OpenClaw: `/sandbox/.openclaw//` when the agent declares that state dir. +- Hermes: `/sandbox/.hermes/platforms//` when the agent declares `platforms`. + +The cleanup tries OpenShell sandbox exec and falls back to SSH. + +### Provider Teardown + +For token-backed channels, `applyChannelRemoveToGatewayAndRegistry()`: + +1. Ensures the gateway is reachable. +2. Detaches bridge providers from the sandbox. +3. Deletes bridge providers from the gateway. +4. Treats not-found and not-attached as success-equivalent. +5. Fails without updating registry when non-benign detach or delete errors occur. + +Best-effort mode is used only in rollback paths. +Normal remove fails closed so local registry does not say the channel is gone while a gateway bridge is still live. + +### Policy Narrowing + +`removeChannelPresetIfPresent()` removes the channel-named built-in preset when it is applied. + +Behavior: + +- If the built-in preset does not exist, it only syncs the onboard session. +- If the preset is not applied, it only syncs the onboard session. +- If the preset is applied, it calls `policies.removePreset()`. +- Failure prints a warning and manual `policy-remove ` guidance. + +This is best-effort after bridge teardown. +The command does not roll the channel back to enabled just because policy narrowing failed. + +### Plan Persistence And Rebuild + +After provider and policy cleanup: + +1. `persistManifestChannelRemovePlan()` removes the channel from the durable messaging plan. +2. Token-backed channels try best-effort durable state cleanup. +3. NemoClaw prompts for rebuild. + +If rebuild is deferred, the live sandbox image can still contain old channel config until rebuilt. +The bridge provider is already gone and policy is usually narrowed, so the old config should not be able to authenticate or reach the upstream provider. + +## Channel Stop Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +stopSandboxChannel() +``` + +`channels stop ` disables delivery without deleting credentials or channel state. + +The flow is: + +1. Validate the channel argument. +2. Validate the sandbox exists. +3. Validate the channel is configured for the sandbox. +4. No-op if it is already disabled. +5. Persist a plan with the channel in `disabledChannels`. +6. Prompt for rebuild. + +Important policy behavior: + +```text +channels stop does not immediately remove the live policy preset. +``` + +The stop command changes desired state. +It relies on rebuild or later policy resume reconciliation to prune disabled-channel presets. + +Consequences: + +- If the operator defers rebuild, the old running sandbox may still have active channel config and live egress policy. +- If the operator rebuilds, disabled channels are filtered out of render, runtime setup, host forwards, package installs, and restored policy presets. +- If the operator wants immediate live egress narrowing without waiting for rebuild, they must also run `policy-remove `. + +Credentials and QR state remain intact. +This is what lets `channels start` restore the channel without token re-entry or QR re-pairing. + +## Channel Start Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +startSandboxChannel() +``` + +`channels start ` re-enables a configured channel. + +The flow is: + +1. Validate the channel argument. +2. Validate the sandbox exists. +3. Validate the channel is configured for the sandbox. +4. No-op if it is already enabled. +5. Persist a plan with the channel removed from `disabledChannels`. +6. Apply the channel-named policy preset. +7. If policy apply fails, roll the plan back to disabled state and exit. +8. Prompt for rebuild. +9. If rebuild runs immediately, verify needed host forwards. + +Policy application happens before rebuild. +This prevents a channel from being rebuilt active without the matching upstream egress policy. + +If rebuild is deferred: + +- Policy may already be widened. +- The running image may still have the old disabled configuration. +- The next rebuild applies the active channel config. + +## Rebuild Flow + +Entry point: + +```text +src/lib/actions/sandbox/rebuild.ts +``` + +### Plan Staging + +Before destructive work, rebuild calls: + +```text +stageMessagingManifestPlanForRebuild() +``` + +This function: + +1. Loads the target agent. +2. Checks whether the agent can participate in manifest-based messaging. +3. Lists channel IDs supported by the agent. +4. Hydrates the persisted registry plan. +5. Filters unsupported channels. +6. Refreshes derived fields such as runtime setup and host forward metadata. +7. Writes the `rebuild` plan to `NEMOCLAW_MESSAGING_PLAN_B64`. + +If the agent is not supported by any channel manifest, rebuild clears the messaging env plan and skips. +If a stored plan is invalid or cannot be staged, rebuild fails before backup or deletion. + +### Recreate + +Rebuild deletes and recreates the sandbox through `onboard --resume`. +Before calling onboard, it pins the session to: + +- The target sandbox name. +- The target agent. +- The staged messaging plan. +- The original inference provider, model, credential env, endpoint, and Hermes tool gateways. + +The resumed onboarding flow injects the plan into the new image exactly like initial onboarding. + +### Policy Restore + +After recreate and state restore, rebuild restores policy presets. + +Relevant helper: + +```text +mergeRebuildMessagingPolicyPresets() +``` + +Inputs: + +- Backup manifest policy presets when a backup exists. +- Registry policy presets as fallback for stale-sandbox recovery. +- Enabled channel IDs from the staged rebuild plan. +- Disabled channel IDs from the staged rebuild plan. + +Behavior: + +1. Start from backup policy presets or registry policy presets. +2. Prune presets that belong to disabled messaging channels. +3. Add presets that belong to enabled messaging channels. +4. Apply each preset. +5. Track restored and failed preset names. +6. Update the registry `policies` field with only successfully restored presets. + +This means `policy-list` should not show a local applied marker for a preset that rebuild failed to restore. + +### OpenClaw Doctor Reapply + +OpenClaw rebuild runs `openclaw doctor --fix` after state restore. +Doctor can rewrite `openclaw.json`. + +To keep messaging config intact, rebuild calls: + +```text +reapplyMessagingManifestAfterOpenClawDoctor() +``` + +This reapplies manifest-owned render and post-agent-install hook outputs after doctor. +It is OpenClaw-specific. +Hermes does not have an equivalent post-doctor rewrite step. + +### Host Forward Verification + +After rebuild, `ensureMessagingHostForwardAfterRebuild()` verifies host forwards needed by active channels. +Teams depends on this because inbound Bot Framework traffic reaches the sandbox through the configured local webhook port. + +Host forwards are not OpenShell egress policy. +They are separate host-side routing state. + +## `policy-add` Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +addSandboxPolicy() +``` + +Modes: + +- Built-in preset by name. +- Custom preset from `--from-file`. +- Custom presets from `--from-dir`. +- Interactive preset picker. + +### Built-In Preset Add + +For a built-in preset: + +1. Validate the preset name against `policies.listPresets()` after agent filtering. +2. Refuse if the preset is already recorded as applied. +3. Load preset content with `policies.loadPreset()`. +4. Print endpoint preview. +5. Print preset validation warnings. +6. Confirm unless `--yes`, `--force`, or non-interactive mode skips confirmation. +7. Apply the preset through `policies.applyPreset()`. +8. Sync onboard session policy presets. +9. Refresh the sandbox policy context file. + +`policies.applyPreset()`: + +1. Calls `loadPresetForSandbox()`. +2. Resolves an agent-specific policy document when available. +3. Reads current gateway policy with `openshell policy get --full`. +4. Merges the preset's `network_policies` entries into that YAML. +5. Writes a temporary policy file. +6. Calls `openshell policy set --policy --wait `. +7. Records the preset name in the registry. + +### Messaging Preset Warning + +For messaging presets, `getPresetValidationWarning()` uses manifest-derived metadata to warn: + +```text +The preset only opens network egress. +It does not enable channel setup, pairing, or runtime configuration. +``` + +The warning can include channel-specific notes from `validationWarningLines`. + +### Custom Preset Add + +Custom presets: + +1. Must be YAML files. +2. Must declare `preset.name`. +3. Must declare a `network_policies` mapping. +4. Must not collide with a built-in preset name. +5. Are persisted under `registry.customPolicies`. + +Custom presets do not interact with channel manifests. +They can open equivalent endpoints, but they do not configure channel credentials, render agent config, or create messaging plan entries. + +## `policy-remove` Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +removeSandboxPolicy() +``` + +The command: + +1. Selects a built-in or custom preset that is recorded as applied. +2. Resolves preset content from built-in files or registry custom policies. +3. Prints endpoint removal preview. +4. Confirms unless confirmation is skipped. +5. Calls `policies.removePreset()`. +6. Syncs onboard session policy presets. +7. Refreshes the sandbox policy context file. + +`policies.removePreset()`: + +1. Resolves agent-specific preset content when applicable. +2. Reads the current gateway policy. +3. Removes all concrete `network_policies` keys declared by the preset. +4. Calls `openshell policy set --policy --wait `. +5. Removes the built-in preset name from registry `policies` or the custom preset from `customPolicies`. + +`policy-remove ` does not edit `messaging.plan`. +If the channel is still active, it remains configured but cannot reach its upstream API until policy is restored. + +## `policy-list` Flow + +Entry point: + +```text +src/lib/actions/sandbox/policy-channel.ts +listSandboxPolicies() +``` + +The command lists: + +- Built-in non-messaging presets from `nemoclaw-blueprint/policies/presets/`. +- Built-in messaging presets from `src/lib/messaging/channels//policy/.yaml`. +- Sandbox-scoped custom presets from the registry. + +For each preset, it computes: + +- `inRegistry`: whether the preset name is recorded in registry `policies` or `customPolicies`. +- `inGateway`: whether the current OpenShell gateway policy contains all concrete policy keys for that preset. + +`inGateway` is computed by: + +1. Running `openshell policy get --full `. +2. Parsing the returned policy YAML. +3. Listing current `network_policies` keys. +4. Matching those keys against built-in and custom preset definitions. +5. Using agent-specific preset content for agent sandboxes when available. + +If the gateway cannot be reached or the policy cannot be parsed, `getGatewayPresets()` returns `null`. +The display then shows local state only and warns. + +`policy-list` does not infer desired channel state from manifests. +It only compares known preset definitions against local registry and gateway policy. + +## Status And Doctor Flows + +Status and doctor paths read the same plan and runtime artifacts, but they should not mutate policy. + +Relevant files: + +```text +src/lib/actions/sandbox/channel-status.ts +src/lib/actions/sandbox/doctor-messaging.ts +src/lib/messaging/diagnostics.ts +src/lib/channel-runtime-status.ts +``` + +These flows can show: + +- Configured channels. +- Active channels. +- Disabled channels. +- Runtime visibility. +- Slack Socket Mode overlaps. +- Missing bridge startup signals. +- Missing runtime artifacts. +- Gateway or host-forward issues. + +They should not be used as a source of truth for applying network policy. +They are diagnostic consumers of registry, plan, runtime, and gateway state. + +## OpenClaw And Hermes Differences + +### OpenClaw + +OpenClaw channel render targets include: + +- `openclaw.json` channel blocks. +- `openclaw.json` plugin entries. +- OpenClaw plugin installs through `openclaw plugins install`. +- OpenClaw runtime preloads. +- OpenClaw runtime secret scans. +- Post-doctor render reapply after rebuild. + +OpenClaw policy behavior: + +- Baseline policy is `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. +- Messaging endpoints are not baseline. +- Built-in messaging presets generally apply from `nemoclaw-blueprint/policies/presets`. +- Telegram uses concrete key `telegram_bot`. +- WeChat uses concrete key `wechat_bridge`. + +### Hermes + +Hermes channel render targets include: + +- `~/.hermes/.env` env lines. +- `~/.hermes/config.yaml` platform sections. +- Hermes runtime env aliases when declared. +- Hermes package installs only when a trusted active manifest declares a pinned `hermes-uv-pip` package. + +Hermes policy behavior: + +- Baseline policy is `agents/hermes/policy-additions.yaml`. +- That file includes messaging templates for several channels. +- Inactive Hermes messaging entries are removed before sandbox create. +- `policy-add` and `policy-list` resolve agent-specific entries before built-in fallback. +- Telegram uses concrete key `telegram`. +- Slack, Discord, Teams, and WeChat use Hermes-specific entries when available. +- WhatsApp currently falls back to the built-in policy preset. + +### Unsupported Agents + +The manifest `supportedAgents` list is authoritative. +Agents that are not supported by any channel manifest should not receive messaging config, providers, policy mutations, or stale plan rebuilds. + +The unsupported-agent boundary is enforced in: + +- Channel listing. +- Channel add. +- Planner supported-channel checks. +- Rebuild plan staging. +- Onboard stale-plan cleanup. + +## Failure Boundaries And Rollback Rules + +### Fail Before Mutation + +These failures happen before policy, provider, registry, or rebuild mutation: + +- Unknown channel. +- Channel unsupported by sandbox agent. +- Missing or malformed channel-named preset YAML on `channels add`. +- Missing required inputs before active add. +- User aborts a conflict check. +- Non-interactive conflict without `--force`. +- Rebuild messaging plan cannot be staged. + +### Fail Closed After Provider Mutation + +`channels add` can register providers before policy apply. +If policy apply then fails, rollback attempts to remove or restore provider and credential state. + +Fresh add rollback tries to clean: + +- Channel tokens. +- Gateway provider attachments. +- Gateway providers. +- Registry state. + +Existing channel token rotation rollback restores: + +- Prior local credentials. +- Prior registry plan where possible. +- Prior gateway provider values on a best-effort basis. + +Residual gateway provider state is explicitly warned. + +### Fail Closed Before QR State Loss + +`channels remove` for in-sandbox QR channels clears durable in-sandbox auth state before registry and policy mutation. +If that cleanup cannot be confirmed, the command exits and leaves registry and policy untouched. + +### Best-Effort Narrowing After Bridge Removal + +Policy removal during `channels remove` is best-effort once bridge teardown has succeeded. +If policy narrowing fails, the bridge is gone but egress might remain. +The command prints manual `policy-remove ` guidance. + +### Start Rollback + +`channels start` applies policy after enabling the plan. +If policy apply fails, it attempts to put the plan back into disabled state. + +This prevents rebuild from later making the channel active without egress. + +## Common Drift States + +### Active Channel But Missing Policy + +How it happens: + +- Operator runs `policy-remove `. +- Policy restore fails during rebuild. +- Custom policy replacement removes the concrete keys. + +Effect: + +- Agent config and providers can exist. +- Channel traffic is denied by OpenShell. +- `policy-list` should show the preset missing from gateway. + +Recovery: + +```bash +nemoclaw policy-add --yes +``` + +### Policy Applied But Channel Not Configured + +How it happens: + +- Operator runs `policy-add `. +- Open tier applies messaging presets. +- Custom policy contains equivalent endpoints. + +Effect: + +- Sandbox can reach provider API endpoints. +- No bridge is configured unless onboarding or `channels add` created one. + +Recovery: + +```bash +nemoclaw channels add +nemoclaw rebuild +``` + +### Disabled Channel With Live Policy Still Applied + +How it happens: + +- Operator runs `channels stop ` and defers rebuild. + +Effect: + +- Desired plan says disabled. +- Live sandbox and live policy may still reflect the old active state. + +Recovery: + +```bash +nemoclaw rebuild +``` + +Optional immediate narrowing: + +```bash +nemoclaw policy-remove --yes +``` + +### Registry Says Applied But Gateway Unknown + +How it happens: + +- Gateway unreachable during `policy-list`. +- OpenShell policy query fails. + +Effect: + +- `policy-list` shows local state only. +- It cannot prove enforcement. + +Recovery: + +```bash +openshell gateway start --name +nemoclaw policy-list +``` + +## Contributor Checklist For Channel Policy Changes + +When adding or changing a channel policy: + +1. Update the channel manifest first. +2. Keep `supportedAgents` precise. +3. Add or update `policyPresets`. +4. Add or update `src/lib/messaging/channels//policy/openclaw.yaml`. +5. Add or update `src/lib/messaging/channels//policy/hermes.yaml` when Hermes supports the channel. +6. Keep manifest `policyKeys` and `agentPolicyKeys` aligned with the actual YAML keys. +7. Decide whether `requiredAtCreate` is truly needed. +8. Add `validationWarningLines` when direct `policy-add` validation has a known trap. +9. Add or update template resolvers only for derived render values. +10. Add hooks only for side effects or checks that static manifest data cannot express. +11. Update build-time trusted manifest registration if the build applier still uses a static trusted list. +12. Test manifest metadata and plan compilation. +13. Test channel add for preset validation and rollback. +14. Test channel remove for provider, policy, and QR state cleanup when applicable. +15. Test stop/start policy behavior. +16. Test rebuild policy restoration, including disabled-channel pruning. +17. Test Hermes agent-specific policy resolution if the channel supports Hermes. +18. Test `policy-list` gateway matching when concrete keys differ from the preset name. + +## Known Design Gaps + +### Channel Command Preset Name Assumption + +`channels add`, `channels start`, and `channels remove` currently apply or remove the channel-named preset directly. +They do not consume the compiled plan's `networkPolicy.entries`. + +Current built-ins are safe because channel ID and preset name match. +A future channel with a different preset name would need a shared helper that reads manifest policy metadata instead of assuming `channelId === presetName`. + +### Scattered Registration + +The manifest contract is strong, but registration remains split across: + +- Built-in manifests. +- Hook registry. +- Template resolver registry. +- Metadata facades. +- Build-time trusted manifest list. +- Legacy `src/lib/sandbox/channels.ts`. + +A future catalog layer should centralize these surfaces. + +### Policy Is Not Solely Manifest-Driven + +Open policy tier and manual `policy-add` can apply messaging presets independent of active channels. +This is intentional, but it means "preset applied" is not equivalent to "channel configured". + +### Stop Does Not Immediately Narrow Live Egress + +`channels stop` persists desired disabled state and relies on rebuild or policy reconciliation to narrow policy. +This preserves credentials and state for later start, but it is not an immediate firewall operation. + +If immediate egress closure is required, pair `channels stop` with `policy-remove`. + +## Minimal Mental Model + +Use this model when debugging: + +```text +Manifest + declares channel needs, including policy preset names and concrete keys + +Planner + compiles manifests plus current state into SandboxMessagingPlan + +Registry + stores desired channel state and recorded policy preset names + +Docker build applier + turns active plan entries into agent config, packages, runtime setup, and runtime artifacts + +OpenShell providers + carry secrets into the sandbox as placeholders + +OpenShell policy + decides whether the sandbox can reach upstream messaging hosts + +Channel commands + mutate desired channel state, providers, and sometimes live policy + +Policy commands + mutate live policy and registry policy names, but not channel config + +Rebuild + reconciles desired channel state into a fresh image and restores policy to match enabled channels +``` + +When behavior is confusing, inspect these in order: + +1. Manifest support and `policyPresets`. +2. Registry `messaging.plan`. +3. Registry `policies` and `customPolicies`. +4. Gateway policy from `openshell policy get --full`. +5. Agent config in `/sandbox/.openclaw` or `/sandbox/.hermes`. +6. Reduced runtime plan artifact. +7. OpenShell providers. +8. Host forwards for Teams. diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 0135c254ff0..1567aa363b2 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -10,7 +10,9 @@ import YAML from "yaml"; vi.mock("../policy", () => ({ mergePresetNamesIntoPolicy: (policy: string, presetNames: string[]) => ({ - policy: `${policy.trimEnd()}\n slack: {}\n`, + policy: `${policy.trimEnd()}\n${presetNames + .map((preset) => ` ${preset === "wechat" ? "wechat_bridge" : preset}: {}`) + .join("\n")}\n`, appliedPresets: presetNames, missingPresets: [], }), diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index fe66d74dd94..07b4fad326a 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -7,7 +7,10 @@ import YAML from "yaml"; import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; import * as policies from "../policy"; -import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets"; +import { + allMessagingChannelPolicyPresets, + requiredMessagingChannelPolicyPresets, +} from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { filterSuppressedAgentRequiredPresets } from "./policy-tier-suppression"; import { cleanupTempDir, secureTempFile } from "./temp-files"; @@ -228,10 +231,14 @@ export function prepareInitialSandboxCreatePolicy( tierKnown && options.policyTier !== "restricted" ? requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw") : []; + const isHermesPolicy = options.agentName === "hermes" || isHermesPolicyPath(basePolicyPath); + const messagingCreateTimePresets = isHermesPolicy + ? allMessagingChannelPolicyPresets(activeMessagingChannels) + : requiredMessagingChannelPolicyPresets(activeMessagingChannels); const requestedCreateTimePresets = filterSuppressedAgentRequiredPresets( [ ...new Set([ - ...requiredMessagingChannelPolicyPresets(activeMessagingChannels), + ...messagingCreateTimePresets, ...otelCreateTimePresets, ...(options.additionalPresets || []), ]), @@ -242,7 +249,7 @@ export function prepareInitialSandboxCreatePolicy( const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))]; let basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8"); - if (options.agentName === "hermes" || isHermesPolicyPath(basePolicyPath)) { + if (isHermesPolicy) { const filtered = filterHermesInactiveMessagingPolicies(basePolicy, activeMessagingChannels); if (filtered.changed) { const policyPath = secureTempFile("nemoclaw-agent-policy", ".yaml"); @@ -294,7 +301,9 @@ export function prepareInitialSandboxCreatePolicy( }; } - const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets); + const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets, { + agent: options.agentName ?? null, + }); if (mergedPolicy.missingPresets.length > 0) { throw new Error( `Cannot prepare sandbox create policy; missing policy preset(s): ${mergedPolicy.missingPresets.join(", ")}`, diff --git a/src/lib/policy/context.test.ts b/src/lib/policy/context.test.ts index 6223f539309..dfab4b8fde9 100644 --- a/src/lib/policy/context.test.ts +++ b/src/lib/policy/context.test.ts @@ -14,6 +14,7 @@ vi.mock(".", () => ({ listCustomPresets: vi.fn(), listPresets: vi.fn(), loadPreset: vi.fn(), + loadPresetForSandbox: vi.fn(), })); vi.mock("./tiers", () => ({ @@ -58,6 +59,9 @@ function mockBuiltinPresets() { ]); vi.mocked(policies.listCustomPresets).mockReturnValue([]); vi.mocked(policies.loadPreset).mockImplementation((name: string) => PRESET_CONTENT[name] ?? null); + vi.mocked(policies.loadPresetForSandbox).mockImplementation( + (_sandboxName: string, name: string) => PRESET_CONTENT[name] ?? null, + ); vi.mocked(policies.getPresetEndpoints).mockImplementation((content: string) => { const hosts: string[] = []; const regex = /host:\s*(\S+)/g; @@ -93,6 +97,7 @@ function resetMocks() { vi.mocked(policies.listPresets).mockReset(); vi.mocked(policies.listCustomPresets).mockReset(); vi.mocked(policies.loadPreset).mockReset(); + vi.mocked(policies.loadPresetForSandbox).mockReset(); vi.mocked(policies.getPresetEndpoints).mockReset(); vi.mocked(policies.getGatewayPresets).mockReset(); vi.mocked(policies.getGatewayPresets).mockReturnValue(null); diff --git a/src/lib/policy/context.ts b/src/lib/policy/context.ts index 86fd2827342..6ccc0776954 100644 --- a/src/lib/policy/context.ts +++ b/src/lib/policy/context.ts @@ -7,7 +7,7 @@ import { getPresetEndpoints, listCustomPresets, listPresets, - loadPreset, + loadPresetForSandbox, } from "."; import { hostStemsFromEndpoints } from "./host-redaction"; import { getTier } from "./tiers"; @@ -146,7 +146,12 @@ function partitionPresets( const isApplied = applied.has(info.name); const verification = resolveVerification(info.name, isApplied, gatewayPresets); const onGatewayOnly = !isApplied && verification === "gateway-only"; - const entry = presetEntry(info, "builtin", loadPreset(info.name), verification); + const entry = presetEntry( + info, + "builtin", + loadPresetForSandbox(sandboxName, info.name), + verification, + ); if (isApplied || onGatewayOnly) { active.push(entry); } else { diff --git a/src/lib/policy/failure-classifier.test.ts b/src/lib/policy/failure-classifier.test.ts index d197571dd70..ea150670652 100644 --- a/src/lib/policy/failure-classifier.test.ts +++ b/src/lib/policy/failure-classifier.test.ts @@ -14,6 +14,7 @@ vi.mock(".", () => ({ listCustomPresets: vi.fn(), listPresets: vi.fn(), loadPreset: vi.fn(), + loadPresetForSandbox: vi.fn(), })); vi.mock("./tiers", () => ({ @@ -58,6 +59,9 @@ function mockBuiltinPresets() { ]); vi.mocked(policies.listCustomPresets).mockReturnValue([]); vi.mocked(policies.loadPreset).mockImplementation((name: string) => PRESET_CONTENT[name] ?? null); + vi.mocked(policies.loadPresetForSandbox).mockImplementation( + (_sandboxName: string, name: string) => PRESET_CONTENT[name] ?? null, + ); vi.mocked(policies.getPresetEndpoints).mockImplementation((content: string) => { const hosts: string[] = []; const regex = /host:\s*(\S+)/g; @@ -93,6 +97,7 @@ function resetMocks() { vi.mocked(policies.listPresets).mockReset(); vi.mocked(policies.listCustomPresets).mockReset(); vi.mocked(policies.loadPreset).mockReset(); + vi.mocked(policies.loadPresetForSandbox).mockReset(); vi.mocked(policies.getPresetEndpoints).mockReset(); vi.mocked(policies.getGatewayPresets).mockReset(); vi.mocked(policies.getGatewayPresets).mockReturnValue(null); diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 483605ab079..6d4d2cf0c20 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -8,7 +8,9 @@ import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, listBuiltInMessagingChannelManifests, + listMessagingChannelPolicyPresets, listMessagingPolicyPresetMetadata, + loadMessagingChannelPolicyPreset, } from "../messaging/channels"; const fs = require("fs"); @@ -46,6 +48,14 @@ type SelectionOptions = { applied?: string[]; }; +type PresetLoadOptions = { + agent?: string | null; +}; + +type MergePresetNamesOptions = { + agent?: string | null; +}; + type SetupPolicyPresetSupportOptions = { webSearchSupported?: boolean | null; }; @@ -55,13 +65,20 @@ function isPolicyDocument(value: PolicyValue): value is PolicyDocument { } /** - * Enumerate every preset YAML under `nemoclaw-blueprint/policies/presets/` - * and return `{ file, name, description }` triples parsed from the file's - * `preset:` header. + * Enumerate every built-in preset and return `{ file, name, description }` + * triples parsed from each file's `preset:` header. Non-messaging presets live + * under `nemoclaw-blueprint/policies/presets/`; messaging channel presets live + * beside their channel manifests under `src/lib/messaging/channels//policy/`. */ function listPresets(): PresetInfo[] { - if (!fs.existsSync(PRESETS_DIR)) return []; - return fs + const channelPresets = listMessagingChannelPolicyPresets().map(({ file, name, description }) => ({ + file, + name, + description, + })); + const channelPresetNames = new Set(channelPresets.map((preset) => preset.name)); + if (!fs.existsSync(PRESETS_DIR)) return channelPresets; + const centralPresets = fs .readdirSync(PRESETS_DIR) .filter((f: string) => f.endsWith(".yaml")) .map((f: string) => { @@ -73,26 +90,39 @@ function listPresets(): PresetInfo[] { name: nameMatch ? nameMatch[1].trim() : f.replace(".yaml", ""), description: descMatch ? descMatch[1].trim() : "", }; - }); + }) + .filter((preset: PresetInfo) => !channelPresetNames.has(preset.name)); + return [...centralPresets, ...channelPresets]; } /** - * Read a built-in preset by short name from `PRESETS_DIR`. Guards against - * path traversal and returns `null` if the preset does not exist. + * Read a non-messaging built-in preset by short name from `PRESETS_DIR`. + * Guards against path traversal and returns `null` if the preset does not + * exist. */ -function loadPreset(name: string): string | null { +function loadCentralPreset(name: string, options: { reportMissing?: boolean } = {}): string | null { const file = path.resolve(PRESETS_DIR, `${name}.yaml`); if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { console.error(` Invalid preset name: ${name}`); return null; } if (!fs.existsSync(file)) { - console.error(` Preset not found: ${name}`); + if (options.reportMissing !== false) console.error(` Preset not found: ${name}`); return null; } return fs.readFileSync(file, "utf-8"); } +function loadPresetForAgent(name: string, options: PresetLoadOptions = {}): string | null { + const channelPreset = loadMessagingChannelPolicyPreset(name, { agent: options.agent }); + if (channelPreset) return channelPreset; + return loadCentralPreset(name); +} + +function loadPreset(name: string): string | null { + return loadPresetForAgent(name, { agent: "openclaw" }); +} + function isPolicyObject(value: PolicyValue): value is PolicyObject { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -179,7 +209,19 @@ function loadAgentPresetContent( } function loadPresetForSandbox(sandboxName: string, presetName: string): string | null { - const builtinPresetContent = loadPreset(presetName); + let sandboxAgent: string | null = null; + try { + sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; + } catch { + sandboxAgent = null; + } + + const channelPresetContent = loadMessagingChannelPolicyPreset(presetName, { + agent: sandboxAgent, + }); + if (channelPresetContent) return channelPresetContent; + + const builtinPresetContent = loadCentralPreset(presetName); if (!builtinPresetContent) return null; return ( loadAgentPresetContent(sandboxName, presetName, builtinPresetContent) || builtinPresetContent @@ -498,13 +540,14 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st function mergePresetNamesIntoPolicy( currentPolicy: string, presetNames: string[], + options: MergePresetNamesOptions = {}, ): { policy: string; appliedPresets: string[]; missingPresets: string[] } { let merged = currentPolicy; const appliedPresets: string[] = []; const missingPresets: string[] = []; for (const presetName of [...new Set(presetNames)]) { - const presetContent = loadPreset(presetName); + const presetContent = loadPresetForAgent(presetName, { agent: options.agent }); const presetEntries = extractPresetEntries(presetContent); if (!presetEntries) { missingPresets.push(presetName); @@ -858,9 +901,10 @@ function applyPresetContent( } /** - * Apply a built-in preset (by name) to a running sandbox. Loads the preset - * from `nemoclaw-blueprint/policies/presets/.yaml` and delegates to - * `applyPresetContent`. Returns `false` if the named preset does not exist. + * Apply a built-in preset (by name) to a running sandbox. Loads messaging + * presets from channel-owned policy files and non-messaging presets from the + * central preset directory, then delegates to `applyPresetContent`. Returns + * `false` if the named preset does not exist. */ function applyPreset( sandboxName: string, @@ -1315,6 +1359,7 @@ export { listPresets, listSetupPolicyPresets, loadPreset, + loadPresetForSandbox, loadPresetFromFile, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, diff --git a/test/channels-add-deepagents-rejection.test.ts b/test/channels-add-deepagents-rejection.test.ts index b6110d45a62..8c35ee937cf 100644 --- a/test/channels-add-deepagents-rejection.test.ts +++ b/test/channels-add-deepagents-rejection.test.ts @@ -93,6 +93,7 @@ const policies = require(${d("policy/index.js")}); const policyCalls = { loadPreset: [], applyPreset: [] }; policies.listPresets = () => []; policies.loadPreset = (name) => { policyCalls.loadPreset.push(name); return "network_policies:\n stub: {}\n"; }; +policies.loadPresetForSandbox = (_sandboxName, name) => policies.loadPreset(name); policies.parsePresetPolicyKeys = () => ["stub"]; policies.applyPreset = (name, preset) => { policyCalls.applyPreset.push({ name, preset }); return true; }; policies.getAppliedPresets = () => []; diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 6a7ed85ee85..419015e46e7 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -181,7 +181,7 @@ policies.loadPreset = (name) => { if (${JSON.stringify(presetMissingNetworkPolicies)}) return "name: " + name + "\ndescription: \"stub preset without network_policies\"\n"; if (${JSON.stringify(presetMalformedYaml)}) return "network_policies:\n - [unclosed\n"; return "network_policies:\n " + name + ":\n egress:\n - host: example.com"; -}; +}; policies.loadPresetForSandbox = (_sandboxName, name) => policies.loadPreset(name); policies.applyPreset = (sandboxName, presetName) => { appliedCalls.push({ sandboxName, presetName }); callOrder.push("applyPreset:" + presetName); diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 1f60c30939e..ade58b87bf7 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -461,7 +461,7 @@ const { createSandbox } = require(${onboardPath}); assert.ok(payload.createCommand.command.includes("sandbox create")); assert.match(payload.createCommand.command, /--provider my-assistant-slack-bridge/); assert.match(payload.createCommand.command, /--provider my-assistant-slack-app/); - assert.doesNotMatch(payload.createCommand.policyPath, /nemoclaw-initial-policy/); + assert.match(payload.createCommand.policyPath, /nemoclaw-initial-policy/); assert.equal(payload.createCommand.policyReadError, null); assert.deepEqual(payload.registeredPolicies, ["slack"]); assert.deepEqual(payload.slackBinaryPaths, [ diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index c673473157a..5146dc0a3cf 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -44,6 +44,7 @@ policies.listCustomPresets = () => [ ]; policies.getAppliedPresets = () => ["my-api"]; policies.loadPreset = () => null; // built-in lookup misses +policies.loadPresetForSandbox = () => null; // built-in lookup misses policies.getPresetEndpoints = () => ["api.example.internal"]; policies.removePreset = (sandboxName, presetName) => { calls.push({ type: "remove", sandboxName, presetName }); diff --git a/test/policies.test.ts b/test/policies.test.ts index df83d4922cc..75fd532214b 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -50,6 +50,12 @@ function parseRepoYaml(relativePath: string): Record { >; } +function presetInfoPath(preset: { file: string }): string { + return preset.file.includes("/") + ? path.join(REPO_ROOT, preset.file) + : path.join(REPO_ROOT, "nemoclaw-blueprint/policies/presets", preset.file); +} + function parseResultPayload(stdout: string): any { const marker = "__RESULT__"; const markerIndex = stdout.indexOf(marker); @@ -1617,151 +1623,6 @@ exit 1 } }); - it("Slack REST endpoints opt into OpenShell request-body credential rewrite", () => { - const policySources = [ - fs.readFileSync( - path.join(REPO_ROOT, "nemoclaw-blueprint/policies/presets/slack.yaml"), - "utf8", - ), - fs.readFileSync(path.join(REPO_ROOT, "agents/hermes/policy-additions.yaml"), "utf8"), - fs.readFileSync(path.join(REPO_ROOT, "agents/hermes/policy-permissive.yaml"), "utf8"), - fs.readFileSync( - path.join(REPO_ROOT, "nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml"), - "utf8", - ), - ]; - const slackRestHosts = new Set(["slack.com", "api.slack.com", "hooks.slack.com"]); - - for (const content of policySources) { - const parsed = YAML.parse(content) as { - network_policies?: Record< - string, - { - endpoints?: Array<{ - host?: string; - protocol?: string; - request_body_credential_rewrite?: boolean; - }>; - } - >; - }; - const endpoints = Object.values(parsed.network_policies ?? {}).flatMap( - (policy) => policy.endpoints ?? [], - ); - for (const endpoint of endpoints.filter((candidate) => - slackRestHosts.has(candidate.host ?? ""), - )) { - expect(endpoint).toMatchObject({ - protocol: "rest", - request_body_credential_rewrite: true, - }); - } - } - }); - - it("Hermes messaging gateway policies use native inspected WebSocket policy", () => { - const policyFiles = [ - path.join(REPO_ROOT, "agents/hermes/policy-additions.yaml"), - path.join(REPO_ROOT, "agents/hermes/policy-permissive.yaml"), - ]; - const cases = [ - "gateway.discord.gg", - "*.discord.gg", - "wss-primary.slack.com", - "wss-backup.slack.com", - ]; - - for (const file of policyFiles) { - const content = fs.readFileSync(file, "utf8"); - const parsed = YAML.parse(content) as { - network_policies?: Record< - string, - { - endpoints?: Array<{ - host?: string; - protocol?: string; - access?: string; - tls?: string; - websocket_credential_rewrite?: boolean; - rules?: Array<{ allow?: { method?: string; path?: string } }>; - }>; - } - >; - }; - const endpoints = Object.values(parsed.network_policies ?? {}).flatMap( - (policy) => policy.endpoints ?? [], - ); - for (const host of cases) { - const endpoint = endpoints.find((candidate) => candidate.host === host); - expect(endpoint).toBeTruthy(); - expect(endpoint).toMatchObject({ - protocol: "websocket", - enforcement: "enforce", - websocket_credential_rewrite: true, - }); - expect(endpoint).not.toHaveProperty("access"); - expect(endpoint).not.toHaveProperty("tls"); - expect(endpoint?.rules).toEqual( - expect.arrayContaining([ - { allow: { method: "GET", path: "/**" } }, - { allow: { method: "WEBSOCKET_TEXT", path: "/**" } }, - ]), - ); - } - } - }); - - it("Hermes Discord REST mutations are scoped to discord.com", () => { - const parsed = parseRepoYaml("agents/hermes/policy-additions.yaml"); - const networkPolicies = parsed.network_policies as Record< - string, - { - endpoints?: Array<{ - host?: string; - rules?: Array<{ allow?: { method?: string; path?: string } }>; - }>; - } - >; - const rulesFor = (policy: string, host: string) => - (networkPolicies[policy]?.endpoints ?? []) - .filter((endpoint) => endpoint.host === host) - .flatMap((endpoint) => endpoint.rules ?? []) - .map((rule) => rule.allow) - .filter((rule): rule is { method: string; path: string } => - Boolean(rule?.method && rule?.path), - ); - const sortRules = (rules: Array<{ method: string; path: string }>) => - [...rules].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)); - - const nousRules = rulesFor("nous_research", "nousresearch.com"); - expect(nousRules).not.toContainEqual({ method: "PUT", path: "/**" }); - expect(nousRules).not.toContainEqual({ method: "PATCH", path: "/**" }); - expect(nousRules.filter((rule) => ["PUT", "PATCH", "DELETE"].includes(rule.method))).toEqual( - [], - ); - - const discordMutationRules = sortRules( - rulesFor("discord", "discord.com").filter((rule) => - ["PUT", "PATCH", "DELETE"].includes(rule.method), - ), - ); - expect(discordMutationRules).toEqual( - sortRules([ - { method: "PUT", path: "/api/v*/applications/*/commands" }, - { method: "PUT", path: "/api/v*/channels/*/messages/*/reactions/*/@me" }, - { method: "PATCH", path: "/api/v*/applications/*" }, - { method: "PATCH", path: "/api/v*/applications/*/commands/*" }, - { method: "PATCH", path: "/api/v*/channels/*/messages/*" }, - { method: "PATCH", path: "/api/v*/webhooks/*/*/messages/*" }, - { method: "DELETE", path: "/api/v*/applications/*/commands/*" }, - { method: "DELETE", path: "/api/v*/channels/*/messages/*" }, - { method: "DELETE", path: "/api/v*/channels/*/messages/*/reactions/*/*" }, - { method: "DELETE", path: "/api/v*/webhooks/*/*/messages/*" }, - ]), - ); - expect(discordMutationRules.some((rule) => rule.path === "/**")).toBe(false); - }); - it("Hermes PyPI policy lets curl verify read-only package index access (#4014)", () => { const parsed = parseRepoYaml("agents/hermes/policy-additions.yaml"); const pypiPolicy = parsed.network_policies?.pypi as @@ -1823,11 +1684,7 @@ exit 1 : []; const policyFiles = [ path.join(REPO_ROOT, "nemoclaw-blueprint/policies/openclaw-sandbox.yaml"), - ...policies - .listPresets() - .map((preset) => - path.join(REPO_ROOT, "nemoclaw-blueprint/policies/presets", preset.file), - ), + ...policies.listPresets().map((preset) => presetInfoPath(preset)), ...agentPolicyFiles, ]; diff --git a/test/policy-add-remove-session-sync.test.ts b/test/policy-add-remove-session-sync.test.ts index 5ee0ce987e2..1b352e42c58 100644 --- a/test/policy-add-remove-session-sync.test.ts +++ b/test/policy-add-remove-session-sync.test.ts @@ -82,6 +82,7 @@ const calls = { apply: [], applyContent: [], remove: [] }; policies.listPresets = () => ${JSON.stringify(presetNamesAvailable.map((name) => ({ name })))}; policies.getAppliedPresets = () => ${JSON.stringify(appliedPresets)}; policies.loadPreset = (name) => ({ name, network_policies: {} }); +policies.loadPresetForSandbox = (_sandboxName, name) => policies.loadPreset(name); policies.getPresetEndpoints = () => []; policies.getPresetValidationWarning = () => null; policies.selectFromList = async (items) => items[0]?.name || null; diff --git a/test/policy-channel-yaml-contract.test.ts b/test/policy-channel-yaml-contract.test.ts new file mode 100644 index 00000000000..328e89344e7 --- /dev/null +++ b/test/policy-channel-yaml-contract.test.ts @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); + +type Endpoint = { + host?: string; + protocol?: string; + enforcement?: string; + access?: string; + tls?: string; + request_body_credential_rewrite?: boolean; + websocket_credential_rewrite?: boolean; + rules?: Array<{ allow?: { method?: string; path?: string } }>; +}; + +function channelPolicy(channel: string, agent: "openclaw" | "hermes"): Record { + const file = path.join( + REPO_ROOT, + "src/lib/messaging/channels", + channel, + "policy", + `${agent}.yaml`, + ); + return YAML.parse(fs.readFileSync(file, "utf8")) as Record; +} + +function allEndpoints(policy: Record): Endpoint[] { + return Object.values( + (policy.network_policies ?? {}) as Record, + ).flatMap((entry) => entry.endpoints ?? []); +} + +function expectInspectedWebSocket(endpoint: Endpoint | undefined): void { + expect(endpoint).toBeTruthy(); + expect(endpoint).toMatchObject({ + protocol: "websocket", + enforcement: "enforce", + websocket_credential_rewrite: true, + }); + expect(endpoint).not.toHaveProperty("access"); + expect(endpoint).not.toHaveProperty("tls"); + expect(endpoint?.rules).toEqual( + expect.arrayContaining([ + { allow: { method: "GET", path: "/**" } }, + { allow: { method: "WEBSOCKET_TEXT", path: "/**" } }, + ]), + ); +} + +describe("channel-owned messaging policy YAML", () => { + it("Slack REST endpoints opt into OpenShell request-body credential rewrite", () => { + const sources = [ + channelPolicy("slack", "openclaw"), + channelPolicy("slack", "hermes"), + YAML.parse( + fs.readFileSync(path.join(REPO_ROOT, "agents/hermes/policy-permissive.yaml"), "utf8"), + ), + YAML.parse( + fs.readFileSync( + path.join(REPO_ROOT, "nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml"), + "utf8", + ), + ), + ]; + const slackRestHosts = new Set(["slack.com", "api.slack.com", "hooks.slack.com"]); + + for (const endpoint of sources + .flatMap(allEndpoints) + .filter((entry) => slackRestHosts.has(entry.host ?? ""))) { + expect(endpoint).toMatchObject({ + protocol: "rest", + request_body_credential_rewrite: true, + }); + } + }); + + it("Hermes messaging gateway policies use native inspected WebSocket policy", () => { + const cases = [ + { policy: channelPolicy("discord", "hermes"), hosts: ["gateway.discord.gg", "*.discord.gg"] }, + { + policy: channelPolicy("slack", "hermes"), + hosts: ["wss-primary.slack.com", "wss-backup.slack.com"], + }, + ]; + + for (const { policy, hosts } of cases) { + const endpoints = allEndpoints(policy); + for (const host of hosts) { + expectInspectedWebSocket(endpoints.find((endpoint) => endpoint.host === host)); + } + } + }); + + it("Hermes Discord REST mutations are scoped to discord.com", () => { + const networkPolicies = channelPolicy("discord", "hermes").network_policies as Record< + string, + { endpoints?: Endpoint[] } + >; + const rulesFor = (policy: string, host: string) => + (networkPolicies[policy]?.endpoints ?? []) + .filter((endpoint) => endpoint.host === host) + .flatMap((endpoint) => endpoint.rules ?? []) + .map((rule) => rule.allow) + .filter((rule): rule is { method: string; path: string } => + Boolean(rule?.method && rule?.path), + ); + const sortRules = (rules: Array<{ method: string; path: string }>) => + [...rules].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)); + + const nousRules = rulesFor("nous_research", "nousresearch.com"); + expect(nousRules.filter((rule) => ["PUT", "PATCH", "DELETE"].includes(rule.method))).toEqual( + [], + ); + + const discordMutationRules = sortRules( + rulesFor("discord", "discord.com").filter((rule) => + ["PUT", "PATCH", "DELETE"].includes(rule.method), + ), + ); + expect(discordMutationRules).toEqual( + sortRules([ + { method: "PUT", path: "/api/v*/applications/*/commands" }, + { method: "PUT", path: "/api/v*/channels/*/messages/*/reactions/*/@me" }, + { method: "PATCH", path: "/api/v*/applications/*" }, + { method: "PATCH", path: "/api/v*/applications/*/commands/*" }, + { method: "PATCH", path: "/api/v*/channels/*/messages/*" }, + { method: "PATCH", path: "/api/v*/webhooks/*/*/messages/*" }, + { method: "DELETE", path: "/api/v*/applications/*/commands/*" }, + { method: "DELETE", path: "/api/v*/channels/*/messages/*" }, + { method: "DELETE", path: "/api/v*/channels/*/messages/*/reactions/*/*" }, + { method: "DELETE", path: "/api/v*/webhooks/*/*/messages/*" }, + ]), + ); + expect(discordMutationRules.some((rule) => rule.path === "/**")).toBe(false); + }); +}); diff --git a/test/pr-review-advisor.test.ts b/test/pr-review-advisor.test.ts index 612c0c68215..57e309b38cb 100644 --- a/test/pr-review-advisor.test.ts +++ b/test/pr-review-advisor.test.ts @@ -214,9 +214,9 @@ describe("PR review advisor", () => { }); it("classifies sandbox and workflow changes as requiring deeper validation", () => { - expect(classifyTestDepth(["nemoclaw-blueprint/policies/presets/slack.yaml"]).verdict).toBe( - "runtime_validation_recommended", - ); + expect( + classifyTestDepth(["src/lib/messaging/channels/slack/policy/openclaw.yaml"]).verdict, + ).toBe("runtime_validation_recommended"); expect(classifyTestDepth(["src/lib/credentials.ts"]).verdict).toBe("mocks_recommended"); expect(classifyTestDepth(["docs/get-started/quickstart.mdx"]).verdict).toBe("unit_sufficient"); }); diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 70db9adfc94..1c3d2d7aab9 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -664,11 +664,11 @@ describe("jira preset", () => { describe("messaging WebSocket presets", () => { const DISCORD_PRESET_PATH = new URL( - "../nemoclaw-blueprint/policies/presets/discord.yaml", + "../src/lib/messaging/channels/discord/policy/openclaw.yaml", import.meta.url, ); const SLACK_PRESET_PATH = new URL( - "../nemoclaw-blueprint/policies/presets/slack.yaml", + "../src/lib/messaging/channels/slack/policy/openclaw.yaml", import.meta.url, ); @@ -724,7 +724,7 @@ describe("messaging WebSocket presets", () => { describe("Slack REST credential rewrite", () => { const SLACK_PRESET_PATH = new URL( - "../nemoclaw-blueprint/policies/presets/slack.yaml", + "../src/lib/messaging/channels/slack/policy/openclaw.yaml", import.meta.url, ); const data = loadYaml(SLACK_PRESET_PATH); diff --git a/test/validate-config-schemas.test.ts b/test/validate-config-schemas.test.ts index 85ee98dcbc8..372d130a612 100644 --- a/test/validate-config-schemas.test.ts +++ b/test/validate-config-schemas.test.ts @@ -9,7 +9,7 @@ * Vitest project. */ -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, it, expect } from "vitest"; @@ -96,6 +96,7 @@ describe("config validation target discovery", () => { const targets = discoverTargets(); const filesBySchema = new Map(targets.map((target) => [target.schema, target.files])); const sandboxPolicyFiles = filesBySchema.get("schemas/sandbox-policy.schema.json") ?? []; + const presetFiles = filesBySchema.get("schemas/policy-preset.schema.json") ?? []; it("includes every binary-scoped sandbox policy family", () => { expect(sandboxPolicyFiles).toEqual( @@ -116,6 +117,17 @@ describe("config validation target discovery", () => { ]), ); }); + + it("discovers channel-owned messaging policy presets", () => { + expect(presetFiles).toEqual( + expect.arrayContaining([ + "src/lib/messaging/channels/slack/policy/openclaw.yaml", + "src/lib/messaging/channels/slack/policy/hermes.yaml", + "src/lib/messaging/channels/telegram/policy/openclaw.yaml", + "src/lib/messaging/channels/telegram/policy/hermes.yaml", + ]), + ); + }); }); // ── Blueprint ──────────────────────────────────────────────────────────────── @@ -384,20 +396,13 @@ describe("sandbox-policy.schema.json", () => { describe("policy-preset.schema.json", () => { const validate = compileSchema("schemas/policy-preset.schema.json"); - const presetsDir = repoPath("nemoclaw-blueprint/policies/presets"); - - let presetFiles: string[] = []; - try { - presetFiles = readdirSync(presetsDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); - } catch (err) { - const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined; - if (code !== "ENOENT") throw err; - // directory may not exist - } + const presetFiles = + discoverTargets().find((target) => target.schema === "schemas/policy-preset.schema.json") + ?.files ?? []; for (const file of presetFiles) { it(`${file} passes schema validation`, () => { - const data = loadYAML(join(presetsDir, file)); + const data = loadYAML(repoPath(file)); expectValid(validate, data, file); }); } diff --git a/tools/pr-review-advisor/analyze.mts b/tools/pr-review-advisor/analyze.mts index 39cefc05fb5..ac8173cb448 100755 --- a/tools/pr-review-advisor/analyze.mts +++ b/tools/pr-review-advisor/analyze.mts @@ -709,6 +709,7 @@ export function classifyTestDepth( file.endsWith("Dockerfile") || /(^|\/)(install|setup|brev-setup|nemoclaw-start)\.sh$/.test(file) || file.startsWith("nemoclaw-blueprint/policies/") || + (file.startsWith("src/lib/messaging/channels/") && file.includes("/policy/")) || file.startsWith("nemoclaw/src/blueprint/") || file.startsWith("test/e2e/") || file.includes("sandbox") || From 2ab2f4843b19370114e6b27e0204ebd3e99135b1 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 1 Jul 2026 19:44:30 +0700 Subject: [PATCH 02/16] docs(policy): remove messaging policy flow note --- .../messaging-network-policy-flow.md | 1266 ----------------- 1 file changed, 1266 deletions(-) delete mode 100644 src/lib/messaging/messaging-network-policy-flow.md diff --git a/src/lib/messaging/messaging-network-policy-flow.md b/src/lib/messaging/messaging-network-policy-flow.md deleted file mode 100644 index e28481f045f..00000000000 --- a/src/lib/messaging/messaging-network-policy-flow.md +++ /dev/null @@ -1,1266 +0,0 @@ - - - -# Messaging Network Policy Technical Flow - -This note describes how NemoClaw messaging channel manifests interact with OpenShell network policy for OpenClaw and Hermes sandboxes. -It is an internal technical reference for maintainers and contributors. -It is not part of the published user documentation under `docs/`. - -The goal is to make the full lifecycle explicit: - -- Onboarding. -- Channel add. -- Channel remove. -- Channel stop. -- Channel start. -- Rebuild. -- `policy-add`. -- `policy-remove`. -- `policy-list`. -- Status and diagnostics paths that read the same state. - -## Executive Summary - -Messaging channel configuration and network policy are separate but linked state. -The channel manifest declares what the channel needs. -The compiled messaging plan records the desired channel state. -OpenShell network policy decides whether the sandbox can reach the messaging provider. - -The most important invariant is: - -```text -Active channel config must not be built without the policy path that lets the channel reach its upstream API. -Disabled channels must not contribute render output, host forwards, runtime setup, or required policy entries. -Raw channel secrets must not be serialized into plans, registry state, Docker build args, policy files, or agent config. -``` - -The current implementation is manifest-first, but not every policy mutation is plan-applier-first. -The plan contains manifest-derived `networkPolicy.entries`, and `MessagingSetupApplier.applyPolicyAtOpenShell()` can apply those entries. -However, the live channel command paths currently call the generic policy helpers with the channel-named preset directly. -This works today because every built-in messaging channel has a matching built-in preset name. -If a future channel needs `channelId !== presetName`, the channel command paths must stop assuming the canonical channel ID is also the preset name. - -## Primary Source Files - -### Manifest And Plan Contracts - -| File | Responsibility | -|------|----------------| -| `src/lib/messaging/manifest/types.ts` | Defines `ChannelManifest`, `SandboxMessagingPlan`, `SandboxMessagingNetworkPolicyPlan`, hook phases, credential bindings, render entries, build steps, runtime setup, state updates, and workflow names. | -| `src/lib/messaging/manifest/registry.ts` | Provides the channel manifest registry interface. | -| `src/lib/messaging/channels/built-ins.ts` | Registers current built-in channel manifests. | -| `src/lib/messaging/channels/metadata.ts` | Derives legacy metadata from manifests, including policy preset maps, credential metadata, agent policy key aliases, and validation warnings. | -| `src/lib/messaging/compiler/workflow-planner.ts` | Builds workflow-specific plans for onboard, add, remove, stop, start, and rebuild. | -| `src/lib/messaging/compiler/manifest-compiler.ts` | Compiles manifests into serializable plan sections. | -| `src/lib/messaging/compiler/engines/policy-resolver.ts` | Converts manifest `policyPresets` into plan `networkPolicy.entries`. | - -### Host Appliers - -| File | Responsibility | -|------|----------------| -| `src/lib/messaging/applier/setup-applier.ts` | Encodes, decodes, reads, writes, and applies messaging plans. | -| `src/lib/messaging/applier/policy.ts` | Applies active plan policy entries through an injected `applyPresets` callback. | -| `src/lib/messaging/applier/openshell-provider.ts` | Creates, updates, and attaches OpenShell providers for channel credentials. | -| `src/lib/messaging/applier/host-state-applier.ts` | Persists the durable compact messaging plan in the sandbox registry. | -| `src/lib/messaging/applier/plan-filter.ts` | Filters plan entries to active, non-disabled channels. | -| `src/lib/messaging/applier/build/messaging-build-applier.mts` | Applies the messaging plan inside the sandbox image build. | - -### Policy Helpers - -| File | Responsibility | -|------|----------------| -| `src/lib/policy/index.ts` | Loads built-in and custom presets, merges policy YAML, applies and removes presets with `openshell policy set --wait`, and computes `policy-list` gateway matches. | -| `src/lib/onboard/initial-policy.ts` | Builds the initial sandbox create policy, including create-time messaging presets and Hermes inactive-message-policy pruning. | -| `src/lib/onboard/messaging-policy-presets.ts` | Maps selected or disabled channels to policy presets using manifest-derived metadata. | -| `src/lib/onboard/policy-selection.ts` | Merges tier defaults, enabled channel presets, required channel presets, and agent-required presets. | -| `src/lib/onboard/policy-preset-sync.ts` | Reconciles live policy to the target policy preset set by applying and removing presets. | -| `src/lib/onboard/policy-resume-selection.ts` | Reconciles policy selection during resume, including disabled messaging cleanup. | - -### Lifecycle Coordinators - -| File | Responsibility | -|------|----------------| -| `src/lib/onboard/messaging-channel-setup.ts` | Selects channels during onboarding and writes `NEMOCLAW_MESSAGING_PLAN_B64`. | -| `src/lib/onboard/messaging-prep.ts` | Prepares OpenShell provider definitions for sandbox creation. | -| `src/lib/onboard/sandbox-create-plan.ts` | Computes active messaging channels, initial policy, create args, and providers for sandbox creation. | -| `src/lib/onboard/dockerfile-patch.ts` | Injects the encoded messaging plan into the staged Dockerfile build arg. | -| `src/lib/onboard/machine/handlers/policies.ts` | Connects active channels and disabled channels into policy selection and resume handling. | -| `src/lib/actions/sandbox/policy-channel.ts` | Implements `policy-add`, `policy-remove`, `policy-list`, and `channels add/remove/stop/start`. | -| `src/lib/actions/sandbox/rebuild.ts` | Stages messaging plans before rebuild, restores policy presets, reapplies OpenClaw messaging render after doctor, and verifies host forwards. | -| `src/lib/actions/sandbox/channel-status.ts` | Reads channel runtime status and plan state. | -| `src/lib/actions/sandbox/doctor-messaging.ts` | Reads messaging state for diagnostics. | - -### Agent Policy Inputs - -| File | Responsibility | -|------|----------------| -| `nemoclaw-blueprint/policies/openclaw-sandbox.yaml` | Shared OpenClaw baseline sandbox policy. Messaging provider endpoints are not in this baseline. | -| `agents/hermes/policy-additions.yaml` | Hermes baseline policy. Messaging provider endpoints are not in this baseline. | -| `src/lib/messaging/channels//policy/openclaw.yaml` | OpenClaw channel-owned network policy preset YAML for a messaging channel. | -| `src/lib/messaging/channels//policy/hermes.yaml` | Hermes channel-owned network policy preset YAML for a messaging channel. | -| `nemoclaw-blueprint/policies/presets/.yaml` | Built-in operator-facing policy presets for non-messaging integrations. | -| `agents/openclaw/manifest.yaml` | OpenClaw agent manifest. Its legacy policy path points to `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. | -| `agents/hermes/manifest.yaml` | Hermes agent manifest. Its policy additions path is `agents/hermes/policy-additions.yaml`. | - -## Data Model - -### Channel Manifest - -Each built-in channel manifest lives at: - -```text -src/lib/messaging/channels//manifest.ts -``` - -Policy-relevant manifest fields are: - -| Field | Meaning | -|-------|---------| -| `supportedAgents` | Agents that may use this channel. Current built-ins list `openclaw` and `hermes`. Unsupported agents are rejected before policy, provider, registry, or rebuild mutation. | -| `policyPresets` | Operator-facing policy preset declarations needed when the channel is active. | -| `policyPresets[].name` | Preset name users see and pass to `policy-add`. Current built-ins use the channel ID. | -| `policyPresets[].policyKeys` | Concrete `network_policies` keys for the default policy source. | -| `policyPresets[].agentPolicyKeys` | Concrete `network_policies` keys for specific agents. Telegram maps `telegram` to Hermes key `telegram`. | -| `policyPresets[].requiredAtCreate` | Whether onboarding should force the preset into initial create-time policy and effective policy selection. Slack currently sets this. | -| `policyPresets[].validationWarningLines` | Extra warnings shown when a user applies the preset directly. Discord uses this to steer validation away from `curl`. | -| `credentials` | Provider binding declarations. The plan gets placeholders and availability, not raw tokens. | -| `render` | Agent config render entries for OpenClaw and Hermes. These are filtered to active channels. | -| `hostForward` | Inbound webhook port metadata. Teams declares this. This is not a network policy entry. | -| `runtime` | Runtime visibility, env aliasing, preload, and secret-scan metadata. | -| `agentPackages` | Agent package/plugin installs for active channels. | -| `hooks` | Enrollment, pre-enable, reachability, post-install, health, status, and diagnostic hooks. | - -### Sandbox Messaging Plan - -The compiled plan is `SandboxMessagingPlan`. -It is serialized through: - -```text -NEMOCLAW_MESSAGING_PLAN_B64 -``` - -The plan contains: - -| Plan section | Meaning | -|--------------|---------| -| `channels` | Requested channels and whether each is active, selected, configured, or disabled. | -| `disabledChannels` | Channels configured but explicitly stopped. | -| `credentialBindings` | Provider names, env keys, placeholders, availability, and optional non-secret hashes. | -| `networkPolicy` | Manifest-derived preset names and concrete policy keys. | -| `agentRender` | OpenClaw JSON fragments, Hermes env lines, and Hermes YAML fragments. | -| `buildSteps` | Package installs, build args, and build-file hook outputs. | -| `runtimeSetup` | Runtime preloads, env aliases, and secret scans. | -| `stateUpdates` | Persisted non-secret channel state and rebuild hydration metadata. | -| `healthChecks` | Post-rebuild health checks. | - -Plans are JSON-compatible. -They must not contain functions, class instances, or raw secrets. - -### Registry State - -The sandbox registry stores two independent policy-related concepts: - -```text -registry.sandboxes..messaging.plan -registry.sandboxes..policies -registry.sandboxes..customPolicies -``` - -`messaging.plan` is the desired channel state. -`policies` is the list of built-in preset names NemoClaw believes are applied. -`customPolicies` is the list of custom policy presets applied with `policy-add --from-file` or `policy-add --from-dir`. - -These can drift. -For example, an active channel can exist while its policy preset has been manually removed. -Conversely, `policy-add telegram` can open egress without enabling Telegram channel configuration. - -`policy-list` intentionally displays both local registry state and gateway-enforced state so drift is visible. - -## Current Built-In Channel Policy Mapping - -| Channel | Manifest preset name | OpenClaw concrete policy key | Hermes concrete policy key | OpenClaw YAML | Hermes YAML | -|---------|----------------------|------------------------------|----------------------------|---------------|-------------| -| `telegram` | `telegram` | `telegram_bot` | `telegram` | `src/lib/messaging/channels/telegram/policy/openclaw.yaml` | `src/lib/messaging/channels/telegram/policy/hermes.yaml` | -| `discord` | `discord` | `discord` | `discord` | `src/lib/messaging/channels/discord/policy/openclaw.yaml` | `src/lib/messaging/channels/discord/policy/hermes.yaml` | -| `slack` | `slack` | `slack` | `slack` | `src/lib/messaging/channels/slack/policy/openclaw.yaml` | `src/lib/messaging/channels/slack/policy/hermes.yaml` | -| `teams` | `teams` | `teams` | `teams` | `src/lib/messaging/channels/teams/policy/openclaw.yaml` | `src/lib/messaging/channels/teams/policy/hermes.yaml` | -| `wechat` | `wechat` | `wechat_bridge` | `wechat_bridge` | `src/lib/messaging/channels/wechat/policy/openclaw.yaml` | `src/lib/messaging/channels/wechat/policy/hermes.yaml` | -| `whatsapp` | `whatsapp` | `whatsapp` | `whatsapp` | `src/lib/messaging/channels/whatsapp/policy/openclaw.yaml` | `src/lib/messaging/channels/whatsapp/policy/hermes.yaml` | - -Important details: - -- Telegram's built-in preset name is `telegram`, but the OpenClaw concrete policy key is `telegram_bot`. -- Telegram's Hermes concrete policy key is `telegram`, selected through `agentPolicyKeys` and policy key aliases. -- WeChat's preset name is `wechat`, but the concrete policy key is `wechat_bridge`. -- Slack is marked `requiredAtCreate` in its manifest. -- Teams declares both a policy preset and a host forward. - The forward handles inbound Bot Framework webhook traffic and is separate from outbound sandbox egress policy. -- WhatsApp has no host-side token provider. - Pairing state is created inside the sandbox and policy only opens the external WhatsApp Web and media endpoints. - -## Policy Loading And Agent Overrides - -The user-visible preset name is resolved by `src/lib/policy/index.ts`. - -For a built-in preset: - -1. `loadPreset(presetName)` first checks whether the preset is a messaging channel preset. -2. Messaging channel presets resolve from `src/lib/messaging/channels//policy/openclaw.yaml` by default. -3. `loadPresetForSandbox(sandboxName, presetName)` checks the sandbox agent and resolves Hermes messaging presets from `src/lib/messaging/channels//policy/hermes.yaml`. -4. Non-messaging presets still resolve from `nemoclaw-blueprint/policies/presets/.yaml`. -5. Legacy agent policy additions remain a fallback only for non-messaging agent-specific overrides. - -This means `policy-add telegram` is not necessarily the same YAML for OpenClaw and Hermes. -OpenClaw gets the `telegram_bot` entry from `telegram/policy/openclaw.yaml`. -Hermes gets the `telegram` entry from `telegram/policy/hermes.yaml`. - -The agent policy key alias map comes from manifests through: - -```text -getMessagingPolicyKeyAliases() -``` - -This keeps agent override lookup tied to channel manifests instead of hard-coded policy tables. - -## Onboarding Flow - -### 1. Channel Selection - -Entry point: - -```text -src/lib/onboard/messaging-channel-setup.ts -``` - -`setupMessagingChannels()`: - -1. Reads built-in manifests from `createBuiltInChannelManifestRegistry()`. -2. Filters channels through the selected agent's supported channel set. -3. In non-interactive mode, detects channels whose required manifest inputs are complete in env or credential store. -4. In interactive mode, renders a channel selector and seeds already-configured channels. -5. Calls `setupSelectedMessagingChannels()`. - -`setupSelectedMessagingChannels()`: - -1. Normalizes selected channel IDs. -2. Builds an `onboard` plan through `MessagingWorkflowPlanner.buildPlan()`. -3. Runs manifest enrollment hooks when interactive. -4. Writes the plan into `NEMOCLAW_MESSAGING_PLAN_B64`. -5. Deletes inactive selected channels from the enabled set. -6. Prints in-sandbox QR guidance for channels such as WhatsApp. - -At this point, channel configuration is planned. -OpenShell policy is not fully reconciled yet. - -### 2. Conflict And Preflight Checks - -Relevant files: - -```text -src/lib/onboard/sandbox-messaging-preflight.ts -src/lib/onboard/messaging-conflict-guard.ts -``` - -The preflight reads the staged plan and checks for conflicts before sandbox creation. -It respects `disabledChannels`. - -Conflict checks include: - -- Generic credential hash overlap between sandboxes. -- Channel-owned `pre-enable` hooks. -- Slack Socket Mode ownership. -- Teams host-forward port ownership. - -Unsupported agents are blocked earlier through manifest support checks. -DeepAgents-style stale plans are stripped or skipped at action and rebuild boundaries. - -### 3. Provider Preparation - -Entry point: - -```text -src/lib/onboard/messaging-prep.ts -``` - -`prepareCreateSandboxMessaging()`: - -1. Derives token definitions from manifest credential metadata. -2. Filters token definitions to selected channels when a selected-channel list is available. -3. Removes token definitions for disabled channels. -4. Registers additional placeholder providers for available secrets. -5. Detects reusable providers that already exist in OpenShell. -6. Returns `messagingTokenDefs`, reusable provider names, reusable channel names, and disabled channel names. - -Provider records are separate from network policy. -Providers attach secrets to the sandbox through OpenShell. -Policy allows outbound traffic to provider APIs. - -### 4. Active Channel Derivation For Sandbox Create - -Entry point: - -```text -src/lib/onboard/sandbox-create-plan.ts -``` - -`prepareSandboxCreatePlan()` computes `activeMessagingChannels`. -A channel is active for create if it is not disabled and one of these holds: - -- Its primary credential token is available. -- Its provider is reusable. -- It is selected and uses QR or in-sandbox pairing semantics. - -The active channel list feeds: - -- Initial sandbox policy preparation. -- Provider attachment to `openshell sandbox create`. -- Policy selection later in onboarding. - -### 5. Initial Sandbox Create Policy - -Entry point: - -```text -src/lib/onboard/initial-policy.ts -``` - -`prepareInitialSandboxCreatePolicy(basePolicyPath, activeMessagingChannels, options)` builds the policy file passed to: - -```text -openshell sandbox create --policy -``` - -#### OpenClaw - -OpenClaw's baseline is `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. -It does not contain messaging provider endpoints. - -The create-time policy adds: - -- Messaging presets whose manifest sets `requiredAtCreate`. -- Other create-time additions such as some OpenClaw OTEL cases when not suppressed by policy tier. -- Agent or tool gateway additions passed through `additionalPresets`. - -Currently Slack is the messaging preset marked `requiredAtCreate`. -Telegram, Discord, Teams, WeChat, and WhatsApp are generally applied later through the policy selection step or explicit `policy-add`. - -#### Hermes - -Hermes uses `agents/hermes/policy-additions.yaml` as its base policy. -That file contains baseline Hermes entries only. - -Before create, `prepareInitialSandboxCreatePolicy()` treats all active Hermes messaging channel presets as create-time presets and merges them from channel-owned Hermes policy files. -`filterHermesInactiveMessagingPolicies()` remains as compatibility cleanup for older Hermes policy files that still contain embedded messaging templates. -The mapping from channel to Hermes policy keys is still derived from manifests through `getMessagingPolicyKeysByChannel({ agent: "hermes" })`. - -This prevents a Hermes sandbox from getting Telegram, Discord, Slack, Teams, WeChat, or WhatsApp egress merely because the Hermes baseline file exists. - -If an active Hermes channel's policy entry is already present in the filtered base policy, `prepareInitialSandboxCreatePolicy()` records it as already applied. -If an active create-time preset is absent from the base policy, the initial-policy helper merges channel-owned Hermes preset YAML by name. - -Current consequence: - -- Hermes Slack, Telegram, Discord, Teams, WeChat, and WhatsApp use policy files under `src/lib/messaging/channels//policy/hermes.yaml`. -- Hermes baseline policy can change independently from messaging channel egress. - -### 6. Dockerfile Plan Injection - -Entry point: - -```text -src/lib/onboard/dockerfile-patch.ts -``` - -`patchStagedDockerfile()` reads `NEMOCLAW_MESSAGING_PLAN_B64`. -If a plan exists, it hydrates derived plan fields and replaces: - -```text -ARG NEMOCLAW_MESSAGING_PLAN_B64=... -``` - -in the staged Dockerfile. - -If the Dockerfile lacks that arg, patching fails. -This prevents a selected channel from silently disappearing from the image build. - -### 7. Build-Time Applier - -Entry point: - -```text -src/lib/messaging/applier/build/messaging-build-applier.mts -``` - -The build applier reads `NEMOCLAW_MESSAGING_PLAN_B64` and validates that it matches the target agent. -It filters all build work to active, non-disabled channels. - -For OpenClaw, it can: - -- Install declared OpenClaw plugins. -- Run `openclaw doctor --fix` with messaging credential placeholder env overrides. -- Render `openclaw.json` channel and plugin fragments. -- Apply post-agent-install build-file hook outputs. -- Write the reduced runtime plan artifact. - -For Hermes, it can: - -- Render `~/.hermes/.env` lines. -- Render `~/.hermes/config.yaml` fragments. -- Validate trusted Hermes `uv` package specs before root-time installation. -- Write the reduced runtime plan artifact. - -Build-time validation treats `NEMOCLAW_MESSAGING_PLAN_B64` as a derived artifact, not root authority. -Hermes package specs are rechecked against trusted built-in manifests for active channels. - -### 8. Policy Selection - -Entry points: - -```text -src/lib/onboard/machine/handlers/policies.ts -src/lib/onboard/policy-selection.ts -src/lib/onboard/policy-preset-sync.ts -``` - -The policy state handler gathers: - -- Channels selected in the current onboarding run. -- Channels recorded in the onboard session messaging plan. -- Channels active in the sandbox registry messaging plan. -- Channels disabled in the registry plan. - -`mergePolicyMessagingChannels()` merges selected, recorded, and active channels, then excludes disabled channels. - -`setupPoliciesWithSelection()` then computes the target preset set from: - -- Tier defaults. -- Enabled messaging channels. -- Required messaging channel presets. -- Web search configuration. -- Local inference. -- Hermes managed tool gateways. -- Agent-required additions. -- Previously applied presets that should be preserved. - -Disabled messaging channel presets are pruned. -Restricted tier suppression can remove some agent-required presets. - -Important behavior: - -- Open policy tier can include messaging presets even if no channel is configured. - Policy egress is allowed, but no channel bridge exists unless onboarding or `channels add` configured it. -- An operator can remove a non-required channel preset from the policy selector. - The channel config can remain in the image, but its upstream egress will fail until the preset is re-applied. -- Required create-time channel presets are merged back into the effective selection. - -The final target set is reconciled by `syncPresetSelection()`: - -1. Remove applied presets not in the target. -2. Apply newly selected presets. -3. Use `applyPresets()` for batches of built-in presets when possible. -4. Use `applyPreset()` otherwise. -5. Persist the effective set back into the registry when reconciliation touched the live set. - -## Channel Add Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -addSandboxChannel() -``` - -### Add Preconditions - -`channels add ` validates before prompting for tokens or mutating state: - -1. A channel argument is required. -2. The channel manifest must exist. -3. The sandbox agent must support the manifest. -4. The built-in preset YAML named by the canonical channel ID must exist. -5. That preset YAML must contain parseable `network_policies` entries. - -If preset YAML is missing or malformed, the command exits before: - -- Token prompt. -- Provider registration. -- Registry write. -- Policy mutation. -- Rebuild prompt. - -Current gotcha: - -```text -channels add currently validates and applies a preset named after the canonical channel ID. -``` - -This is aligned with current built-ins. -It is not general enough for future channels whose manifest `policyPresets[].name` differs from the channel ID. - -### Add Plan Creation - -`planSandboxChannelAdd()`: - -1. Hydrates non-secret stored config from the onboard session and registry. -2. Builds an `add-channel` plan through `MessagingWorkflowPlanner.buildChannelAddPlanFromSandboxEntry()`. -3. Merges the incoming channel plan with any existing registry plan. -4. Writes the plan to `NEMOCLAW_MESSAGING_PLAN_B64`. - -### Add Conflict Checks - -After planning, `channels add` runs: - -- Generic credential hash conflict checks. -- Channel-owned `pre-enable` hooks. - -Failure behavior: - -- In interactive mode, the user can continue unless the hook failure is not a conflict error. -- In non-interactive mode, conflicts abort unless `--force` is used where supported. -- If the user aborts, no provider, policy, registry, or rebuild mutation has happened yet. - -### Add Active Plan Assertion - -`assertAddChannelPlanActive()` verifies the target channel is active. -If required secret or config inputs are missing, it prints the missing manifest input IDs and exits. - -This matters for host-QR channels such as WeChat. -A cached token without required account metadata is not enough to build an active plan. - -### Add Token Or Host-QR Channels - -Token and host-QR channels currently include: - -- Telegram. -- Discord. -- Slack. -- Microsoft Teams. -- WeChat. - -The flow is: - -1. Collect manifest credentials from env or credential store. -2. Persist acquired channel tokens locally for this run. -3. Register or update OpenShell bridge providers. -4. Apply the channel-named policy preset with `applyChannelPresetIfAvailable()`. -5. Persist the messaging plan to the sandbox registry. -6. Prompt for rebuild. -7. If rebuild runs immediately, verify host forwards and run manifest health checks. - -Policy application happens before plan persistence. -If policy application fails on a fresh add after provider registration, `rollbackChannelAdd()` attempts to: - -- Clear staged channel tokens. -- Detach and delete bridge providers. -- Restore prior local credential state when rotating an existing channel. -- Warn about residual gateway provider state when cleanup cannot be proven. - -This prevents a sandbox from advertising an enabled channel while the policy preset failed to apply. - -### Add In-Sandbox QR Channels - -WhatsApp is the current in-sandbox QR channel. - -The flow is: - -1. Apply the channel-named policy preset. -2. Register no host-side channel provider because there is no host-side token. -3. Persist the active messaging plan. -4. Print pairing guidance. -5. Prompt for rebuild. -6. Pair inside the rebuilt sandbox. - -Policy is applied before plan persistence so the channel is not rebuilt active without upstream egress. - -### Add Rebuild Deferral - -If the operator declines the rebuild: - -- Providers may already be registered. -- The policy preset may already be applied. -- The registry messaging plan records the channel. -- The running sandbox image still has the old channel config until rebuild. - -This is intentional. -The queued state is durable and `rebuild` later applies it to the image. - -## Channel Remove Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -removeSandboxChannel() -``` - -### Remove Preconditions - -The command validates: - -- A channel argument is present. -- The channel exists in the legacy channel facade from `src/lib/sandbox/channels.ts`. - -Removal still uses the compatibility channel facade because provider token keys and QR state helpers predate the manifest-only shape. - -### QR State Cleanup First - -For QR-paired channels that store auth state inside the sandbox, currently WhatsApp, removal starts by clearing durable in-sandbox state. - -This happens before provider, registry, or policy mutation. -If cleanup fails and the channel has residue in registry, policy, session, or live applied presets, the command exits. - -This ordering prevents rebuild backup and restore from preserving an auth blob after the operator asked to remove the channel. - -Cleanup paths are agent-derived: - -- OpenClaw: `/sandbox/.openclaw//` when the agent declares that state dir. -- Hermes: `/sandbox/.hermes/platforms//` when the agent declares `platforms`. - -The cleanup tries OpenShell sandbox exec and falls back to SSH. - -### Provider Teardown - -For token-backed channels, `applyChannelRemoveToGatewayAndRegistry()`: - -1. Ensures the gateway is reachable. -2. Detaches bridge providers from the sandbox. -3. Deletes bridge providers from the gateway. -4. Treats not-found and not-attached as success-equivalent. -5. Fails without updating registry when non-benign detach or delete errors occur. - -Best-effort mode is used only in rollback paths. -Normal remove fails closed so local registry does not say the channel is gone while a gateway bridge is still live. - -### Policy Narrowing - -`removeChannelPresetIfPresent()` removes the channel-named built-in preset when it is applied. - -Behavior: - -- If the built-in preset does not exist, it only syncs the onboard session. -- If the preset is not applied, it only syncs the onboard session. -- If the preset is applied, it calls `policies.removePreset()`. -- Failure prints a warning and manual `policy-remove ` guidance. - -This is best-effort after bridge teardown. -The command does not roll the channel back to enabled just because policy narrowing failed. - -### Plan Persistence And Rebuild - -After provider and policy cleanup: - -1. `persistManifestChannelRemovePlan()` removes the channel from the durable messaging plan. -2. Token-backed channels try best-effort durable state cleanup. -3. NemoClaw prompts for rebuild. - -If rebuild is deferred, the live sandbox image can still contain old channel config until rebuilt. -The bridge provider is already gone and policy is usually narrowed, so the old config should not be able to authenticate or reach the upstream provider. - -## Channel Stop Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -stopSandboxChannel() -``` - -`channels stop ` disables delivery without deleting credentials or channel state. - -The flow is: - -1. Validate the channel argument. -2. Validate the sandbox exists. -3. Validate the channel is configured for the sandbox. -4. No-op if it is already disabled. -5. Persist a plan with the channel in `disabledChannels`. -6. Prompt for rebuild. - -Important policy behavior: - -```text -channels stop does not immediately remove the live policy preset. -``` - -The stop command changes desired state. -It relies on rebuild or later policy resume reconciliation to prune disabled-channel presets. - -Consequences: - -- If the operator defers rebuild, the old running sandbox may still have active channel config and live egress policy. -- If the operator rebuilds, disabled channels are filtered out of render, runtime setup, host forwards, package installs, and restored policy presets. -- If the operator wants immediate live egress narrowing without waiting for rebuild, they must also run `policy-remove `. - -Credentials and QR state remain intact. -This is what lets `channels start` restore the channel without token re-entry or QR re-pairing. - -## Channel Start Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -startSandboxChannel() -``` - -`channels start ` re-enables a configured channel. - -The flow is: - -1. Validate the channel argument. -2. Validate the sandbox exists. -3. Validate the channel is configured for the sandbox. -4. No-op if it is already enabled. -5. Persist a plan with the channel removed from `disabledChannels`. -6. Apply the channel-named policy preset. -7. If policy apply fails, roll the plan back to disabled state and exit. -8. Prompt for rebuild. -9. If rebuild runs immediately, verify needed host forwards. - -Policy application happens before rebuild. -This prevents a channel from being rebuilt active without the matching upstream egress policy. - -If rebuild is deferred: - -- Policy may already be widened. -- The running image may still have the old disabled configuration. -- The next rebuild applies the active channel config. - -## Rebuild Flow - -Entry point: - -```text -src/lib/actions/sandbox/rebuild.ts -``` - -### Plan Staging - -Before destructive work, rebuild calls: - -```text -stageMessagingManifestPlanForRebuild() -``` - -This function: - -1. Loads the target agent. -2. Checks whether the agent can participate in manifest-based messaging. -3. Lists channel IDs supported by the agent. -4. Hydrates the persisted registry plan. -5. Filters unsupported channels. -6. Refreshes derived fields such as runtime setup and host forward metadata. -7. Writes the `rebuild` plan to `NEMOCLAW_MESSAGING_PLAN_B64`. - -If the agent is not supported by any channel manifest, rebuild clears the messaging env plan and skips. -If a stored plan is invalid or cannot be staged, rebuild fails before backup or deletion. - -### Recreate - -Rebuild deletes and recreates the sandbox through `onboard --resume`. -Before calling onboard, it pins the session to: - -- The target sandbox name. -- The target agent. -- The staged messaging plan. -- The original inference provider, model, credential env, endpoint, and Hermes tool gateways. - -The resumed onboarding flow injects the plan into the new image exactly like initial onboarding. - -### Policy Restore - -After recreate and state restore, rebuild restores policy presets. - -Relevant helper: - -```text -mergeRebuildMessagingPolicyPresets() -``` - -Inputs: - -- Backup manifest policy presets when a backup exists. -- Registry policy presets as fallback for stale-sandbox recovery. -- Enabled channel IDs from the staged rebuild plan. -- Disabled channel IDs from the staged rebuild plan. - -Behavior: - -1. Start from backup policy presets or registry policy presets. -2. Prune presets that belong to disabled messaging channels. -3. Add presets that belong to enabled messaging channels. -4. Apply each preset. -5. Track restored and failed preset names. -6. Update the registry `policies` field with only successfully restored presets. - -This means `policy-list` should not show a local applied marker for a preset that rebuild failed to restore. - -### OpenClaw Doctor Reapply - -OpenClaw rebuild runs `openclaw doctor --fix` after state restore. -Doctor can rewrite `openclaw.json`. - -To keep messaging config intact, rebuild calls: - -```text -reapplyMessagingManifestAfterOpenClawDoctor() -``` - -This reapplies manifest-owned render and post-agent-install hook outputs after doctor. -It is OpenClaw-specific. -Hermes does not have an equivalent post-doctor rewrite step. - -### Host Forward Verification - -After rebuild, `ensureMessagingHostForwardAfterRebuild()` verifies host forwards needed by active channels. -Teams depends on this because inbound Bot Framework traffic reaches the sandbox through the configured local webhook port. - -Host forwards are not OpenShell egress policy. -They are separate host-side routing state. - -## `policy-add` Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -addSandboxPolicy() -``` - -Modes: - -- Built-in preset by name. -- Custom preset from `--from-file`. -- Custom presets from `--from-dir`. -- Interactive preset picker. - -### Built-In Preset Add - -For a built-in preset: - -1. Validate the preset name against `policies.listPresets()` after agent filtering. -2. Refuse if the preset is already recorded as applied. -3. Load preset content with `policies.loadPreset()`. -4. Print endpoint preview. -5. Print preset validation warnings. -6. Confirm unless `--yes`, `--force`, or non-interactive mode skips confirmation. -7. Apply the preset through `policies.applyPreset()`. -8. Sync onboard session policy presets. -9. Refresh the sandbox policy context file. - -`policies.applyPreset()`: - -1. Calls `loadPresetForSandbox()`. -2. Resolves an agent-specific policy document when available. -3. Reads current gateway policy with `openshell policy get --full`. -4. Merges the preset's `network_policies` entries into that YAML. -5. Writes a temporary policy file. -6. Calls `openshell policy set --policy --wait `. -7. Records the preset name in the registry. - -### Messaging Preset Warning - -For messaging presets, `getPresetValidationWarning()` uses manifest-derived metadata to warn: - -```text -The preset only opens network egress. -It does not enable channel setup, pairing, or runtime configuration. -``` - -The warning can include channel-specific notes from `validationWarningLines`. - -### Custom Preset Add - -Custom presets: - -1. Must be YAML files. -2. Must declare `preset.name`. -3. Must declare a `network_policies` mapping. -4. Must not collide with a built-in preset name. -5. Are persisted under `registry.customPolicies`. - -Custom presets do not interact with channel manifests. -They can open equivalent endpoints, but they do not configure channel credentials, render agent config, or create messaging plan entries. - -## `policy-remove` Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -removeSandboxPolicy() -``` - -The command: - -1. Selects a built-in or custom preset that is recorded as applied. -2. Resolves preset content from built-in files or registry custom policies. -3. Prints endpoint removal preview. -4. Confirms unless confirmation is skipped. -5. Calls `policies.removePreset()`. -6. Syncs onboard session policy presets. -7. Refreshes the sandbox policy context file. - -`policies.removePreset()`: - -1. Resolves agent-specific preset content when applicable. -2. Reads the current gateway policy. -3. Removes all concrete `network_policies` keys declared by the preset. -4. Calls `openshell policy set --policy --wait `. -5. Removes the built-in preset name from registry `policies` or the custom preset from `customPolicies`. - -`policy-remove ` does not edit `messaging.plan`. -If the channel is still active, it remains configured but cannot reach its upstream API until policy is restored. - -## `policy-list` Flow - -Entry point: - -```text -src/lib/actions/sandbox/policy-channel.ts -listSandboxPolicies() -``` - -The command lists: - -- Built-in non-messaging presets from `nemoclaw-blueprint/policies/presets/`. -- Built-in messaging presets from `src/lib/messaging/channels//policy/.yaml`. -- Sandbox-scoped custom presets from the registry. - -For each preset, it computes: - -- `inRegistry`: whether the preset name is recorded in registry `policies` or `customPolicies`. -- `inGateway`: whether the current OpenShell gateway policy contains all concrete policy keys for that preset. - -`inGateway` is computed by: - -1. Running `openshell policy get --full `. -2. Parsing the returned policy YAML. -3. Listing current `network_policies` keys. -4. Matching those keys against built-in and custom preset definitions. -5. Using agent-specific preset content for agent sandboxes when available. - -If the gateway cannot be reached or the policy cannot be parsed, `getGatewayPresets()` returns `null`. -The display then shows local state only and warns. - -`policy-list` does not infer desired channel state from manifests. -It only compares known preset definitions against local registry and gateway policy. - -## Status And Doctor Flows - -Status and doctor paths read the same plan and runtime artifacts, but they should not mutate policy. - -Relevant files: - -```text -src/lib/actions/sandbox/channel-status.ts -src/lib/actions/sandbox/doctor-messaging.ts -src/lib/messaging/diagnostics.ts -src/lib/channel-runtime-status.ts -``` - -These flows can show: - -- Configured channels. -- Active channels. -- Disabled channels. -- Runtime visibility. -- Slack Socket Mode overlaps. -- Missing bridge startup signals. -- Missing runtime artifacts. -- Gateway or host-forward issues. - -They should not be used as a source of truth for applying network policy. -They are diagnostic consumers of registry, plan, runtime, and gateway state. - -## OpenClaw And Hermes Differences - -### OpenClaw - -OpenClaw channel render targets include: - -- `openclaw.json` channel blocks. -- `openclaw.json` plugin entries. -- OpenClaw plugin installs through `openclaw plugins install`. -- OpenClaw runtime preloads. -- OpenClaw runtime secret scans. -- Post-doctor render reapply after rebuild. - -OpenClaw policy behavior: - -- Baseline policy is `nemoclaw-blueprint/policies/openclaw-sandbox.yaml`. -- Messaging endpoints are not baseline. -- Built-in messaging presets generally apply from `nemoclaw-blueprint/policies/presets`. -- Telegram uses concrete key `telegram_bot`. -- WeChat uses concrete key `wechat_bridge`. - -### Hermes - -Hermes channel render targets include: - -- `~/.hermes/.env` env lines. -- `~/.hermes/config.yaml` platform sections. -- Hermes runtime env aliases when declared. -- Hermes package installs only when a trusted active manifest declares a pinned `hermes-uv-pip` package. - -Hermes policy behavior: - -- Baseline policy is `agents/hermes/policy-additions.yaml`. -- That file includes messaging templates for several channels. -- Inactive Hermes messaging entries are removed before sandbox create. -- `policy-add` and `policy-list` resolve agent-specific entries before built-in fallback. -- Telegram uses concrete key `telegram`. -- Slack, Discord, Teams, and WeChat use Hermes-specific entries when available. -- WhatsApp currently falls back to the built-in policy preset. - -### Unsupported Agents - -The manifest `supportedAgents` list is authoritative. -Agents that are not supported by any channel manifest should not receive messaging config, providers, policy mutations, or stale plan rebuilds. - -The unsupported-agent boundary is enforced in: - -- Channel listing. -- Channel add. -- Planner supported-channel checks. -- Rebuild plan staging. -- Onboard stale-plan cleanup. - -## Failure Boundaries And Rollback Rules - -### Fail Before Mutation - -These failures happen before policy, provider, registry, or rebuild mutation: - -- Unknown channel. -- Channel unsupported by sandbox agent. -- Missing or malformed channel-named preset YAML on `channels add`. -- Missing required inputs before active add. -- User aborts a conflict check. -- Non-interactive conflict without `--force`. -- Rebuild messaging plan cannot be staged. - -### Fail Closed After Provider Mutation - -`channels add` can register providers before policy apply. -If policy apply then fails, rollback attempts to remove or restore provider and credential state. - -Fresh add rollback tries to clean: - -- Channel tokens. -- Gateway provider attachments. -- Gateway providers. -- Registry state. - -Existing channel token rotation rollback restores: - -- Prior local credentials. -- Prior registry plan where possible. -- Prior gateway provider values on a best-effort basis. - -Residual gateway provider state is explicitly warned. - -### Fail Closed Before QR State Loss - -`channels remove` for in-sandbox QR channels clears durable in-sandbox auth state before registry and policy mutation. -If that cleanup cannot be confirmed, the command exits and leaves registry and policy untouched. - -### Best-Effort Narrowing After Bridge Removal - -Policy removal during `channels remove` is best-effort once bridge teardown has succeeded. -If policy narrowing fails, the bridge is gone but egress might remain. -The command prints manual `policy-remove ` guidance. - -### Start Rollback - -`channels start` applies policy after enabling the plan. -If policy apply fails, it attempts to put the plan back into disabled state. - -This prevents rebuild from later making the channel active without egress. - -## Common Drift States - -### Active Channel But Missing Policy - -How it happens: - -- Operator runs `policy-remove `. -- Policy restore fails during rebuild. -- Custom policy replacement removes the concrete keys. - -Effect: - -- Agent config and providers can exist. -- Channel traffic is denied by OpenShell. -- `policy-list` should show the preset missing from gateway. - -Recovery: - -```bash -nemoclaw policy-add --yes -``` - -### Policy Applied But Channel Not Configured - -How it happens: - -- Operator runs `policy-add `. -- Open tier applies messaging presets. -- Custom policy contains equivalent endpoints. - -Effect: - -- Sandbox can reach provider API endpoints. -- No bridge is configured unless onboarding or `channels add` created one. - -Recovery: - -```bash -nemoclaw channels add -nemoclaw rebuild -``` - -### Disabled Channel With Live Policy Still Applied - -How it happens: - -- Operator runs `channels stop ` and defers rebuild. - -Effect: - -- Desired plan says disabled. -- Live sandbox and live policy may still reflect the old active state. - -Recovery: - -```bash -nemoclaw rebuild -``` - -Optional immediate narrowing: - -```bash -nemoclaw policy-remove --yes -``` - -### Registry Says Applied But Gateway Unknown - -How it happens: - -- Gateway unreachable during `policy-list`. -- OpenShell policy query fails. - -Effect: - -- `policy-list` shows local state only. -- It cannot prove enforcement. - -Recovery: - -```bash -openshell gateway start --name -nemoclaw policy-list -``` - -## Contributor Checklist For Channel Policy Changes - -When adding or changing a channel policy: - -1. Update the channel manifest first. -2. Keep `supportedAgents` precise. -3. Add or update `policyPresets`. -4. Add or update `src/lib/messaging/channels//policy/openclaw.yaml`. -5. Add or update `src/lib/messaging/channels//policy/hermes.yaml` when Hermes supports the channel. -6. Keep manifest `policyKeys` and `agentPolicyKeys` aligned with the actual YAML keys. -7. Decide whether `requiredAtCreate` is truly needed. -8. Add `validationWarningLines` when direct `policy-add` validation has a known trap. -9. Add or update template resolvers only for derived render values. -10. Add hooks only for side effects or checks that static manifest data cannot express. -11. Update build-time trusted manifest registration if the build applier still uses a static trusted list. -12. Test manifest metadata and plan compilation. -13. Test channel add for preset validation and rollback. -14. Test channel remove for provider, policy, and QR state cleanup when applicable. -15. Test stop/start policy behavior. -16. Test rebuild policy restoration, including disabled-channel pruning. -17. Test Hermes agent-specific policy resolution if the channel supports Hermes. -18. Test `policy-list` gateway matching when concrete keys differ from the preset name. - -## Known Design Gaps - -### Channel Command Preset Name Assumption - -`channels add`, `channels start`, and `channels remove` currently apply or remove the channel-named preset directly. -They do not consume the compiled plan's `networkPolicy.entries`. - -Current built-ins are safe because channel ID and preset name match. -A future channel with a different preset name would need a shared helper that reads manifest policy metadata instead of assuming `channelId === presetName`. - -### Scattered Registration - -The manifest contract is strong, but registration remains split across: - -- Built-in manifests. -- Hook registry. -- Template resolver registry. -- Metadata facades. -- Build-time trusted manifest list. -- Legacy `src/lib/sandbox/channels.ts`. - -A future catalog layer should centralize these surfaces. - -### Policy Is Not Solely Manifest-Driven - -Open policy tier and manual `policy-add` can apply messaging presets independent of active channels. -This is intentional, but it means "preset applied" is not equivalent to "channel configured". - -### Stop Does Not Immediately Narrow Live Egress - -`channels stop` persists desired disabled state and relies on rebuild or policy reconciliation to narrow policy. -This preserves credentials and state for later start, but it is not an immediate firewall operation. - -If immediate egress closure is required, pair `channels stop` with `policy-remove`. - -## Minimal Mental Model - -Use this model when debugging: - -```text -Manifest - declares channel needs, including policy preset names and concrete keys - -Planner - compiles manifests plus current state into SandboxMessagingPlan - -Registry - stores desired channel state and recorded policy preset names - -Docker build applier - turns active plan entries into agent config, packages, runtime setup, and runtime artifacts - -OpenShell providers - carry secrets into the sandbox as placeholders - -OpenShell policy - decides whether the sandbox can reach upstream messaging hosts - -Channel commands - mutate desired channel state, providers, and sometimes live policy - -Policy commands - mutate live policy and registry policy names, but not channel config - -Rebuild - reconciles desired channel state into a fresh image and restores policy to match enabled channels -``` - -When behavior is confusing, inspect these in order: - -1. Manifest support and `policyPresets`. -2. Registry `messaging.plan`. -3. Registry `policies` and `customPolicies`. -4. Gateway policy from `openshell policy get --full`. -5. Agent config in `/sandbox/.openclaw` or `/sandbox/.hermes`. -6. Reduced runtime plan artifact. -7. OpenShell providers. -8. Host forwards for Teams. From 64b06a3fb30f02d9f59ba9fba6754bb561164412 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 1 Jul 2026 19:55:06 +0700 Subject: [PATCH 03/16] test(policy): avoid conditional in channel policy test --- src/lib/messaging/channels/policy.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/messaging/channels/policy.test.ts b/src/lib/messaging/channels/policy.test.ts index a42ebd59e7d..ba7cea4c51e 100644 --- a/src/lib/messaging/channels/policy.test.ts +++ b/src/lib/messaging/channels/policy.test.ts @@ -46,15 +46,15 @@ describe("messaging channel policy presets", () => { }); it("ships a policy file for every manifest-supported agent and preset", () => { - const missing: string[] = []; - for (const manifest of listBuiltInMessagingChannelManifests()) { - for (const agent of manifest.supportedAgents) { - for (const preset of listMessagingPolicyPresetMetadata({ manifests: [manifest], agent })) { - const resolved = resolveMessagingChannelPolicyPresetPath(preset.presetName, agent); - if (!resolved) missing.push(`${manifest.id}/${agent}/${preset.presetName}`); - } - } - } + const missing = listBuiltInMessagingChannelManifests().flatMap((manifest) => + manifest.supportedAgents.flatMap((agent) => + listMessagingPolicyPresetMetadata({ manifests: [manifest], agent }).flatMap((preset) => + resolveMessagingChannelPolicyPresetPath(preset.presetName, agent) + ? [] + : [`${manifest.id}/${agent}/${preset.presetName}`], + ), + ), + ); expect(missing).toEqual([]); }); }); From 92d42846d202de312dfb4ed8f61c110c07d444e0 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 1 Jul 2026 20:09:24 +0700 Subject: [PATCH 04/16] fix(policy): harden channel preset agent resolution --- package.json | 2 +- src/lib/messaging/channels/policy.test.ts | 15 +++++++ src/lib/messaging/channels/policy.ts | 12 +++-- test/channels-add-preset.test.ts | 10 ++--- .../cli/policy-dispatch.test.ts | 30 +++++++++++++ test/policy-channel-agent-resolution.test.ts | 45 +++++++++++++++++++ test/policy-channel-yaml-contract.test.ts | 28 +++++++++--- 7 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 test/policy-channel-agent-resolution.test.ts diff --git a/package.json b/package.json index 17996f48da1..4d2c17d2565 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ ".version", "bin/", "dist/", - "src/lib/messaging/channels/**/policy/*.yaml", + "src/lib/messaging/channels/**/policy/*.{yaml,yml}", "nemoclaw/dist/", "nemoclaw/openclaw.plugin.json", "nemoclaw/package.json", diff --git a/src/lib/messaging/channels/policy.test.ts b/src/lib/messaging/channels/policy.test.ts index ba7cea4c51e..e7c166e38ac 100644 --- a/src/lib/messaging/channels/policy.test.ts +++ b/src/lib/messaging/channels/policy.test.ts @@ -45,6 +45,21 @@ describe("messaging channel policy presets", () => { ); }); + it("does not fall back to OpenClaw policies for unsupported agents", () => { + expect( + loadMessagingChannelPolicyPreset("telegram", { agent: "langchain-deepagents-code" }), + ).toBeNull(); + expect( + resolveMessagingChannelPolicyPresetPath("telegram", "langchain-deepagents-code"), + ).toBeNull(); + expect(listMessagingChannelPolicyPresets({ agent: "langchain-deepagents-code" })).toEqual([]); + }); + + it("returns null for unknown channel policy presets", () => { + expect(loadMessagingChannelPolicyPreset("nonexistent", { agent: "hermes" })).toBeNull(); + expect(resolveMessagingChannelPolicyPresetPath("nonexistent", "hermes")).toBeNull(); + }); + it("ships a policy file for every manifest-supported agent and preset", () => { const missing = listBuiltInMessagingChannelManifests().flatMap((manifest) => manifest.supportedAgents.flatMap((agent) => diff --git a/src/lib/messaging/channels/policy.ts b/src/lib/messaging/channels/policy.ts index d508c61e40f..f8c0bb92fa3 100644 --- a/src/lib/messaging/channels/policy.ts +++ b/src/lib/messaging/channels/policy.ts @@ -23,8 +23,12 @@ export interface MessagingChannelPolicyPresetInfo { readonly agent: MessagingAgentId; } -function normalizeAgent(agent: MessagingAgentId | string | null | undefined): MessagingAgentId { - return agent === "hermes" ? "hermes" : "openclaw"; +function normalizeAgent( + agent: MessagingAgentId | string | null | undefined, +): MessagingAgentId | null { + if (agent == null) return "openclaw"; + if (agent === "openclaw" || agent === "hermes") return agent; + return null; } function isSafeId(value: string): boolean { @@ -70,7 +74,8 @@ export function resolveMessagingChannelPolicyPresetPath( agent: MessagingAgentId | string | null | undefined = "openclaw", ): string | null { const normalizedAgent = normalizeAgent(agent); - for (const preset of listMessagingPolicyPresetMetadata()) { + if (!normalizedAgent) return null; + for (const preset of listMessagingPolicyPresetMetadata({ agent: normalizedAgent })) { if (preset.presetName !== presetName) continue; const file = channelPolicyPath(preset.channelId, normalizedAgent); if (file && fs.existsSync(file)) return file; @@ -93,6 +98,7 @@ export function listMessagingChannelPolicyPresets( options: { readonly agent?: MessagingAgentId | string | null } = {}, ): MessagingChannelPolicyPresetInfo[] { const agent = normalizeAgent(options.agent); + if (!agent) return []; const result: MessagingChannelPolicyPresetInfo[] = []; const seen = new Set(); for (const preset of listMessagingPolicyPresetMetadata({ agent })) { diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 419015e46e7..05e0e57910e 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -176,12 +176,14 @@ const appliedCalls = []; const removedCalls = []; const callOrder = []; policies.listPresets = () => ${JSON.stringify(presetNamesAvailable.map((name) => ({ name })))}; -policies.loadPreset = (name) => { +function stubPresetContent(name) { if (${JSON.stringify(presetFileMissing)}) return null; if (${JSON.stringify(presetMissingNetworkPolicies)}) return "name: " + name + "\ndescription: \"stub preset without network_policies\"\n"; if (${JSON.stringify(presetMalformedYaml)}) return "network_policies:\n - [unclosed\n"; return "network_policies:\n " + name + ":\n egress:\n - host: example.com"; -}; policies.loadPresetForSandbox = (_sandboxName, name) => policies.loadPreset(name); +} +policies.loadPreset = (name) => stubPresetContent(name); +policies.loadPresetForSandbox = (sandboxName, name) => { callOrder.push("loadPresetForSandbox:" + sandboxName + ":" + name); return stubPresetContent(name); }; policies.applyPreset = (sandboxName, presetName) => { appliedCalls.push({ sandboxName, presetName }); callOrder.push("applyPreset:" + presetName); @@ -339,10 +341,8 @@ const ctx = module.exports; [{ sandboxName: "test-sb", presetName: channel }], `expected applyPreset("test-sb", "${channel}") exactly once; got ${JSON.stringify(payload.appliedCalls)}`, ); + assert.ok(payload.callOrder.includes(`loadPresetForSandbox:test-sb:${channel}`)); - // Contract 2: ordering invariant — preset apply must precede rebuild, - // otherwise the rebuild's backup manifest will not capture it and - // Step 5.5 of rebuild.ts has nothing to restore. const applyIdx = payload.callOrder.indexOf(`applyPreset:${channel}`); const rebuildIdx = payload.callOrder.indexOf("promptAndRebuild"); assert.ok( diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 5146dc0a3cf..2e9e4101f34 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -3,10 +3,12 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +const requireForTest = createRequire(import.meta.url); const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "nemoclaw.js")); const CREDENTIALS_PATH = JSON.stringify( @@ -14,6 +16,7 @@ const CREDENTIALS_PATH = JSON.stringify( ); const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "policy", "index.js")); const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "state", "registry.js")); +const YAML_PATH = JSON.stringify(requireForTest.resolve("yaml")); type PolicyCall = { type: string; @@ -24,6 +27,33 @@ type PolicyCall = { }; describe("compiled CLI policy contracts", () => { + it("loads channel-owned messaging YAML from the packaged source layout", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-packaged-channel-")); + const scriptPath = path.join(tmpDir, "packaged-channel-policy-check.js"); + const script = String.raw` +const YAML = require(${YAML_PATH}); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "hermes-contract", agent: "hermes", policies: [] }); +const openclaw = YAML.parse(policies.loadPreset("telegram")); +const hermes = YAML.parse(policies.loadPresetForSandbox("hermes-contract", "telegram")); +process.stdout.write("__RESULT__" + JSON.stringify({ + openclawKeys: Object.keys(openclaw.network_policies || {}), + hermesKeys: Object.keys(hermes.network_policies || {}), +})); +`; + fs.writeFileSync(scriptPath, script); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir }, + }); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); + expect(payload.openclawKeys).toEqual(["telegram_bot"]); + expect(payload.hermesKeys).toEqual(["telegram"]); + }); + describe("policy-remove custom presets", () => { function runPolicyRemoveCustom( presetName: string, diff --git a/test/policy-channel-agent-resolution.test.ts b/test/policy-channel-agent-resolution.test.ts new file mode 100644 index 00000000000..2cad0b92574 --- /dev/null +++ b/test/policy-channel-agent-resolution.test.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "policy", "index.ts")); +const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "state", "registry.ts")); +const SOURCE_NODE_ARGS = ["--import", "tsx"]; + +describe("sandbox-aware messaging policy resolution", () => { + it("does not use OpenClaw channel policies for unsupported sandbox agents", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-agent-resolution-")); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ + name: "deepagents-sandbox", + agent: "langchain-deepagents-code", + policies: [], +}); +const channelPreset = policies.loadPresetForSandbox("deepagents-sandbox", "telegram"); +const centralPreset = policies.loadPresetForSandbox("deepagents-sandbox", "npm"); +process.stdout.write("__RESULT__" + JSON.stringify({ + channelPreset, + centralPresetHasNpmPolicy: String(centralPreset).includes("npm_yarn:"), +})); +`; + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir }, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); + expect(payload.channelPreset).toBeNull(); + expect(payload.centralPresetHasNpmPolicy).toBe(true); + }); +}); diff --git a/test/policy-channel-yaml-contract.test.ts b/test/policy-channel-yaml-contract.test.ts index 328e89344e7..fe9207204b8 100644 --- a/test/policy-channel-yaml-contract.test.ts +++ b/test/policy-channel-yaml-contract.test.ts @@ -36,6 +36,11 @@ function allEndpoints(policy: Record): Endpoint[] { ).flatMap((entry) => entry.endpoints ?? []); } +function requireNonEmpty(items: T[], label: string): T[] { + expect(items[0], label).toBeDefined(); + return items; +} + function expectInspectedWebSocket(endpoint: Endpoint | undefined): void { expect(endpoint).toBeTruthy(); expect(endpoint).toMatchObject({ @@ -69,10 +74,12 @@ describe("channel-owned messaging policy YAML", () => { ), ]; const slackRestHosts = new Set(["slack.com", "api.slack.com", "hooks.slack.com"]); + const slackRestEndpoints = requireNonEmpty( + sources.flatMap(allEndpoints).filter((entry) => slackRestHosts.has(entry.host ?? "")), + "expected Slack REST endpoints in channel and permissive policies", + ); - for (const endpoint of sources - .flatMap(allEndpoints) - .filter((entry) => slackRestHosts.has(entry.host ?? ""))) { + for (const endpoint of slackRestEndpoints) { expect(endpoint).toMatchObject({ protocol: "rest", request_body_credential_rewrite: true, @@ -113,10 +120,19 @@ describe("channel-owned messaging policy YAML", () => { const sortRules = (rules: Array<{ method: string; path: string }>) => [...rules].sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)); - const nousRules = rulesFor("nous_research", "nousresearch.com"); - expect(nousRules.filter((rule) => ["PUT", "PATCH", "DELETE"].includes(rule.method))).toEqual( - [], + const discordEndpoints = requireNonEmpty( + networkPolicies.discord?.endpoints ?? [], + "expected Hermes Discord endpoints", ); + const nonDiscordMutationRules = discordEndpoints + .filter((endpoint) => endpoint.host !== "discord.com") + .flatMap((endpoint) => endpoint.rules ?? []) + .map((rule) => rule.allow) + .filter((rule): rule is { method: string; path: string } => + Boolean(rule?.method && rule?.path), + ) + .filter((rule) => ["PUT", "PATCH", "DELETE"].includes(rule.method)); + expect(nonDiscordMutationRules).toEqual([]); const discordMutationRules = sortRules( rulesFor("discord", "discord.com").filter((rule) => From 05c44ac7340fc4fed8b5830c37b9b2f4d9f82747 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 1 Jul 2026 20:34:25 +0700 Subject: [PATCH 05/16] test(policy): cover channel preset negative paths --- src/lib/messaging/channels/policy.test.ts | 127 +++++++++++++++---- src/lib/messaging/channels/policy.ts | 12 +- test/policy-channel-agent-resolution.test.ts | 2 +- 3 files changed, 113 insertions(+), 28 deletions(-) diff --git a/src/lib/messaging/channels/policy.test.ts b/src/lib/messaging/channels/policy.test.ts index e7c166e38ac..c94f690f141 100644 --- a/src/lib/messaging/channels/policy.test.ts +++ b/src/lib/messaging/channels/policy.test.ts @@ -1,18 +1,58 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import { listBuiltInMessagingChannelManifests, listMessagingPolicyPresetMetadata, } from "./metadata"; -import { - listMessagingChannelPolicyPresets, - loadMessagingChannelPolicyPreset, - resolveMessagingChannelPolicyPresetPath, -} from "./policy"; + +type PolicyFixture = { + readonly channelId: string; + readonly presetName: string; +}; + +function fixtureContentFor( + file: string, + filesByChannel: Readonly>, +): string | null { + const normalized = file.replaceAll("\\", "/"); + return ( + Object.entries(filesByChannel).find(([channelId]) => + normalized.endsWith(`/src/lib/messaging/channels/${channelId}/policy/openclaw.yaml`), + )?.[1] ?? null + ); +} + +async function importPolicy(): Promise { + vi.resetModules(); + return import("./policy"); +} + +async function importPolicyWithFixtures( + presets: readonly PolicyFixture[], + filesByChannel: Readonly> = {}, +): Promise { + vi.resetModules(); + vi.doMock("./metadata", () => ({ + listMessagingPolicyPresetMetadata: vi.fn(() => presets), + })); + vi.doMock("node:fs", () => ({ + default: { + existsSync: vi.fn((file: string) => fixtureContentFor(file, filesByChannel) !== null), + readFileSync: vi.fn((file: string) => fixtureContentFor(file, filesByChannel) ?? ""), + }, + })); + return import("./policy"); +} + +afterEach(() => { + vi.doUnmock("./metadata"); + vi.doUnmock("node:fs"); + vi.resetModules(); +}); function policyKeys(content: string | null): string[] { expect(content).toBeTruthy(); @@ -21,17 +61,19 @@ function policyKeys(content: string | null): string[] { } describe("messaging channel policy presets", () => { - it("loads OpenClaw and Hermes channel-specific Telegram policy keys", () => { - expect(policyKeys(loadMessagingChannelPolicyPreset("telegram", { agent: "openclaw" }))).toEqual( - ["telegram_bot"], - ); - expect(policyKeys(loadMessagingChannelPolicyPreset("telegram", { agent: "hermes" }))).toEqual([ - "telegram", - ]); + it("loads OpenClaw and Hermes channel-specific Telegram policy keys", async () => { + const policy = await importPolicy(); + expect( + policyKeys(policy.loadMessagingChannelPolicyPreset("telegram", { agent: "openclaw" })), + ).toEqual(["telegram_bot"]); + expect( + policyKeys(policy.loadMessagingChannelPolicyPreset("telegram", { agent: "hermes" })), + ).toEqual(["telegram"]); }); - it("lists operator-facing preset names from channel-owned policy files", () => { - const presets = listMessagingChannelPolicyPresets(); + it("lists operator-facing preset names from channel-owned policy files", async () => { + const policy = await importPolicy(); + const presets = policy.listMessagingChannelPolicyPresets(); expect(presets.map((preset) => preset.name).sort()).toEqual([ "discord", "slack", @@ -45,26 +87,63 @@ describe("messaging channel policy presets", () => { ); }); - it("does not fall back to OpenClaw policies for unsupported agents", () => { + it("does not fall back to OpenClaw policies for unsupported agents", async () => { + const policy = await importPolicy(); expect( - loadMessagingChannelPolicyPreset("telegram", { agent: "langchain-deepagents-code" }), + policy.loadMessagingChannelPolicyPreset("telegram", { + agent: "langchain-deepagents-code", + }), ).toBeNull(); expect( - resolveMessagingChannelPolicyPresetPath("telegram", "langchain-deepagents-code"), + policy.resolveMessagingChannelPolicyPresetPath("telegram", "langchain-deepagents-code"), ).toBeNull(); - expect(listMessagingChannelPolicyPresets({ agent: "langchain-deepagents-code" })).toEqual([]); + expect( + policy.listMessagingChannelPolicyPresets({ agent: "langchain-deepagents-code" }), + ).toEqual([]); + }); + + it("returns null for unknown channel policy presets", async () => { + const policy = await importPolicy(); + expect(policy.loadMessagingChannelPolicyPreset("nonexistent", { agent: "hermes" })).toBeNull(); + expect(policy.resolveMessagingChannelPolicyPresetPath("nonexistent", "hermes")).toBeNull(); + }); + + it("rejects path traversal channel ids from preset metadata", async () => { + const policy = await importPolicyWithFixtures([ + { channelId: "../telegram", presetName: "telegram" }, + ]); + expect(policy.resolveMessagingChannelPolicyPresetPath("telegram")).toBeNull(); + expect(policy.loadMessagingChannelPolicyPreset("telegram")).toBeNull(); + }); + + it("returns null when channel policy files are missing", async () => { + const policy = await importPolicyWithFixtures([{ channelId: "missing", presetName: "slack" }]); + expect(policy.resolveMessagingChannelPolicyPresetPath("slack")).toBeNull(); + expect(policy.loadMessagingChannelPolicyPreset("slack")).toBeNull(); + }); + + it("skips channel policy files whose preset header has the wrong name", async () => { + const policy = await importPolicyWithFixtures([{ channelId: "slack", presetName: "slack" }], { + slack: "preset:\n name: discord\nnetwork_policies:\n discord: {}\n", + }); + expect(policy.loadMessagingChannelPolicyPreset("slack")).toBeNull(); + expect(policy.listMessagingChannelPolicyPresets()).toEqual([]); }); - it("returns null for unknown channel policy presets", () => { - expect(loadMessagingChannelPolicyPreset("nonexistent", { agent: "hermes" })).toBeNull(); - expect(resolveMessagingChannelPolicyPresetPath("nonexistent", "hermes")).toBeNull(); + it("returns null for malformed channel policy YAML", async () => { + const policy = await importPolicyWithFixtures([{ channelId: "slack", presetName: "slack" }], { + slack: "preset:\n name: [\nnetwork_policies:\n slack: {}\n", + }); + expect(policy.loadMessagingChannelPolicyPreset("slack")).toBeNull(); + expect(policy.listMessagingChannelPolicyPresets()).toEqual([]); }); - it("ships a policy file for every manifest-supported agent and preset", () => { + it("ships a policy file for every manifest-supported agent and preset", async () => { + const policy = await importPolicy(); const missing = listBuiltInMessagingChannelManifests().flatMap((manifest) => manifest.supportedAgents.flatMap((agent) => listMessagingPolicyPresetMetadata({ manifests: [manifest], agent }).flatMap((preset) => - resolveMessagingChannelPolicyPresetPath(preset.presetName, agent) + policy.resolveMessagingChannelPolicyPresetPath(preset.presetName, agent) ? [] : [`${manifest.id}/${agent}/${preset.presetName}`], ), diff --git a/src/lib/messaging/channels/policy.ts b/src/lib/messaging/channels/policy.ts index f8c0bb92fa3..3807d319de9 100644 --- a/src/lib/messaging/channels/policy.ts +++ b/src/lib/messaging/channels/policy.ts @@ -41,12 +41,18 @@ function channelPolicyPath(channelId: string, agent: MessagingAgentId): string | } function readPresetHeader(content: string): { name: string; description: string } | null { - const parsed = YAML.parse(content); + let parsed: { preset?: unknown } | null; + try { + parsed = YAML.parse(content); + } catch { + return null; + } const preset = parsed?.preset; if (!preset || typeof preset !== "object" || Array.isArray(preset)) return null; - const name = preset.name; + const fields = preset as Record; + const name = fields.name; if (typeof name !== "string" || name.trim().length === 0) return null; - const description = typeof preset.description === "string" ? preset.description.trim() : ""; + const description = typeof fields.description === "string" ? fields.description.trim() : ""; return { name: name.trim(), description }; } diff --git a/test/policy-channel-agent-resolution.test.ts b/test/policy-channel-agent-resolution.test.ts index 2cad0b92574..2fd796d569c 100644 --- a/test/policy-channel-agent-resolution.test.ts +++ b/test/policy-channel-agent-resolution.test.ts @@ -13,7 +13,7 @@ const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "state", const SOURCE_NODE_ARGS = ["--import", "tsx"]; describe("sandbox-aware messaging policy resolution", () => { - it("does not use OpenClaw channel policies for unsupported sandbox agents", () => { + it("loadPresetForSandbox fails closed for unknown messaging agents without blocking central presets", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-agent-resolution-")); const script = String.raw` const registry = require(${REGISTRY_PATH}); From 678f952da781021c297d261b64f72b6ce61466ba Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 1 Jul 2026 20:44:38 +0700 Subject: [PATCH 06/16] test(policy): stabilize channel preset fixtures --- src/lib/messaging/channels/policy.test.ts | 102 ++++++++---------- src/lib/messaging/channels/policy.ts | 120 +++++++++++++++++----- 2 files changed, 133 insertions(+), 89 deletions(-) diff --git a/src/lib/messaging/channels/policy.test.ts b/src/lib/messaging/channels/policy.test.ts index c94f690f141..eda57a434dc 100644 --- a/src/lib/messaging/channels/policy.test.ts +++ b/src/lib/messaging/channels/policy.test.ts @@ -1,13 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { listBuiltInMessagingChannelManifests, listMessagingPolicyPresetMetadata, } from "./metadata"; +import { + createMessagingChannelPolicyResolver, + listMessagingChannelPolicyPresets, + loadMessagingChannelPolicyPreset, + resolveMessagingChannelPolicyPresetPath, +} from "./policy"; type PolicyFixture = { readonly channelId: string; @@ -26,34 +32,17 @@ function fixtureContentFor( ); } -async function importPolicy(): Promise { - vi.resetModules(); - return import("./policy"); -} - -async function importPolicyWithFixtures( +function createPolicyWithFixtures( presets: readonly PolicyFixture[], filesByChannel: Readonly> = {}, -): Promise { - vi.resetModules(); - vi.doMock("./metadata", () => ({ - listMessagingPolicyPresetMetadata: vi.fn(() => presets), - })); - vi.doMock("node:fs", () => ({ - default: { - existsSync: vi.fn((file: string) => fixtureContentFor(file, filesByChannel) !== null), - readFileSync: vi.fn((file: string) => fixtureContentFor(file, filesByChannel) ?? ""), - }, - })); - return import("./policy"); +): ReturnType { + return createMessagingChannelPolicyResolver({ + existsSync: (file) => fixtureContentFor(file, filesByChannel) !== null, + readFileSync: (file) => fixtureContentFor(file, filesByChannel) ?? "", + listPresetMetadata: () => presets, + }); } -afterEach(() => { - vi.doUnmock("./metadata"); - vi.doUnmock("node:fs"); - vi.resetModules(); -}); - function policyKeys(content: string | null): string[] { expect(content).toBeTruthy(); const parsed = YAML.parse(content ?? ""); @@ -61,19 +50,17 @@ function policyKeys(content: string | null): string[] { } describe("messaging channel policy presets", () => { - it("loads OpenClaw and Hermes channel-specific Telegram policy keys", async () => { - const policy = await importPolicy(); - expect( - policyKeys(policy.loadMessagingChannelPolicyPreset("telegram", { agent: "openclaw" })), - ).toEqual(["telegram_bot"]); - expect( - policyKeys(policy.loadMessagingChannelPolicyPreset("telegram", { agent: "hermes" })), - ).toEqual(["telegram"]); + it("loads OpenClaw and Hermes channel-specific Telegram policy keys", () => { + expect(policyKeys(loadMessagingChannelPolicyPreset("telegram", { agent: "openclaw" }))).toEqual( + ["telegram_bot"], + ); + expect(policyKeys(loadMessagingChannelPolicyPreset("telegram", { agent: "hermes" }))).toEqual([ + "telegram", + ]); }); - it("lists operator-facing preset names from channel-owned policy files", async () => { - const policy = await importPolicy(); - const presets = policy.listMessagingChannelPolicyPresets(); + it("lists operator-facing preset names from channel-owned policy files", () => { + const presets = listMessagingChannelPolicyPresets(); expect(presets.map((preset) => preset.name).sort()).toEqual([ "discord", "slack", @@ -87,63 +74,54 @@ describe("messaging channel policy presets", () => { ); }); - it("does not fall back to OpenClaw policies for unsupported agents", async () => { - const policy = await importPolicy(); + it("does not fall back to OpenClaw policies for unsupported agents", () => { expect( - policy.loadMessagingChannelPolicyPreset("telegram", { - agent: "langchain-deepagents-code", - }), + loadMessagingChannelPolicyPreset("telegram", { agent: "langchain-deepagents-code" }), ).toBeNull(); expect( - policy.resolveMessagingChannelPolicyPresetPath("telegram", "langchain-deepagents-code"), + resolveMessagingChannelPolicyPresetPath("telegram", "langchain-deepagents-code"), ).toBeNull(); - expect( - policy.listMessagingChannelPolicyPresets({ agent: "langchain-deepagents-code" }), - ).toEqual([]); + expect(listMessagingChannelPolicyPresets({ agent: "langchain-deepagents-code" })).toEqual([]); }); - it("returns null for unknown channel policy presets", async () => { - const policy = await importPolicy(); - expect(policy.loadMessagingChannelPolicyPreset("nonexistent", { agent: "hermes" })).toBeNull(); - expect(policy.resolveMessagingChannelPolicyPresetPath("nonexistent", "hermes")).toBeNull(); + it("returns null for unknown channel policy presets", () => { + expect(loadMessagingChannelPolicyPreset("nonexistent", { agent: "hermes" })).toBeNull(); + expect(resolveMessagingChannelPolicyPresetPath("nonexistent", "hermes")).toBeNull(); }); - it("rejects path traversal channel ids from preset metadata", async () => { - const policy = await importPolicyWithFixtures([ - { channelId: "../telegram", presetName: "telegram" }, - ]); + it("rejects path traversal channel ids from preset metadata", () => { + const policy = createPolicyWithFixtures([{ channelId: "../telegram", presetName: "telegram" }]); expect(policy.resolveMessagingChannelPolicyPresetPath("telegram")).toBeNull(); expect(policy.loadMessagingChannelPolicyPreset("telegram")).toBeNull(); }); - it("returns null when channel policy files are missing", async () => { - const policy = await importPolicyWithFixtures([{ channelId: "missing", presetName: "slack" }]); + it("returns null when channel policy files are missing", () => { + const policy = createPolicyWithFixtures([{ channelId: "missing", presetName: "slack" }]); expect(policy.resolveMessagingChannelPolicyPresetPath("slack")).toBeNull(); expect(policy.loadMessagingChannelPolicyPreset("slack")).toBeNull(); }); - it("skips channel policy files whose preset header has the wrong name", async () => { - const policy = await importPolicyWithFixtures([{ channelId: "slack", presetName: "slack" }], { + it("skips channel policy files whose preset header has the wrong name", () => { + const policy = createPolicyWithFixtures([{ channelId: "slack", presetName: "slack" }], { slack: "preset:\n name: discord\nnetwork_policies:\n discord: {}\n", }); expect(policy.loadMessagingChannelPolicyPreset("slack")).toBeNull(); expect(policy.listMessagingChannelPolicyPresets()).toEqual([]); }); - it("returns null for malformed channel policy YAML", async () => { - const policy = await importPolicyWithFixtures([{ channelId: "slack", presetName: "slack" }], { + it("returns null for malformed channel policy YAML", () => { + const policy = createPolicyWithFixtures([{ channelId: "slack", presetName: "slack" }], { slack: "preset:\n name: [\nnetwork_policies:\n slack: {}\n", }); expect(policy.loadMessagingChannelPolicyPreset("slack")).toBeNull(); expect(policy.listMessagingChannelPolicyPresets()).toEqual([]); }); - it("ships a policy file for every manifest-supported agent and preset", async () => { - const policy = await importPolicy(); + it("ships a policy file for every manifest-supported agent and preset", () => { const missing = listBuiltInMessagingChannelManifests().flatMap((manifest) => manifest.supportedAgents.flatMap((agent) => listMessagingPolicyPresetMetadata({ manifests: [manifest], agent }).flatMap((preset) => - policy.resolveMessagingChannelPolicyPresetPath(preset.presetName, agent) + resolveMessagingChannelPolicyPresetPath(preset.presetName, agent) ? [] : [`${manifest.id}/${agent}/${preset.presetName}`], ), diff --git a/src/lib/messaging/channels/policy.ts b/src/lib/messaging/channels/policy.ts index 3807d319de9..8705378b8db 100644 --- a/src/lib/messaging/channels/policy.ts +++ b/src/lib/messaging/channels/policy.ts @@ -9,6 +9,15 @@ import { ROOT } from "../../state/paths"; import type { MessagingAgentId } from "../manifest"; import { listMessagingPolicyPresetMetadata } from "./metadata"; +type PolicyPresetLocator = { + readonly channelId: string; + readonly presetName: string; +}; + +type PolicyPresetMetadataReader = (options: { + readonly agent?: MessagingAgentId; +}) => readonly PolicyPresetLocator[]; + const CHANNELS_ROOT = path.join(ROOT, "src", "lib", "messaging", "channels"); const POLICY_FILE_BY_AGENT: Readonly> = { openclaw: "openclaw.yaml", @@ -23,6 +32,26 @@ export interface MessagingChannelPolicyPresetInfo { readonly agent: MessagingAgentId; } +export interface MessagingChannelPolicyResolver { + readonly resolveMessagingChannelPolicyPresetPath: ( + presetName: string, + agent?: MessagingAgentId | string | null | undefined, + ) => string | null; + readonly loadMessagingChannelPolicyPreset: ( + presetName: string, + options?: { readonly agent?: MessagingAgentId | string | null }, + ) => string | null; + readonly listMessagingChannelPolicyPresets: (options?: { + readonly agent?: MessagingAgentId | string | null; + }) => MessagingChannelPolicyPresetInfo[]; +} + +export interface MessagingChannelPolicyResolverDeps { + readonly existsSync: (file: string) => boolean; + readonly readFileSync: (file: string, encoding: BufferEncoding) => string; + readonly listPresetMetadata: PolicyPresetMetadataReader; +} + function normalizeAgent( agent: MessagingAgentId | string | null | undefined, ): MessagingAgentId | null { @@ -60,10 +89,11 @@ function readChannelPolicyInfo( channelId: string, expectedPresetName: string, agent: MessagingAgentId, + deps: MessagingChannelPolicyResolverDeps, ): MessagingChannelPolicyPresetInfo | null { const file = channelPolicyPath(channelId, agent); - if (!file || !fs.existsSync(file)) return null; - const content = fs.readFileSync(file, "utf-8"); + if (!file || !deps.existsSync(file)) return null; + const content = deps.readFileSync(file, "utf-8"); const header = readPresetHeader(content); if (!header || header.name !== expectedPresetName) return null; return { @@ -75,46 +105,82 @@ function readChannelPolicyInfo( }; } +export function createMessagingChannelPolicyResolver( + deps: MessagingChannelPolicyResolverDeps, +): MessagingChannelPolicyResolver { + function resolveMessagingChannelPolicyPresetPath( + presetName: string, + agent: MessagingAgentId | string | null | undefined = "openclaw", + ): string | null { + const normalizedAgent = normalizeAgent(agent); + if (!normalizedAgent) return null; + for (const preset of deps.listPresetMetadata({ agent: normalizedAgent })) { + if (preset.presetName !== presetName) continue; + const file = channelPolicyPath(preset.channelId, normalizedAgent); + if (file && deps.existsSync(file)) return file; + } + return null; + } + + function loadMessagingChannelPolicyPreset( + presetName: string, + options: { readonly agent?: MessagingAgentId | string | null } = {}, + ): string | null { + const file = resolveMessagingChannelPolicyPresetPath(presetName, options.agent); + if (!file) return null; + const content = deps.readFileSync(file, "utf-8"); + const header = readPresetHeader(content); + return header?.name === presetName ? content : null; + } + + function listMessagingChannelPolicyPresets( + options: { readonly agent?: MessagingAgentId | string | null } = {}, + ): MessagingChannelPolicyPresetInfo[] { + const agent = normalizeAgent(options.agent); + if (!agent) return []; + const result: MessagingChannelPolicyPresetInfo[] = []; + const seen = new Set(); + for (const preset of deps.listPresetMetadata({ agent })) { + if (seen.has(preset.presetName)) continue; + const info = readChannelPolicyInfo(preset.channelId, preset.presetName, agent, deps); + if (!info) continue; + result.push(info); + seen.add(preset.presetName); + } + return result; + } + + return { + listMessagingChannelPolicyPresets, + loadMessagingChannelPolicyPreset, + resolveMessagingChannelPolicyPresetPath, + }; +} + +const defaultPolicyResolver = createMessagingChannelPolicyResolver({ + existsSync: (file) => fs.existsSync(file), + readFileSync: (file, encoding) => fs.readFileSync(file, encoding), + listPresetMetadata: listMessagingPolicyPresetMetadata, +}); + export function resolveMessagingChannelPolicyPresetPath( presetName: string, agent: MessagingAgentId | string | null | undefined = "openclaw", ): string | null { - const normalizedAgent = normalizeAgent(agent); - if (!normalizedAgent) return null; - for (const preset of listMessagingPolicyPresetMetadata({ agent: normalizedAgent })) { - if (preset.presetName !== presetName) continue; - const file = channelPolicyPath(preset.channelId, normalizedAgent); - if (file && fs.existsSync(file)) return file; - } - return null; + return defaultPolicyResolver.resolveMessagingChannelPolicyPresetPath(presetName, agent); } export function loadMessagingChannelPolicyPreset( presetName: string, options: { readonly agent?: MessagingAgentId | string | null } = {}, ): string | null { - const file = resolveMessagingChannelPolicyPresetPath(presetName, options.agent); - if (!file) return null; - const content = fs.readFileSync(file, "utf-8"); - const header = readPresetHeader(content); - return header?.name === presetName ? content : null; + return defaultPolicyResolver.loadMessagingChannelPolicyPreset(presetName, options); } export function listMessagingChannelPolicyPresets( options: { readonly agent?: MessagingAgentId | string | null } = {}, ): MessagingChannelPolicyPresetInfo[] { - const agent = normalizeAgent(options.agent); - if (!agent) return []; - const result: MessagingChannelPolicyPresetInfo[] = []; - const seen = new Set(); - for (const preset of listMessagingPolicyPresetMetadata({ agent })) { - if (seen.has(preset.presetName)) continue; - const info = readChannelPolicyInfo(preset.channelId, preset.presetName, agent); - if (!info) continue; - result.push(info); - seen.add(preset.presetName); - } - return result; + return defaultPolicyResolver.listMessagingChannelPolicyPresets(options); } export function isMessagingChannelPolicyPreset(presetName: string): boolean { From cb23ca4bdd8ffa9b6fbd06a76a7866eecde660ec Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 2 Jul 2026 21:49:18 +0530 Subject: [PATCH 07/16] fix(policy): hide unsupported messaging presets --- .../customize-network-policy.mdx | 1 + .../integration-policy-examples.mdx | 1 + docs/reference/commands-nemohermes.mdx | 2 + docs/reference/commands.mdx | 2 + docs/reference/network-policies.mdx | 4 +- .../sandbox/policy-channel-list.test.ts | 29 +++++- .../sandbox/policy-channel-policy.test.ts | 33 ++++++- src/lib/actions/sandbox/policy-channel.ts | 12 ++- src/lib/policy/index.ts | 30 ++++-- test/policy-channel-agent-resolution.test.ts | 93 +++++++++++++++++++ 10 files changed, 189 insertions(+), 18 deletions(-) diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 78a12ded96f..caa94be7946 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -203,6 +203,7 @@ For guided post-install examples, refer to [Common Integration Policy Examples]( During onboarding, the [policy tier](../reference/network-policies#policy-tiers) you select determines which presets are enabled by default. You can add or remove individual presets in the interactive preset screen that follows tier selection. +Built-in preset choices are scoped to the sandbox's active agent, so unsupported messaging channel presets do not appear in `policy-list` or the interactive `policy-add` picker for agents without matching channel policy files. Available presets: diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 62f675ac24d..4acb248cb10 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -47,6 +47,7 @@ An approval updates the running policy, but it does not create a reviewable Nemo ## Supported Integration Presets NemoClaw ships maintained policy presets for common services in `nemoclaw-blueprint/policies/presets/`. +Messaging channel presets are scoped to the sandbox's active agent; if an agent does not have a matching channel policy, that channel preset is omitted from `policy-list` and `policy-add ` reports it as unknown. | Workflow | Preset | |----------|--------| diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 3d54a051797..a8c8cde86ba 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -821,6 +821,7 @@ nemohermes my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown or already applied, the command exits non-zero with a clear error. +Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets appear only when NemoClaw has a matching channel policy for that agent; unavailable channel presets use the standard unknown-preset error before endpoint preview or confirmation. Custom preset files are tracked with the sandbox that applied them. `policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. Before `policy-add` writes a merged policy, it reads and parses the current live policy from OpenShell. @@ -860,6 +861,7 @@ Custom presets bypass the built-in preset review process and can widen sandbox e ### `nemohermes policy-list` List available policy presets and show which ones are applied to the sandbox. +The available built-in rows are scoped to the sandbox's active agent, so unsupported messaging channel policies are not listed for agents without matching channel policy files. The command cross-references the local registry against the live gateway state (via `openshell policy get`), so it flags presets that are applied in one place but not the other. This catches desync caused by external edits to the gateway policy or stale registry entries after a manual rollback. Preset summaries come only from the YAML `preset.description` field. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 926127f9078..fcb93764405 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1125,6 +1125,7 @@ $$nemoclaw my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown or already applied, the command exits non-zero with a clear error. +Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets appear only when NemoClaw has a matching channel policy for that agent; unavailable channel presets use the standard unknown-preset error before endpoint preview or confirmation. Custom preset files are tracked with the sandbox that applied them. `policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. Before `policy-add` writes a merged policy, it reads and parses the current live policy from OpenShell. @@ -1164,6 +1165,7 @@ Custom presets bypass the built-in preset review process and can widen sandbox e ### `$$nemoclaw policy-list` List available policy presets and show which ones are applied to the sandbox. +The available built-in rows are scoped to the sandbox's active agent, so unsupported messaging channel policies are not listed for agents without matching channel policy files. The command cross-references the local registry against the live gateway state (via `openshell policy get`), so it flags presets that are applied in one place but not the other. This catches desync caused by external edits to the gateway policy or stale registry entries after a manual rollback. Preset summaries come only from the YAML `preset.description` field. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 0ebb0006ca4..af9ea1b273e 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -73,8 +73,8 @@ The baseline policy is always applied regardless of the selected tier. | 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. -Tier-default presets are pre-selected; additional presets can be added from the full list. -NemoClaw filters tier defaults by the active agent's supported integrations. +Tier-default presets are pre-selected; additional presets can be added from the built-in preset list available to the sandbox's active agent. +NemoClaw filters tier defaults and built-in preset choices 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. diff --git a/src/lib/actions/sandbox/policy-channel-list.test.ts b/src/lib/actions/sandbox/policy-channel-list.test.ts index 19fd9b43fde..cbd64716204 100644 --- a/src/lib/actions/sandbox/policy-channel-list.test.ts +++ b/src/lib/actions/sandbox/policy-channel-list.test.ts @@ -11,7 +11,7 @@ type PresetInfo = { const moduleMocks = vi.hoisted(() => ({ getSandbox: vi.fn<(sandboxName: string) => Record | null>(), getCustomPolicies: vi.fn<(sandboxName: string) => PresetInfo[]>(), - listPresets: vi.fn<() => PresetInfo[]>(), + listPresets: vi.fn<(options?: { agent?: string | null }) => PresetInfo[]>(), listCustomPresets: vi.fn<(sandboxName: string) => PresetInfo[]>(), getAppliedPresets: vi.fn<(sandboxName: string) => string[]>(), getGatewayPresets: vi.fn<(sandboxName: string) => string[] | null>(), @@ -172,6 +172,33 @@ describe("listSandboxPolicies provenance", () => { expect(output).not.toMatch(/○ pypi \[/); }); + it("omits channel policy presets that are not available for the sandbox agent (#6185)", () => { + arrangeListing({ + appliedNames: [], + gatewayNames: [], + tier: "balanced", + agent: "langchain-deepagents-code", + }); + moduleMocks.listPresets.mockImplementation((options) => + options?.agent === "langchain-deepagents-code" + ? [ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + ] + : POLICY_PRESETS, + ); + + listSandboxPolicies("test-sandbox"); + + expect(moduleMocks.listPresets).toHaveBeenCalledWith({ + agent: "langchain-deepagents-code", + }); + const output = printedText(); + expect(output).toContain("○ npm"); + expect(output).not.toContain("discord"); + expect(output).not.toContain("telegram"); + }); + it.each([ { agent: "hermes", diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 0f71fdb7a5e..0e4424a6d95 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -61,6 +61,7 @@ let getSandboxMock: MockInstance; let getAppliedPresetsMock: MockInstance; let selectFromListMock: MockInstance; let selectForRemovalMock: MockInstance; +let loadPresetForSandboxMock: MockInstance; let applyPresetMock: MockInstance; let removePresetMock: MockInstance; @@ -113,12 +114,12 @@ beforeEach(() => { const presetName = String(name); return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`; }); - vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( - (_sandboxName: unknown, name: unknown) => { + loadPresetForSandboxMock = vi + .spyOn(policies, "loadPresetForSandbox") + .mockImplementation((_sandboxName: unknown, name: unknown) => { const presetName = String(name); return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`; - }, - ); + }); applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true); }); @@ -234,6 +235,30 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); + it("treats messaging channel policy presets unavailable to terminal-runtime agents as unknown before preview or prompt", async () => { + arrangeSandbox("langchain-deepagents-code"); + vi.spyOn(policies, "listPresets").mockReturnValue([ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + { name: "tavily", description: "Tavily Search API access" }, + ]); + + await expect( + captureExit(() => addSandboxPolicy("test-sandbox", { preset: "telegram", yes: true })), + ).resolves.toBe(1); + + const output = printedText(); + expect(output).toContain("Unknown preset 'telegram'."); + expect(output).toContain("Valid presets: npm, pypi, tavily"); + expect(output).not.toContain("not supported for agent"); + expect(output).not.toContain("Channels supported by agent"); + expect(output).not.toContain("Preset not found"); + expect(output).not.toContain("Endpoints that would be opened"); + expect(promptMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + }); + it.each([ { preset: "telegram", diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index fd928621fdc..374c0ce7c07 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -137,8 +137,12 @@ export async function addSandboxPolicy( return; } - const sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; - const allPresets = filterSetupPolicyPresetsForAgent(policies.listPresets(), sandboxAgent); + const sandboxEntry = registry.getSandbox(sandboxName); + const sandboxAgent = sandboxEntry?.agent ?? null; + const allPresets = filterSetupPolicyPresetsForAgent( + policies.listPresets({ agent: sandboxAgent }), + sandboxAgent, + ); const applied = policies.getAppliedPresets(sandboxName); let answer = null; @@ -261,7 +265,8 @@ async function applyExternalPreset( } export function listSandboxPolicies(sandboxName: string) { - const builtin = policies.listPresets(); + const sandboxEntry = registry.getSandbox(sandboxName); + const builtin = policies.listPresets({ agent: sandboxEntry?.agent ?? null }); const custom = policies.listCustomPresets(sandboxName); const allPresets = [...builtin, ...custom]; const registryPresets = policies.getAppliedPresets(sandboxName); @@ -270,7 +275,6 @@ 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, diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 57c9fa3b396..8061fd415ca 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -7,6 +7,7 @@ import type { JsonObject, JsonValue } from "../core/json-types"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, + isMessagingChannelPolicyPreset, listBuiltInMessagingChannelManifests, listMessagingChannelPolicyPresets, listMessagingPolicyPresetMetadata, @@ -52,6 +53,10 @@ type PresetLoadOptions = { agent?: string | null; }; +type PresetListOptions = { + agent?: string | null; +}; + type MergePresetNamesOptions = { agent?: string | null; }; @@ -70,12 +75,14 @@ function isPolicyDocument(value: PolicyValue): value is PolicyDocument { * under `nemoclaw-blueprint/policies/presets/`; messaging channel presets live * beside their channel manifests under `src/lib/messaging/channels//policy/`. */ -function listPresets(): PresetInfo[] { - const channelPresets = listMessagingChannelPolicyPresets().map(({ file, name, description }) => ({ - file, - name, - description, - })); +function listPresets(options: PresetListOptions = {}): PresetInfo[] { + const channelPresets = listMessagingChannelPolicyPresets({ agent: options.agent }).map( + ({ file, name, description }) => ({ + file, + name, + description, + }), + ); const channelPresetNames = new Set(channelPresets.map((preset) => preset.name)); if (!fs.existsSync(PRESETS_DIR)) return channelPresets; const centralPresets = fs @@ -116,6 +123,7 @@ function loadCentralPreset(name: string, options: { reportMissing?: boolean } = function loadPresetForAgent(name: string, options: PresetLoadOptions = {}): string | null { const channelPreset = loadMessagingChannelPolicyPreset(name, { agent: options.agent }); if (channelPreset) return channelPreset; + if (isMessagingChannelPolicyPreset(name)) return null; return loadCentralPreset(name); } @@ -255,6 +263,7 @@ function loadPresetForSandbox(sandboxName: string, presetName: string): string | agent: sandboxAgent, }); if (channelPresetContent) return channelPresetContent; + if (isMessagingChannelPolicyPreset(presetName)) return null; const builtinPresetContent = loadCentralPreset(presetName); if (!builtinPresetContent) return null; @@ -1268,8 +1277,14 @@ function getGatewayPresets(sandboxName: string): string[] | null { const gatewayPolicyNames = new Set(Object.keys(gatewayPolicies)); const matched: string[] = []; + let sandboxAgent: string | null = null; + try { + sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; + } catch { + sandboxAgent = null; + } - for (const preset of listPresets()) { + for (const preset of listPresets({ agent: sandboxAgent })) { if (presetMatchesGateway(loadPresetForSandbox(sandboxName, preset.name), gatewayPolicyNames)) { matched.push(preset.name); } @@ -1407,6 +1422,7 @@ export { getGatewayPresets, getPresetEndpoints, getPresetValidationWarning, + isMessagingChannelPolicyPreset, listCustomPresets, listPresets, listSetupPolicyPresets, diff --git a/test/policy-channel-agent-resolution.test.ts b/test/policy-channel-agent-resolution.test.ts index 2fd796d569c..6c5b8d9c44b 100644 --- a/test/policy-channel-agent-resolution.test.ts +++ b/test/policy-channel-agent-resolution.test.ts @@ -8,6 +8,9 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; const REPO_ROOT = path.join(import.meta.dirname, ".."); +const ACTION_PATH = JSON.stringify( + path.join(REPO_ROOT, "src", "lib", "actions", "sandbox", "policy-channel.ts"), +); const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "policy", "index.ts")); const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "state", "registry.ts")); const SOURCE_NODE_ARGS = ["--import", "tsx"]; @@ -41,5 +44,95 @@ process.stdout.write("__RESULT__" + JSON.stringify({ const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); expect(payload.channelPreset).toBeNull(); expect(payload.centralPresetHasNpmPolicy).toBe(true); + expect(result.stderr).not.toContain("Preset not found"); + }); + + it("gateway preset matching skips unsupported Deep Agents messaging policies without lookup noise (#6185)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-gateway-agent-")); + const openshellPath = path.join(tmpDir, "openshell"); + fs.writeFileSync( + openshellPath, + [ + "#!/usr/bin/env bash", + "cat <<'EOF'", + "Version: 1", + "---", + "version: 1", + "network_policies:", + " npm_yarn:", + " endpoints: []", + "EOF", + "", + ].join("\n"), + ); + fs.chmodSync(openshellPath, 0o755); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ + name: "deepagents-sandbox", + agent: "langchain-deepagents-code", + policies: [], +}); +const gatewayPresets = policies.getGatewayPresets("deepagents-sandbox"); +process.stdout.write("__RESULT__" + JSON.stringify({ gatewayPresets })); +`; + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, NEMOCLAW_OPENSHELL_BIN: openshellPath }, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("Preset not found"); + const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); + expect(payload.gatewayPresets).toEqual(["npm"]); + }); + it("policy-add treats unsupported Deep Agents messaging policy as unknown before preview or prompt (#6185)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-agent-gate-")); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const { addSandboxPolicy } = require(${ACTION_PATH}); +const output = []; +const errors = []; +console.log = (...args) => output.push(args.join(" ")); +console.error = (...args) => errors.push(args.join(" ")); +process.exit = (code) => { throw new Error("EXIT:" + String(code)); }; +registry.registerSandbox({ + name: "deepagents-sandbox", + agent: "langchain-deepagents-code", + policies: [], +}); +(async () => { + let exitCode = null; + try { + await addSandboxPolicy("deepagents-sandbox", { preset: "telegram", yes: true }); + } catch (error) { + exitCode = String(error && error.message) === "EXIT:1" ? 1 : "unexpected"; + errors.push(String(error && (error.stack || error.message || error))); + } + process.stdout.write("__RESULT__" + JSON.stringify({ exitCode, output, errors })); +})(); +`; + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir }, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); + const text = [...payload.output, ...payload.errors].join("\n"); + expect(payload.exitCode).toBe(1); + expect(text).toContain("Unknown preset 'telegram'."); + expect(text).toContain("Valid presets:"); + expect(text).not.toContain("telegram,"); + expect(text).not.toContain("not supported for agent"); + expect(text).not.toContain("Terminal-runtime agents do not run inbound messaging bridges."); + expect(text).not.toContain("Preset not found"); + expect(text).not.toContain("Endpoints that would be opened"); + expect(text).not.toContain("Apply 'telegram'"); }); }); From 65c80ae09bd842cc185376a723b2df26d540e5a9 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 2 Jul 2026 22:27:34 +0530 Subject: [PATCH 08/16] fix(policy): use Hermes presets for inferred policy path Signed-off-by: San Dang --- .../initial-policy-real-policy.test.ts | 84 +++++++++++++++++++ src/lib/onboard/initial-policy.ts | 6 +- 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/lib/onboard/initial-policy-real-policy.test.ts diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts new file mode 100644 index 00000000000..8ee8a7bf0df --- /dev/null +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { prepareInitialSandboxCreatePolicy } from "./initial-policy"; + +type PolicyRule = { + allow?: { + method?: string; + path?: string; + }; +}; + +type PolicyEndpoint = { + host?: string; + rules?: PolicyRule[]; +}; + +type PolicyEntry = { + binaries?: Array<{ path?: string }>; + endpoints?: PolicyEndpoint[]; +}; + +type PolicyDocument = { + network_policies?: Record; +}; + +const cleanupFns: Array<() => boolean | undefined> = []; + +afterEach(() => { + for (const cleanup of cleanupFns.splice(0)) { + cleanup(); + } +}); + +function repoPath(...segments: string[]): string { + return path.join(import.meta.dirname, "..", "..", "..", ...segments); +} + +function readPreparedPolicy(prepared: { + policyPath: string; + cleanup?: () => boolean; +}): PolicyDocument { + if (prepared.cleanup) cleanupFns.push(prepared.cleanup); + return YAML.parse(fs.readFileSync(prepared.policyPath, "utf-8")) as PolicyDocument; +} + +describe("initial sandbox policy real preset merge", () => { + it("uses Hermes channel YAML when the Hermes base policy path implies the agent", () => { + const prepared = prepareInitialSandboxCreatePolicy( + repoPath("agents", "hermes", "policy-additions.yaml"), + ["discord", "slack"], + ); + const policy = readPreparedPolicy(prepared); + + expect(prepared.appliedPresets).toEqual(["discord", "slack"]); + + const slackBinaries = + policy.network_policies?.slack?.binaries?.map((binary) => binary.path) ?? []; + expect(slackBinaries).toEqual([ + "/usr/local/bin/hermes", + "/usr/bin/python3*", + "/opt/hermes/.venv/bin/python", + ]); + + const discordBinaries = + policy.network_policies?.discord?.binaries?.map((binary) => binary.path) ?? []; + expect(discordBinaries).toContain("/usr/bin/python3*"); + expect(discordBinaries).toContain("/opt/hermes/.venv/bin/python"); + expect(discordBinaries).not.toContain("/usr/bin/node"); + + const discordRules = + policy.network_policies?.discord?.endpoints + ?.find((endpoint) => endpoint.host === "discord.com") + ?.rules?.map((rule) => rule.allow) ?? []; + expect(discordRules).not.toContainEqual({ method: "PUT", path: "/**" }); + expect(discordRules).not.toContainEqual({ method: "PATCH", path: "/**" }); + }); +}); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 07b4fad326a..8ddfda55554 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -231,7 +231,9 @@ export function prepareInitialSandboxCreatePolicy( tierKnown && options.policyTier !== "restricted" ? requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw") : []; - const isHermesPolicy = options.agentName === "hermes" || isHermesPolicyPath(basePolicyPath); + const isHermesPolicyFromPath = isHermesPolicyPath(basePolicyPath); + const isHermesPolicy = options.agentName === "hermes" || isHermesPolicyFromPath; + const policyAgent = options.agentName ?? (isHermesPolicyFromPath ? "hermes" : null); const messagingCreateTimePresets = isHermesPolicy ? allMessagingChannelPolicyPresets(activeMessagingChannels) : requiredMessagingChannelPolicyPresets(activeMessagingChannels); @@ -302,7 +304,7 @@ export function prepareInitialSandboxCreatePolicy( } const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets, { - agent: options.agentName ?? null, + agent: policyAgent, }); if (mergedPolicy.missingPresets.length > 0) { throw new Error( From 56b78eaff19c31fb914ebf336e55b5645c111417 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 2 Jul 2026 22:41:25 +0530 Subject: [PATCH 09/16] Revert "fix(policy): reject messaging channel presets on terminal-runtime agents (#6197)" This reverts commit 3a514475b2e02d1e6c763d201e09a2f0c71e552c. Signed-off-by: San Dang --- docs/reference/commands-nemohermes.mdx | 3 +- docs/reference/commands.mdx | 3 +- .../sandbox/policy-add-agent-gate.test.ts | 218 ---------------- .../sandbox/policy-channel-policy.test.ts | 13 +- src/lib/actions/sandbox/policy-channel.ts | 68 +---- src/lib/sandbox/version.ts | 2 +- test/policy-add-deepagents-rejection.test.ts | 241 ------------------ test/policy-channel-agent-resolution.test.ts | 10 +- 8 files changed, 21 insertions(+), 537 deletions(-) delete mode 100644 src/lib/actions/sandbox/policy-add-agent-gate.test.ts delete mode 100644 test/policy-add-deepagents-rejection.test.ts diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 85620bd9398..a8362254ffb 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -828,8 +828,7 @@ nemohermes my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown or already applied, the command exits non-zero with a clear error. -Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets such as `telegram`, `discord`, `slack`, `wechat`, and `whatsapp` apply only to agents that support those channels. -On a terminal-runtime agent such as DeepAgents, which has no inbound messaging gateway, `policy-add` rejects the preset with a clear error before any endpoint disclosure or prompt, matching `channels add`. +Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets appear only when NemoClaw has a matching channel policy for that agent; unavailable channel presets use the standard unknown-preset error before endpoint preview or confirmation. Custom preset files are tracked with the sandbox that applied them. `policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. Before `policy-add` writes a merged policy, it reads and parses the current live policy from OpenShell. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 08a9f6fb459..332bb253139 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1132,8 +1132,7 @@ $$nemoclaw my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown or already applied, the command exits non-zero with a clear error. -Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets such as `telegram`, `discord`, `slack`, `wechat`, and `whatsapp` apply only to agents that support those channels. -On a terminal-runtime agent such as DeepAgents, which has no inbound messaging gateway, `policy-add` rejects the preset with a clear error before any endpoint disclosure or prompt, matching `channels add`. +Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets appear only when NemoClaw has a matching channel policy for that agent; unavailable channel presets use the standard unknown-preset error before endpoint preview or confirmation. Custom preset files are tracked with the sandbox that applied them. `policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. Before `policy-add` writes a merged policy, it reads and parses the current live policy from OpenShell. diff --git a/src/lib/actions/sandbox/policy-add-agent-gate.test.ts b/src/lib/actions/sandbox/policy-add-agent-gate.test.ts deleted file mode 100644 index b11495de097..00000000000 --- a/src/lib/actions/sandbox/policy-add-agent-gate.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createRequire } from "node:module"; - -import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; - -const requireSource = createRequire(import.meta.url); -const D = (p: string) => requireSource(`../../${p}`); - -const registry = D("state/registry.js"); -const defs = D("agent/defs.js"); -const policy = D("policy/index.js"); -const store = D("credentials/store.js"); -const onboardSession = D("state/onboard-session.js"); -const contextRefresh = D("actions/sandbox/policy-context-refresh.js"); - -const { addSandboxPolicy } = D("actions/sandbox/policy-channel.js") as { - addSandboxPolicy: ( - name: string, - options?: { - preset?: string; - dryRun?: boolean; - yes?: boolean; - force?: boolean; - fromFile?: string; - fromDir?: string; - }, - ) => Promise; -}; - -const MESSAGING_POLICY_KEYS = [ - ["telegram_bot", "api.telegram.org"], - ["discord", "discord.com"], - ["slack", "api.slack.com"], - ["wechat_bridge", "api.weixin.qq.com"], - ["whatsapp", "graph.facebook.com"], - ["teams", "graph.microsoft.com"], -] as const; - -const MESSAGING_CHANNELS = ["telegram", "discord", "slack", "wechat", "whatsapp"] as const; - -const PRESETS = [ - { name: "pypi", description: "Python Package Index access" }, - { name: "telegram", description: "Telegram API access" }, - { name: "discord", description: "Discord API access" }, - { name: "slack", description: "Slack API access" }, - { name: "wechat", description: "WeChat API access" }, - { name: "whatsapp", description: "WhatsApp API access" }, -]; - -let errSpy: MockInstance; -let logSpy: MockInstance; -let applyPresetMock: MockInstance; -let selectFromListMock: MockInstance; -let promptMock: MockInstance; - -function exitCodeFromError(err: unknown): number | null { - const message = err instanceof Error ? err.message : String(err); - const match = message.match(/^process\.exit\((\d+)\)$/); - return match ? Number(match[1]) : null; -} - -function errorText(): string { - return (errSpy.mock.calls as unknown[][]).map((call) => call.map(String).join(" ")).join("\n"); -} - -function logText(): string { - return (logSpy.mock.calls as unknown[][]).map((call) => call.map(String).join(" ")).join("\n"); -} - -async function captureExit(action: () => Promise): Promise { - try { - await action(); - } catch (err) { - return exitCodeFromError(err); - } - return null; -} - -beforeEach(() => { - delete process.env.NEMOCLAW_NON_INTERACTIVE; - - logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); - - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "da-test", - agent: "langchain-deepagents-code", - policies: [], - }); - vi.spyOn(policy, "listPresets").mockReturnValue(PRESETS); - vi.spyOn(policy, "listCustomPresets").mockReturnValue([]); - vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]); - vi.spyOn(policy, "loadPreset").mockImplementation((name: unknown) => { - const presetName = String(name); - return `network_policies:\n ${presetName}:\n host: ${presetName}.example.com\n`; - }); - applyPresetMock = vi.spyOn(policy, "applyPreset").mockReturnValue(true); - selectFromListMock = vi.spyOn(policy, "selectFromList").mockResolvedValue("pypi"); - promptMock = vi.spyOn(store, "prompt").mockResolvedValue("y"); - - vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); - vi.spyOn(onboardSession, "updateSession").mockImplementation(() => undefined); - vi.spyOn(contextRefresh, "refreshSandboxPolicyContextFile").mockImplementation(() => undefined); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("addSandboxPolicy channel-agent gate", () => { - it.each( - MESSAGING_CHANNELS, - )("refuses the '%s' channel preset on a terminal-runtime agent before any disclosure, prompt, or apply", async (channel) => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); - - const code = await captureExit(() => - addSandboxPolicy("da-test", { preset: channel, yes: true }), - ); - - expect(code).toBe(1); - expect(errorText()).toMatch( - new RegExp(`Channel '${channel}' does not support agent 'langchain-deepagents-code'`), - ); - expect(errorText()).toMatch(/Channel-supported agents: openclaw, hermes/); - expect(errorText()).toMatch( - /Channels supported by agent 'langchain-deepagents-code': \(none\)/, - ); - expect(logText()).not.toContain("Endpoints that would be opened"); - expect(promptMock).not.toHaveBeenCalled(); - expect(applyPresetMock).not.toHaveBeenCalled(); - }); - - it("still applies a non-messaging preset on a terminal-runtime agent", async () => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); - - await addSandboxPolicy("da-test", { preset: "pypi", yes: true }); - - expect(errorText()).not.toMatch(/does not support agent/); - expect(applyPresetMock).toHaveBeenCalledWith("da-test", "pypi"); - }); - - it("does not gate a messaging-capable agent (openclaw applies a channel preset)", async () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "oc-test", - agent: "openclaw", - policies: [], - }); - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw" }); - - await addSandboxPolicy("oc-test", { preset: "telegram", yes: true }); - - expect(errorText()).not.toMatch(/does not support agent/); - expect(applyPresetMock).toHaveBeenCalledWith("oc-test", "telegram"); - }); - - it("omits unsupported channel presets from the interactive picker for a terminal-runtime agent", async () => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); - - await addSandboxPolicy("da-test"); - - expect(selectFromListMock).toHaveBeenCalledTimes(1); - const offered = (selectFromListMock.mock.calls[0][0] as Array<{ name: string }>).map( - (preset) => preset.name, - ); - expect(offered).toContain("pypi"); - for (const channel of MESSAGING_CHANNELS) { - expect(offered).not.toContain(channel); - } - }); -}); - -describe("addSandboxPolicy custom preset (--from-file) agent gate", () => { - it.each( - MESSAGING_POLICY_KEYS, - )("rejects a custom preset with a '%s' policy key on a terminal-runtime agent before any disclosure, prompt, or apply", async (policyKey, host) => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); - vi.spyOn(policy, "loadPresetFromFile").mockReturnValue({ - presetName: "my-custom", - content: `preset:\n name: my-custom\nnetwork_policies:\n ${policyKey}:\n host: ${host}\n`, - }); - const applyPresetContentMock = vi.spyOn(policy, "applyPresetContent"); - - const code = await captureExit(() => - addSandboxPolicy("da-test", { fromFile: "/tmp/my-custom.yaml", yes: true }), - ); - - expect(code).toBe(1); - expect(errorText()).toMatch(/does not support agent 'langchain-deepagents-code'/); - expect(logText()).not.toContain("Endpoints that would be opened"); - expect(promptMock).not.toHaveBeenCalled(); - expect(applyPresetContentMock).not.toHaveBeenCalled(); - }); - - it("still applies a non-messaging custom preset on a terminal-runtime agent", async () => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code" }); - vi.spyOn(policy, "loadPresetFromFile").mockReturnValue({ - presetName: "my-pypi-mirror", - content: - "preset:\n name: my-pypi-mirror\nnetwork_policies:\n pypi_mirror:\n host: pypi.example.com\n", - }); - const applyPresetContentMock = vi.spyOn(policy, "applyPresetContent").mockReturnValue(true); - - await addSandboxPolicy("da-test", { fromFile: "/tmp/my-pypi-mirror.yaml", yes: true }); - - expect(errorText()).not.toMatch(/does not support agent/); - expect(applyPresetContentMock).toHaveBeenCalledWith( - "da-test", - "my-pypi-mirror", - expect.stringContaining("pypi_mirror"), - { custom: { sourcePath: expect.stringContaining("my-pypi-mirror.yaml") } }, - ); - }); -}); diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 47595155882..0e4424a6d95 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -235,7 +235,7 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); - it("rejects messaging channel policy presets unavailable to terminal-runtime agents before preview or prompt", async () => { + it("treats messaging channel policy presets unavailable to terminal-runtime agents as unknown before preview or prompt", async () => { arrangeSandbox("langchain-deepagents-code"); vi.spyOn(policies, "listPresets").mockReturnValue([ { name: "npm", description: "npm and Yarn registry access" }, @@ -248,13 +248,10 @@ describe("addSandboxPolicy", () => { ).resolves.toBe(1); const output = printedText(); - expect(output).toContain( - "Channel 'telegram' does not support agent 'langchain-deepagents-code'", - ); - expect(output).toContain("Channel-supported agents: openclaw, hermes."); - expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)."); - expect(output).not.toContain("Unknown preset"); - expect(output).not.toContain("Valid presets:"); + expect(output).toContain("Unknown preset 'telegram'."); + expect(output).toContain("Valid presets: npm, pypi, tavily"); + expect(output).not.toContain("not supported for agent"); + expect(output).not.toContain("Channels supported by agent"); expect(output).not.toContain("Preset not found"); expect(output).not.toContain("Endpoints that would be opened"); expect(promptMock).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index e9775f0df86..39faa945ba2 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; -import type { AgentDefinition } from "../../agent/defs"; +import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { prompt as askPrompt, getCredential } from "../../credentials/store"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; @@ -18,7 +18,6 @@ import { getMessagingManifestAvailabilityContext, isMessagingChannelSupportedByAgent, isMessagingHookConflictError, - listMessagingPolicyPresetMetadata, MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, @@ -29,7 +28,6 @@ import { tryGetMessagingAgentId, } from "../../messaging"; import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; -import { resolveAgentForSandbox } from "../../sandbox/version"; import { hashCredential } from "../../security/credential-hash"; import { getSandboxTargetGatewayName } from "./gateway-target"; @@ -140,32 +138,15 @@ export async function addSandboxPolicy( } const sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; - const agent = resolveAgentForSandbox(sandboxName); const allPresets = filterSetupPolicyPresetsForAgent( policies.listPresets({ agent: sandboxAgent }), sandboxAgent, - ).filter((preset: { name: string }) => { - const manifest = resolveChannelManifest(preset.name); - return !manifest || channelSupportedByAgent(manifest, agent); - }); + ); const applied = policies.getAppliedPresets(sandboxName); let answer = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); - const channelManifest = resolveChannelManifest(normalized); - if (channelManifest && !channelSupportedByAgent(channelManifest, agent)) { - console.error( - ` Channel '${channelManifest.id}' does not support agent '${agent.name}' for sandbox '${sandboxName}'.`, - ); - console.error( - ` Channel-supported agents: ${formatSupportedMessagingAgentIds(channelManifest.supportedAgents)}.`, - ); - console.error( - ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, - ); - process.exit(1); - } const preset = allPresets.find((item: { name: string }) => item.name === normalized); if (!preset) { console.error(` Unknown preset '${presetArg}'.`); @@ -244,21 +225,6 @@ async function applyExternalPreset( } if (!loaded) return false; - const agent = resolveAgentForSandbox(sandboxName); - const unsupportedChannel = unsupportedMessagingChannelForPresetContent(loaded.content, agent); - if (unsupportedChannel) { - console.error( - ` Preset '${loaded.presetName}' targets the '${unsupportedChannel.id}' channel, which does not support agent '${agent.name}' for sandbox '${sandboxName}'.`, - ); - console.error( - ` Channel-supported agents: ${formatSupportedMessagingAgentIds(unsupportedChannel.supportedAgents)}.`, - ); - console.error( - ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, - ); - return false; - } - const endpoints = policies.getPresetEndpoints(loaded.content); if (endpoints.length > 0) { console.log(` [${loaded.presetName}] Endpoints that would be opened: ${endpoints.join(", ")}`); @@ -348,6 +314,12 @@ export function listSandboxPolicies(sandboxName: string) { // ── Messaging channels ─────────────────────────────────────────── +function resolveAgentForSandbox(sandboxName: string): AgentDefinition { + const entry = registry.getSandbox(sandboxName); + const agentName = entry?.agent || "openclaw"; + return loadAgent(agentName); +} + function knownManifestChannelNames(): string[] { return messagingManifestRegistry.list().map((manifest) => manifest.id); } @@ -366,30 +338,6 @@ function channelSupportedByAgent(manifest: ChannelManifest, agent: AgentDefiniti return isMessagingChannelSupportedByAgent(manifest, agent); } -// Custom presets (--from-file / --from-dir) have no channel identity of -// their own, so the built-in name-based gate above cannot see them. Detect -// a messaging channel by content instead: match the preset's network_policies -// keys against every channel's known policy keys, then apply the same -// agent-support gate as the built-in path. -function unsupportedMessagingChannelForPresetContent( - content: string, - agent: AgentDefinition, -): ChannelManifest | null { - if (typeof content !== "string") return null; - const policyKeys = new Set(policies.parsePresetPolicyKeys(content)); - if (policyKeys.size === 0) return null; - for (const preset of listMessagingPolicyPresetMetadata()) { - const channelPolicyKeys = [ - ...preset.policyKeys, - ...Object.values(preset.agentPolicyKeys).flatMap((keys) => keys ?? []), - ]; - if (!channelPolicyKeys.some((key) => policyKeys.has(key))) continue; - const manifest = resolveChannelManifest(preset.channelId); - if (manifest && !channelSupportedByAgent(manifest, agent)) return manifest; - } - return null; -} - export function listSandboxChannels(sandboxName: string) { const agent = resolveAgentForSandbox(sandboxName); const availableChannels = availableManifestChannelsForAgent(agent); diff --git a/src/lib/sandbox/version.ts b/src/lib/sandbox/version.ts index adf16bd9363..9f6ec42e943 100644 --- a/src/lib/sandbox/version.ts +++ b/src/lib/sandbox/version.ts @@ -73,7 +73,7 @@ export interface VersionCheckOptions { * Resolve the agent definition for a sandbox. * Falls back to "openclaw" when the sandbox has no agent set. */ -export function resolveAgentForSandbox(sandboxName: string): ReturnType { +function resolveAgentForSandbox(sandboxName: string): ReturnType { const sb = registry.getSandbox(sandboxName); const agentName = sb?.agent || "openclaw"; return loadAgent(agentName); diff --git a/test/policy-add-deepagents-rejection.test.ts b/test/policy-add-deepagents-rejection.test.ts deleted file mode 100644 index 7f695004144..00000000000 --- a/test/policy-add-deepagents-rejection.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import { type SpawnSyncReturns, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, it } from "vitest"; - -const repoRoot = path.join(import.meta.dirname, ".."); - -const MESSAGING_CHANNELS = ["telegram", "discord", "slack", "wechat", "whatsapp"] as const; - -function runScript( - scriptBody: string, - extraFiles: Record = {}, -): SpawnSyncReturns { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-6185-")); - const scriptPath = path.join(tmpDir, "script.js"); - fs.writeFileSync(scriptPath, scriptBody); - for (const [name, content] of Object.entries(extraFiles)) { - fs.writeFileSync(path.join(tmpDir, name), content); - } - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - NEMOCLAW_NON_INTERACTIVE: "1", - }, - timeout: 15000, - }); - fs.rmSync(tmpDir, { recursive: true, force: true }); - return result; -} - -function parseResultPayload>( - result: SpawnSyncReturns, -): T { - const marker = result.stdout.lastIndexOf("__RESULT__"); - assert.ok( - marker >= 0, - `no __RESULT__ marker in stdout:\n${result.stdout}\n---stderr---\n${result.stderr}`, - ); - return JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()) as T; -} - -function buildPreamble(agentName: string): string { - const d = (p: string) => - JSON.stringify(path.join(repoRoot, "src", "lib", p.replace(/\.js$/, ".ts"))); - return String.raw` -const onboard = require(${d("onboard.js")}); -onboard.isNonInteractive = () => true; - -const credentials = require(${d("credentials/store.js")}); -const promptCalls = []; -credentials.prompt = async (msg) => { promptCalls.push(msg); return ""; }; - -const registry = require(${d("state/registry.js")}); -registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(agentName)} }); - -const agentDefs = require(${d("agent/defs.js")}); -agentDefs.loadAgent = () => ({ name: ${JSON.stringify(agentName)} }); - -const policies = require(${d("policy/index.js")}); -const policyCalls = { loadPreset: [], applyPreset: [] }; -policies.listPresets = () => [ - { name: "pypi", description: "Python Package Index access" }, - { name: "telegram", description: "Telegram API access" }, - { name: "discord", description: "Discord API access" }, - { name: "slack", description: "Slack API access" }, - { name: "wechat", description: "WeChat API access" }, - { name: "whatsapp", description: "WhatsApp API access" }, -]; -policies.getAppliedPresets = () => []; -policies.loadPreset = (name) => { policyCalls.loadPreset.push(name); return "network_policies:\n stub: {}\n"; }; -policies.getPresetEndpoints = () => ["api.telegram.org"]; -policies.getPresetValidationWarning = () => null; -policies.applyPreset = (name, preset) => { policyCalls.applyPreset.push({ name, preset }); return true; }; -policies.selectFromList = async () => null; - -const policyModule = require(${d("actions/sandbox/policy-channel.js")}); - -let exitCode = null; -process.exit = (code) => { exitCode = code; throw new Error("__INTERCEPTED_EXIT__:" + code); }; - -const logs = []; -console.log = (...args) => { logs.push(args.map(String).join(" ")); }; -const errors = []; -console.error = (...args) => { errors.push(args.map(String).join(" ")); }; - -module.exports = { - policyModule, - policyCalls, - promptCalls, - logs, - errors, - getExitCode: () => exitCode, -}; -`; -} - -function runPolicyAdd(agentName: string, preset: string) { - const script = `${buildPreamble(agentName)} -const ctx = module.exports; -(async () => { - let caught = null; - try { - await ctx.policyModule.addSandboxPolicy("test-sb", { preset: ${JSON.stringify(preset)}, yes: true }); - } catch (err) { - if (!String(err && err.message).startsWith("__INTERCEPTED_EXIT__")) { - caught = { message: String(err && err.message), stack: err && err.stack }; - } - } - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - exitCode: ctx.getExitCode(), - logs: ctx.logs, - errors: ctx.errors, - policyCalls: ctx.policyCalls, - promptCalls: ctx.promptCalls, - unexpectedError: caught, - }) + "\\n"); -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script crashed: ${result.stderr}\n${result.stdout}`); - return parseResultPayload<{ - exitCode: number; - logs: string[]; - errors: string[]; - policyCalls: { loadPreset: string[]; applyPreset: unknown[] }; - promptCalls: string[]; - unexpectedError: { message: string; stack: string } | null; - }>(result); -} - -const MESSAGING_POLICY_KEYS = [ - ["telegram_bot", "api.telegram.org"], - ["discord", "discord.com"], - ["slack", "api.slack.com"], - ["wechat_bridge", "api.weixin.qq.com"], - ["whatsapp", "graph.facebook.com"], - ["teams", "graph.microsoft.com"], -] as const; - -function runPolicyAddFromFile(agentName: string, presetYamlContent: string) { - const script = `${buildPreamble(agentName)} -const path = require("node:path"); -const ctx = module.exports; -(async () => { - let caught = null; - try { - const filePath = path.join(process.env.HOME, "custom-preset.yaml"); - await ctx.policyModule.addSandboxPolicy("test-sb", { fromFile: filePath, yes: true }); - } catch (err) { - if (!String(err && err.message).startsWith("__INTERCEPTED_EXIT__")) { - caught = { message: String(err && err.message), stack: err && err.stack }; - } - } - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - exitCode: ctx.getExitCode(), - logs: ctx.logs, - errors: ctx.errors, - policyCalls: ctx.policyCalls, - promptCalls: ctx.promptCalls, - unexpectedError: caught, - }) + "\\n"); -})(); -`; - const result = runScript(script, { "custom-preset.yaml": presetYamlContent }); - assert.equal(result.status, 0, `script crashed: ${result.stderr}\n${result.stdout}`); - return parseResultPayload<{ - exitCode: number; - logs: string[]; - errors: string[]; - policyCalls: { loadPreset: string[]; applyPreset: unknown[] }; - promptCalls: string[]; - unexpectedError: { message: string; stack: string } | null; - }>(result); -} - -describe("addSandboxPolicy custom preset (--from-file) channel/agent gate (behaviour)", () => { - it.each( - MESSAGING_POLICY_KEYS, - )("DeepAgents policy-add --from-file with a '%s' policy key exits nonzero before any disclosure, prompt, or apply", (policyKey, host) => { - const presetYaml = `preset:\n name: my-custom-${policyKey.replace(/_/g, "-")}\nnetwork_policies:\n ${policyKey}:\n host: ${host}\n`; - const payload = runPolicyAddFromFile("langchain-deepagents-code", presetYaml); - - assert.equal( - payload.unexpectedError, - null, - `unexpected exception: ${payload.unexpectedError?.stack}`, - ); - assert.equal(payload.exitCode, 1, "expected addSandboxPolicy to exit with code 1"); - assert.ok( - payload.errors.some((msg) => /does not support agent 'langchain-deepagents-code'/.test(msg)), - `missing unsupported channel-agent error in stderr: ${JSON.stringify(payload.errors)}`, - ); - assert.ok( - payload.logs.every((msg) => !/Endpoints that would be opened/.test(msg)), - `endpoint disclosure must not print before the gate: ${JSON.stringify(payload.logs)}`, - ); - assert.deepEqual(payload.promptCalls, [], "prompt must not run before the gate"); - }); -}); - -describe("addSandboxPolicy channel/agent gate (behaviour)", () => { - it.each( - MESSAGING_CHANNELS, - )("DeepAgents policy-add %s exits nonzero before any disclosure, prompt, or apply", (channel) => { - const payload = runPolicyAdd("langchain-deepagents-code", channel); - - assert.equal( - payload.unexpectedError, - null, - `unexpected exception: ${payload.unexpectedError?.stack}`, - ); - assert.equal(payload.exitCode, 1, "expected addSandboxPolicy to exit with code 1"); - assert.ok( - payload.errors.some((msg) => - new RegExp(`Channel '${channel}' does not support agent 'langchain-deepagents-code'`).test( - msg, - ), - ), - `missing unsupported channel-agent error in stderr: ${JSON.stringify(payload.errors)}`, - ); - assert.ok( - payload.logs.every((msg) => !/Endpoints that would be opened/.test(msg)), - `endpoint disclosure must not print before the gate: ${JSON.stringify(payload.logs)}`, - ); - assert.deepEqual(payload.promptCalls, [], "prompt must not run before the gate"); - assert.deepEqual( - payload.policyCalls.applyPreset, - [], - "applyPreset must not run before the gate", - ); - assert.deepEqual(payload.policyCalls.loadPreset, [], "loadPreset must not run before the gate"); - }); -}); diff --git a/test/policy-channel-agent-resolution.test.ts b/test/policy-channel-agent-resolution.test.ts index 19a789fa85e..6c5b8d9c44b 100644 --- a/test/policy-channel-agent-resolution.test.ts +++ b/test/policy-channel-agent-resolution.test.ts @@ -126,11 +126,11 @@ registry.registerSandbox({ const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); const text = [...payload.output, ...payload.errors].join("\n"); expect(payload.exitCode).toBe(1); - expect(text).toContain("Channel 'telegram' does not support agent 'langchain-deepagents-code'"); - expect(text).toContain("Channel-supported agents: openclaw, hermes."); - expect(text).toContain("Channels supported by agent 'langchain-deepagents-code': (none)."); - expect(text).not.toContain("Unknown preset"); - expect(text).not.toContain("Valid presets:"); + expect(text).toContain("Unknown preset 'telegram'."); + expect(text).toContain("Valid presets:"); + expect(text).not.toContain("telegram,"); + expect(text).not.toContain("not supported for agent"); + expect(text).not.toContain("Terminal-runtime agents do not run inbound messaging bridges."); expect(text).not.toContain("Preset not found"); expect(text).not.toContain("Endpoints that would be opened"); expect(text).not.toContain("Apply 'telegram'"); From 9bc89c9379c81160146a8f3fb3dae3c00d656a08 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 2 Jul 2026 22:43:49 +0530 Subject: [PATCH 10/16] test(policy): avoid conditional in initial policy regression Signed-off-by: San Dang --- src/lib/onboard/initial-policy-real-policy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 8ee8a7bf0df..0b39eacfc22 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -46,7 +46,7 @@ function readPreparedPolicy(prepared: { policyPath: string; cleanup?: () => boolean; }): PolicyDocument { - if (prepared.cleanup) cleanupFns.push(prepared.cleanup); + cleanupFns.push(() => prepared.cleanup?.()); return YAML.parse(fs.readFileSync(prepared.policyPath, "utf-8")) as PolicyDocument; } From b7eb0cd4b2285f1e52564d85ab2a9d39ce553f95 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 2 Jul 2026 23:21:42 +0530 Subject: [PATCH 11/16] fix(policy): scope setup presets to sandbox agent Signed-off-by: San Dang --- src/lib/onboard/policy-resume-selection.ts | 6 ++--- src/lib/onboard/policy-selection.ts | 4 +-- src/lib/policy/index.ts | 12 ++++++++- test/policy-tiers-onboard.test.ts | 29 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index d87fe916ae7..8a184effcbf 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -23,11 +23,11 @@ type Preset = { name: string; access?: string }; type PoliciesApi = { setupPolicyPresetSupported( name: string, - options?: { webSearchSupported?: boolean | null }, + options?: { webSearchSupported?: boolean | null; agent?: string | null }, ): boolean; listSetupPolicyPresets( sandboxName: string, - options?: { webSearchSupported?: boolean | null }, + options?: { webSearchSupported?: boolean | null; agent?: string | null }, ): Preset[]; listCustomPresets(sandboxName: string): Preset[]; getAppliedPresets(sandboxName: string): string[]; @@ -54,7 +54,7 @@ export function preparePolicyPresetResumeSelection( tierName?: string | null; }, ): PreparedPolicyResumeSelection { - const supportOptions = { webSearchSupported: options.webSearchSupported }; + const supportOptions = { webSearchSupported: options.webSearchSupported, agent: options.agent }; const appliedPolicyPresets = deps.policies.getAppliedPresets(sandboxName); const selectablePolicyPresets = [ ...filterSetupPolicyPresetsForAgent( diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 804a4723577..e72f1fe67ca 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -30,7 +30,7 @@ import { withPolicyApplicationTrace } from "./tracing"; export { suppressedAgentRequiredPresets } from "./policy-tier-suppression"; type Preset = { name: string; access?: string }; -type SupportOptions = { webSearchSupported?: boolean | null }; +type SupportOptions = { webSearchSupported?: boolean | null; agent?: string | null }; type PoliciesApi = { setupPolicyPresetSupported(name: string, options?: SupportOptions): boolean; listSetupPolicyPresets(sandboxName: string, options?: SupportOptions): Preset[]; @@ -234,7 +234,7 @@ async function setupPoliciesWithSelectionInner( deps.step(8, 8, "Policy presets"); - const supportOptions = { webSearchSupported: options.webSearchSupported }; + const supportOptions = { webSearchSupported: options.webSearchSupported, agent }; const allPresets = filterSetupPolicyPresetsForAgent( deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), agent, diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 8061fd415ca..e105b4ed591 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -63,6 +63,7 @@ type MergePresetNamesOptions = { type SetupPolicyPresetSupportOptions = { webSearchSupported?: boolean | null; + agent?: string | null; }; function isPolicyDocument(value: PolicyValue): value is PolicyDocument { @@ -356,7 +357,16 @@ function listSetupPolicyPresets( sandboxName: string, options: SetupPolicyPresetSupportOptions = {}, ): PresetInfo[] { - return [...filterSetupPolicyPresets(listPresets(), options), ...listCustomPresets(sandboxName)]; + let sandboxAgent: string | null = null; + try { + sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; + } catch { + sandboxAgent = null; + } + return [ + ...filterSetupPolicyPresets(listPresets({ agent: options.agent ?? sandboxAgent }), options), + ...listCustomPresets(sandboxName), + ]; } function clampSetupPolicyPresetNames( diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index c8ddd4e8e4e..9cd3b5b2d38 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -795,6 +795,35 @@ console.log = () => {}; assert.deepEqual(payload.removedCalls, ["nous-web"]); }); + it("rejects unsupported Deep Agents messaging presets during setup policy selection", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "custom", + policyPresets: "telegram", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +policies.applyPreset = () => { process.stdout.write("UNEXPECTED_APPLY\n"); return true; }; +policies.applyPresets = () => { process.stdout.write("UNEXPECTED_APPLY_BATCH\n"); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + const applied = await setupPoliciesWithSelection("test-sb", { agent: "langchain-deepagents-code" }); + process.stdout.write(JSON.stringify({ applied }) + "\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /Unknown policy preset\(s\): telegram/); + assert.doesNotMatch(result.stdout, /UNEXPECTED_APPLY/); + }); + it("preserves a resumed custom preset whose name matches an unsupported built-in", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); const script = From f11f99c9354dcaa0cc56a3d7e0a6f50dce56bf3a Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 2 Jul 2026 23:29:22 +0530 Subject: [PATCH 12/16] test(policy): keep setup regression under size budget Signed-off-by: San Dang --- test/policy-channel-agent-resolution.test.ts | 31 ++++++++++++++++++++ test/policy-tiers-onboard.test.ts | 29 ------------------ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/test/policy-channel-agent-resolution.test.ts b/test/policy-channel-agent-resolution.test.ts index 6c5b8d9c44b..b7d3b06d260 100644 --- a/test/policy-channel-agent-resolution.test.ts +++ b/test/policy-channel-agent-resolution.test.ts @@ -89,6 +89,37 @@ process.stdout.write("__RESULT__" + JSON.stringify({ gatewayPresets })); const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); expect(payload.gatewayPresets).toEqual(["npm"]); }); + it("setup policy preset catalog omits unsupported Deep Agents messaging policies (#6185)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-setup-agent-")); + const script = String.raw` +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ + name: "deepagents-sandbox", + agent: "langchain-deepagents-code", + policies: [], +}); +const names = policies.listSetupPolicyPresets("deepagents-sandbox").map((preset) => preset.name); +process.stdout.write("__RESULT__" + JSON.stringify({ names })); +`; + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir }, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); + expect(payload.names).toContain("npm"); + expect(payload.names).not.toContain("telegram"); + expect(payload.names).not.toContain("discord"); + expect(payload.names).not.toContain("slack"); + expect(payload.names).not.toContain("teams"); + expect(payload.names).not.toContain("whatsapp"); + expect(payload.names).not.toContain("wechat"); + }); + it("policy-add treats unsupported Deep Agents messaging policy as unknown before preview or prompt (#6185)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-agent-gate-")); const script = String.raw` diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 9cd3b5b2d38..c8ddd4e8e4e 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -795,35 +795,6 @@ console.log = () => {}; assert.deepEqual(payload.removedCalls, ["nous-web"]); }); - it("rejects unsupported Deep Agents messaging presets during setup policy selection", () => { - const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); - const script = - buildPreamble({ - tierEnv: "balanced", - policyMode: "custom", - policyPresets: "telegram", - stubOpenshellBin: true, - runCaptureReturn: "Running", - }) + - String.raw` -const policies = require(${policiesPath}); -policies.applyPreset = () => { process.stdout.write("UNEXPECTED_APPLY\n"); return true; }; -policies.applyPresets = () => { process.stdout.write("UNEXPECTED_APPLY_BATCH\n"); return true; }; -policies.getAppliedPresets = () => []; - -console.log = () => {}; - -(async () => { - const applied = await setupPoliciesWithSelection("test-sb", { agent: "langchain-deepagents-code" }); - process.stdout.write(JSON.stringify({ applied }) + "\n"); -})(); -`; - const result = runScript(script); - assert.equal(result.status, 1, result.stderr); - assert.match(result.stderr, /Unknown policy preset\(s\): telegram/); - assert.doesNotMatch(result.stdout, /UNEXPECTED_APPLY/); - }); - it("preserves a resumed custom preset whose name matches an unsupported built-in", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); const script = From 1e8f9ace083172cf7b6057d745882d2ffc8fd65b Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 3 Jul 2026 11:47:18 +0700 Subject: [PATCH 13/16] fix(policy): explain unsupported messaging presets --- .../sandbox/policy-channel-policy.test.ts | 52 +++++++++++-------- src/lib/actions/sandbox/policy-channel.ts | 16 ++++++ 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 0e4424a6d95..5cedae45b56 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -235,29 +235,35 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); - it("treats messaging channel policy presets unavailable to terminal-runtime agents as unknown before preview or prompt", async () => { - arrangeSandbox("langchain-deepagents-code"); - vi.spyOn(policies, "listPresets").mockReturnValue([ - { name: "npm", description: "npm and Yarn registry access" }, - { name: "pypi", description: "Python Package Index access" }, - { name: "tavily", description: "Tavily Search API access" }, - ]); - - await expect( - captureExit(() => addSandboxPolicy("test-sandbox", { preset: "telegram", yes: true })), - ).resolves.toBe(1); - - const output = printedText(); - expect(output).toContain("Unknown preset 'telegram'."); - expect(output).toContain("Valid presets: npm, pypi, tavily"); - expect(output).not.toContain("not supported for agent"); - expect(output).not.toContain("Channels supported by agent"); - expect(output).not.toContain("Preset not found"); - expect(output).not.toContain("Endpoints that would be opened"); - expect(promptMock).not.toHaveBeenCalled(); - expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); - expect(applyPresetMock).not.toHaveBeenCalled(); - }); + it.each(["telegram", "discord", "slack", "wechat", "whatsapp"])( + "rejects Deep Agents policy-add %s with unsupported-agent wording before preview or prompt (#6185)", + async (preset) => { + arrangeSandbox("langchain-deepagents-code"); + vi.spyOn(policies, "listPresets").mockReturnValue([ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + { name: "tavily", description: "Tavily Search API access" }, + ]); + + await expect( + captureExit(() => addSandboxPolicy("test-sandbox", { preset, yes: true })), + ).resolves.toBe(1); + + const output = printedText(); + expect(output).toContain( + `Preset '${preset}' is not a supported channel for agent 'langchain-deepagents-code'`, + ); + expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)"); + expect(output).not.toContain("Unknown preset"); + expect(output).not.toContain("Valid presets"); + expect(output).not.toContain("Preset not found"); + expect(output).not.toContain("Endpoints that would be opened"); + expect(output).not.toContain(`Apply '${preset}'`); + expect(promptMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + }, + ); it.each([ { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 39faa945ba2..dc8032f6891 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -147,6 +147,22 @@ export async function addSandboxPolicy( let answer = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); + const channelManifest = resolveChannelManifest(normalized); + if (channelManifest) { + const agent = resolveAgentForSandbox(sandboxName); + if (!channelSupportedByAgent(channelManifest, agent)) { + console.error( + ` Preset '${channelManifest.id}' is not a supported channel for agent '${agent.name}' in sandbox '${sandboxName}'.`, + ); + console.error( + ` Channel-supported agents: ${formatSupportedMessagingAgentIds(channelManifest.supportedAgents)}.`, + ); + console.error( + ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, + ); + process.exit(1); + } + } const preset = allPresets.find((item: { name: string }) => item.name === normalized); if (!preset) { console.error(` Unknown preset '${presetArg}'.`); From c56fa2d28dd2a77fd9adbba7edbdda647ec53802 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 3 Jul 2026 11:52:02 +0700 Subject: [PATCH 14/16] chore(policy): format unsupported preset test --- .../sandbox/policy-channel-policy.test.ts | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 5cedae45b56..46d36dd7d16 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -235,35 +235,38 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); - it.each(["telegram", "discord", "slack", "wechat", "whatsapp"])( - "rejects Deep Agents policy-add %s with unsupported-agent wording before preview or prompt (#6185)", - async (preset) => { - arrangeSandbox("langchain-deepagents-code"); - vi.spyOn(policies, "listPresets").mockReturnValue([ - { name: "npm", description: "npm and Yarn registry access" }, - { name: "pypi", description: "Python Package Index access" }, - { name: "tavily", description: "Tavily Search API access" }, - ]); - - await expect( - captureExit(() => addSandboxPolicy("test-sandbox", { preset, yes: true })), - ).resolves.toBe(1); - - const output = printedText(); - expect(output).toContain( - `Preset '${preset}' is not a supported channel for agent 'langchain-deepagents-code'`, - ); - expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)"); - expect(output).not.toContain("Unknown preset"); - expect(output).not.toContain("Valid presets"); - expect(output).not.toContain("Preset not found"); - expect(output).not.toContain("Endpoints that would be opened"); - expect(output).not.toContain(`Apply '${preset}'`); - expect(promptMock).not.toHaveBeenCalled(); - expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); - expect(applyPresetMock).not.toHaveBeenCalled(); - }, - ); + it.each([ + "telegram", + "discord", + "slack", + "wechat", + "whatsapp", + ])("rejects Deep Agents policy-add %s with unsupported-agent wording before preview or prompt (#6185)", async (preset) => { + arrangeSandbox("langchain-deepagents-code"); + vi.spyOn(policies, "listPresets").mockReturnValue([ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + { name: "tavily", description: "Tavily Search API access" }, + ]); + + await expect( + captureExit(() => addSandboxPolicy("test-sandbox", { preset, yes: true })), + ).resolves.toBe(1); + + const output = printedText(); + expect(output).toContain( + `Preset '${preset}' is not a supported channel for agent 'langchain-deepagents-code'`, + ); + expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)"); + expect(output).not.toContain("Unknown preset"); + expect(output).not.toContain("Valid presets"); + expect(output).not.toContain("Preset not found"); + expect(output).not.toContain("Endpoints that would be opened"); + expect(output).not.toContain(`Apply '${preset}'`); + expect(promptMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + }); it.each([ { From 8729a1004f04147c291d276e3e6fa509d52cb2b5 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 3 Jul 2026 12:01:02 +0700 Subject: [PATCH 15/16] Revert "chore(policy): format unsupported preset test" This reverts commit c56fa2d28dd2a77fd9adbba7edbdda647ec53802. --- .../sandbox/policy-channel-policy.test.ts | 61 +++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 46d36dd7d16..5cedae45b56 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -235,38 +235,35 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); - it.each([ - "telegram", - "discord", - "slack", - "wechat", - "whatsapp", - ])("rejects Deep Agents policy-add %s with unsupported-agent wording before preview or prompt (#6185)", async (preset) => { - arrangeSandbox("langchain-deepagents-code"); - vi.spyOn(policies, "listPresets").mockReturnValue([ - { name: "npm", description: "npm and Yarn registry access" }, - { name: "pypi", description: "Python Package Index access" }, - { name: "tavily", description: "Tavily Search API access" }, - ]); - - await expect( - captureExit(() => addSandboxPolicy("test-sandbox", { preset, yes: true })), - ).resolves.toBe(1); - - const output = printedText(); - expect(output).toContain( - `Preset '${preset}' is not a supported channel for agent 'langchain-deepagents-code'`, - ); - expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)"); - expect(output).not.toContain("Unknown preset"); - expect(output).not.toContain("Valid presets"); - expect(output).not.toContain("Preset not found"); - expect(output).not.toContain("Endpoints that would be opened"); - expect(output).not.toContain(`Apply '${preset}'`); - expect(promptMock).not.toHaveBeenCalled(); - expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); - expect(applyPresetMock).not.toHaveBeenCalled(); - }); + it.each(["telegram", "discord", "slack", "wechat", "whatsapp"])( + "rejects Deep Agents policy-add %s with unsupported-agent wording before preview or prompt (#6185)", + async (preset) => { + arrangeSandbox("langchain-deepagents-code"); + vi.spyOn(policies, "listPresets").mockReturnValue([ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + { name: "tavily", description: "Tavily Search API access" }, + ]); + + await expect( + captureExit(() => addSandboxPolicy("test-sandbox", { preset, yes: true })), + ).resolves.toBe(1); + + const output = printedText(); + expect(output).toContain( + `Preset '${preset}' is not a supported channel for agent 'langchain-deepagents-code'`, + ); + expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)"); + expect(output).not.toContain("Unknown preset"); + expect(output).not.toContain("Valid presets"); + expect(output).not.toContain("Preset not found"); + expect(output).not.toContain("Endpoints that would be opened"); + expect(output).not.toContain(`Apply '${preset}'`); + expect(promptMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + }, + ); it.each([ { From 367460762eb92a995145d512c030670a5778086d Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 3 Jul 2026 12:01:04 +0700 Subject: [PATCH 16/16] Revert "fix(policy): explain unsupported messaging presets" This reverts commit 1e8f9ace083172cf7b6057d745882d2ffc8fd65b. --- .../sandbox/policy-channel-policy.test.ts | 52 ++++++++----------- src/lib/actions/sandbox/policy-channel.ts | 16 ------ 2 files changed, 23 insertions(+), 45 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 5cedae45b56..0e4424a6d95 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -235,35 +235,29 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); - it.each(["telegram", "discord", "slack", "wechat", "whatsapp"])( - "rejects Deep Agents policy-add %s with unsupported-agent wording before preview or prompt (#6185)", - async (preset) => { - arrangeSandbox("langchain-deepagents-code"); - vi.spyOn(policies, "listPresets").mockReturnValue([ - { name: "npm", description: "npm and Yarn registry access" }, - { name: "pypi", description: "Python Package Index access" }, - { name: "tavily", description: "Tavily Search API access" }, - ]); - - await expect( - captureExit(() => addSandboxPolicy("test-sandbox", { preset, yes: true })), - ).resolves.toBe(1); - - const output = printedText(); - expect(output).toContain( - `Preset '${preset}' is not a supported channel for agent 'langchain-deepagents-code'`, - ); - expect(output).toContain("Channels supported by agent 'langchain-deepagents-code': (none)"); - expect(output).not.toContain("Unknown preset"); - expect(output).not.toContain("Valid presets"); - expect(output).not.toContain("Preset not found"); - expect(output).not.toContain("Endpoints that would be opened"); - expect(output).not.toContain(`Apply '${preset}'`); - expect(promptMock).not.toHaveBeenCalled(); - expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); - expect(applyPresetMock).not.toHaveBeenCalled(); - }, - ); + it("treats messaging channel policy presets unavailable to terminal-runtime agents as unknown before preview or prompt", async () => { + arrangeSandbox("langchain-deepagents-code"); + vi.spyOn(policies, "listPresets").mockReturnValue([ + { name: "npm", description: "npm and Yarn registry access" }, + { name: "pypi", description: "Python Package Index access" }, + { name: "tavily", description: "Tavily Search API access" }, + ]); + + await expect( + captureExit(() => addSandboxPolicy("test-sandbox", { preset: "telegram", yes: true })), + ).resolves.toBe(1); + + const output = printedText(); + expect(output).toContain("Unknown preset 'telegram'."); + expect(output).toContain("Valid presets: npm, pypi, tavily"); + expect(output).not.toContain("not supported for agent"); + expect(output).not.toContain("Channels supported by agent"); + expect(output).not.toContain("Preset not found"); + expect(output).not.toContain("Endpoints that would be opened"); + expect(promptMock).not.toHaveBeenCalled(); + expect(loadPresetForSandboxMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + }); it.each([ { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index dc8032f6891..39faa945ba2 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -147,22 +147,6 @@ export async function addSandboxPolicy( let answer = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); - const channelManifest = resolveChannelManifest(normalized); - if (channelManifest) { - const agent = resolveAgentForSandbox(sandboxName); - if (!channelSupportedByAgent(channelManifest, agent)) { - console.error( - ` Preset '${channelManifest.id}' is not a supported channel for agent '${agent.name}' in sandbox '${sandboxName}'.`, - ); - console.error( - ` Channel-supported agents: ${formatSupportedMessagingAgentIds(channelManifest.supportedAgents)}.`, - ); - console.error( - ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, - ); - process.exit(1); - } - } const preset = allPresets.find((item: { name: string }) => item.name === normalized); if (!preset) { console.error(` Unknown preset '${presetArg}'.`);