diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 10cb64e9b3a..983084bc75e 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -111,7 +111,7 @@ Dynamic changes apply a policy update to a running sandbox without restarting it > A running sandbox's live policy is the baseline policy plus every preset that was layered on during onboarding. > Applying a file that contains only the baseline (or only a single preset) silently drops every other preset that was in effect. -### Option 1: Add a Preset File and Use `policy-add` (Recommended) +### Add a Preset File with `policy-add` (Recommended) This path preserves existing policy entries and is the only NemoClaw-supported flow for merging new entries into a running policy. @@ -147,31 +147,20 @@ Provider-composed `_provider_*` entries are excluded because OpenShell reserves Existing presets and the baseline remain in place. The preset file under `presets/` also persists across sandbox recreations. -### Option 2: Snapshot, Edit, and Set with OpenShell +### Export, Edit, and Set the Base Policy Use this path only when you cannot add a file under the NemoClaw source tree. -Start from the current live policy so the presets layered on at onboarding stay in the file you apply. +Start from the current base policy so the presets layered on at onboarding stay in the file you apply. Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. -Strip the OpenShell metadata header before editing the file, then validate the raw policy shape before replacing your editable copy. -The command order below matches the commands NemoClaw emits internally. +Use NemoClaw to validate the base policy and strip the OpenShell metadata header before writing your editable copy. ```bash -# shellcheck shell=bash -# Source-of-truth review: -# invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. -# sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. -# whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. -# regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. -# removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. -tmp_policy=$(mktemp) -openshell policy get --base my-assistant \ - | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ - > "$tmp_policy" \ - && grep -q '^version:' "$tmp_policy" \ - && grep -q '^network_policies:' "$tmp_policy" \ - && mv "$tmp_policy" current-policy.yaml +$$nemoclaw my-assistant policy-get > current-policy.yaml ``` +The command exits non-zero instead of emitting a partial policy when retrieval or validation fails. +Do not use `--raw` for this workflow because raw output retains the metadata header. + Edit `current-policy.yaml` to add your entries under `network_policies:`, keeping the existing `version` field intact, then apply: ```bash diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 7aa39cd17ef..7c6866c848e 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -405,26 +405,16 @@ Use `policy-list` for normal preset state: $$nemoclaw my-assistant policy-list ``` -Use OpenShell when you need an editable copy of the live policy. +Use the NemoClaw policy export when you need an editable copy of the round-trippable base policy. Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash -# shellcheck shell=bash -# Source-of-truth review: -# invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. -# sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. -# whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. -# regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. -# removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. -tmp_policy=$(mktemp) -openshell policy get --base my-assistant \ - | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ - > "$tmp_policy" \ - && grep -q '^version:' "$tmp_policy" \ - && grep -q '^network_policies:' "$tmp_policy" \ - && mv "$tmp_policy" current-policy.yaml +$$nemoclaw my-assistant policy-get > current-policy.yaml ``` +The export strips OpenShell metadata and exits non-zero if the base policy cannot be retrieved or validated. +Do not add `--raw` when you plan to edit and reapply the file. + If you must replace the live policy, edit the policy file and apply it back to the sandbox: ```bash @@ -440,4 +430,4 @@ Use `$$nemoclaw my-assistant policy-add` for maintained NemoClaw presets. - [Approve or Deny Agent Network Requests](approve-network-requests) for the interactive OpenShell TUI flow. - [Customize the Sandbox Network Policy](customize-network-policy) for static policy edits and raw OpenShell policy files. - [Messaging Channels](../manage-sandboxes/messaging-channels) for Telegram, Discord, Slack, WeChat, and WhatsApp channel configuration. -- [Commands](../reference/commands) for the full `policy-add`, `policy-list`, `policy-remove`, and `channels` command reference. +- [Commands](../reference/commands) for the full `policy-get`, `policy-add`, `policy-list`, `policy-remove`, and `channels` command reference. diff --git a/docs/reference/cli-selection-guide.mdx b/docs/reference/cli-selection-guide.mdx index 6be45cecf76..69bc897e347 100644 --- a/docs/reference/cli-selection-guide.mdx +++ b/docs/reference/cli-selection-guide.mdx @@ -139,27 +139,17 @@ Use `openshell` when the docs explicitly call for a live OpenShell gateway opera openshell policy update --add-endpoint api.example.com:443:read-only:rest:enforce ``` -- Inspect or replace raw OpenShell policy: +- Export the round-trippable OpenShell base policy through NemoClaw, then replace it through OpenShell: Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash - # shellcheck shell=bash - # Source-of-truth review: - # invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. - # sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. - # whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. - # regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. - # removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. - tmp_policy=$(mktemp) - openshell policy get --base \ - | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ - > "$tmp_policy" \ - && grep -q '^version:' "$tmp_policy" \ - && grep -q '^network_policies:' "$tmp_policy" \ - && mv "$tmp_policy" current-policy.yaml + $$nemoclaw policy-get > current-policy.yaml ``` + NemoClaw strips the OpenShell metadata header and exits non-zero if it cannot validate the base policy. + Do not use `--raw` for a file that you plan to reapply. + Edit or review `current-policy.yaml`, then apply it: ```bash @@ -275,8 +265,8 @@ Use `$$nemoclaw policy-add` or `policy-remove` for NemoClaw presets and c NemoClaw merges the new policy with the live policy and reapplies presets during rebuilds. Use `openshell policy update` for precise live endpoint or REST rule changes. -Use `openshell policy get --base ` and `openshell policy set --policy --wait ` only when you need to edit and replace the round-trippable base policy. -Use `--full` only to inspect the effective policy, including provider-composed rules. +Use `$$nemoclaw policy-get` and `openshell policy set --policy --wait ` only when you need to edit and replace the round-trippable base policy. +Use `openshell policy get --full ` only to inspect the effective policy, including provider-composed rules. ### Move Workspace Files diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 747c772d0cf..6aaf02c4343 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1450,6 +1450,29 @@ Managed MCP ownership disables the local-only fallback because exact provider cl $$nemoclaw my-assistant destroy [--yes|-y|--force] [--cleanup-gateway|--no-cleanup-gateway] ``` +### `$$nemoclaw policy-get` + +Export the sandbox's round-trippable OpenShell base policy as YAML. +The command runs `openshell policy get --base`, validates the returned policy, and strips the OpenShell metadata header. +The default output is suitable for review, editing, and later use with `openshell policy set`. +The command exits non-zero when OpenShell fails, returns an empty response, or returns content that is not valid policy YAML. + +```bash +$$nemoclaw my-assistant policy-get > current-policy.yaml +``` + +Use `--raw` only to inspect the unparsed OpenShell response, including its metadata header: + +```bash +$$nemoclaw my-assistant policy-get --raw +``` + +Do not pass `--raw` output to `openshell policy set` because the metadata header is not part of the policy document. + +| Flag | Description | +|------|-------------| +| `--raw` | Print the unparsed `openshell policy get --base` response, including its metadata header. | + ### `$$nemoclaw policy-add` Add a policy preset to a sandbox. @@ -1472,8 +1495,8 @@ If the preset name is unknown or already applied, the command exits non-zero wit 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. -If the live policy read returns non-empty output that NemoClaw cannot parse, the command exits non-zero instead of overwriting the live policy with only the new preset. +Before `policy-add` writes a merged policy, it reads and parses the round-trippable base policy from OpenShell. +If the base policy read returns non-empty output that NemoClaw cannot parse, the command exits non-zero instead of overwriting the live policy with only the new preset. Fix the gateway or policy read problem, then rerun the command. For custom presets, the command also reports when the preset reached the gateway but NemoClaw could not record it in the local sandbox registry, because unrecorded custom presets will not appear in `policy-list` or `status`. Recover or re-onboard the sandbox, then re-apply the custom preset. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 90dca5c0210..6b92282125b 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -270,26 +270,16 @@ Apply policy updates to a running sandbox without restarting: openshell policy update --add-endpoint api.example.com:443:read-only:rest:enforce ``` -To replace the live policy with a complete raw policy file, start from the live policy and use `openshell policy set`. +To replace the live policy with a complete base policy file, export the current base policy and use `openshell policy set`. Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash -# shellcheck shell=bash -# Source-of-truth review: -# invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. -# sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. -# whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. -# regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. -# removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. -tmp_policy=$(mktemp) -openshell policy get --base \ - | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ - > "$tmp_policy" \ - && grep -q '^version:' "$tmp_policy" \ - && grep -q '^network_policies:' "$tmp_policy" \ - && mv "$tmp_policy" current-policy.yaml +$$nemoclaw policy-get > current-policy.yaml ``` +NemoClaw strips the OpenShell metadata header and exits non-zero if it cannot validate the base policy. +Do not add `--raw` when you plan to edit and reapply the file. + Edit or review `current-policy.yaml`, then apply it: ```bash diff --git a/scripts/checks/openshell-policy-mutation-read.ts b/scripts/checks/openshell-policy-mutation-read.ts index 8a7df8dfaf9..012759ac620 100644 --- a/scripts/checks/openshell-policy-mutation-read.ts +++ b/scripts/checks/openshell-policy-mutation-read.ts @@ -34,6 +34,12 @@ interface AuditedMutationRead { } export const MUTATION_READS: readonly AuditedMutationRead[] = [ + { + relativePath: "src/lib/actions/sandbox/policy-get.ts", + expectedReadCalls: 1, + baseCommand: "runCapture(buildPolicyGetCommand(sandboxName))", + fullCommand: "runCapture(buildPolicyGetFullCommand(sandboxName))", + }, { relativePath: "src/lib/policy/index.ts", expectedReadCalls: 6, diff --git a/src/commands/sandbox/policy/get.test.ts b/src/commands/sandbox/policy/get.test.ts new file mode 100644 index 00000000000..54dd087a749 --- /dev/null +++ b/src/commands/sandbox/policy/get.test.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSandboxPolicy: vi.fn(() => ({ raw: "", yaml: "" })), +})); + +vi.mock("../../../lib/actions/sandbox/policy-get", () => ({ + getSandboxPolicy: mocks.getSandboxPolicy, +})); + +import SandboxPolicyGetCommand from "./get"; + +const rootDir = process.cwd(); + +describe("sandbox:policy:get command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("outputs parsed base-policy YAML by default", async () => { + mocks.getSandboxPolicy.mockReturnValue({ + raw: "Version: 1\nHash: abc\nStatus: active\n---\nversion: 1\nnetwork_policies: []", + yaml: "version: 1\nnetwork_policies: []", + }); + + const logSpy = vi.spyOn(SandboxPolicyGetCommand.prototype, "log"); + await SandboxPolicyGetCommand.run(["alpha"], rootDir); + + expect(mocks.getSandboxPolicy).toHaveBeenCalledWith("alpha"); + expect(logSpy).toHaveBeenCalledWith("version: 1\nnetwork_policies: []"); + }); + + it("outputs the unparsed base-policy response with --raw", async () => { + const rawOutput = + "Version: 1\nHash: abc\nStatus: active\n---\nversion: 1\nnetwork_policies: []"; + mocks.getSandboxPolicy.mockReturnValue({ + raw: rawOutput, + yaml: "version: 1\nnetwork_policies: []", + }); + + const logSpy = vi.spyOn(SandboxPolicyGetCommand.prototype, "log"); + await SandboxPolicyGetCommand.run(["alpha", "--raw"], rootDir); + + expect(logSpy).toHaveBeenCalledWith(rawOutput); + }); + + it("exits with error when the base policy is empty", async () => { + mocks.getSandboxPolicy.mockReturnValue({ raw: "", yaml: "" }); + + await expect(SandboxPolicyGetCommand.run(["alpha"], rootDir)).rejects.toThrow( + /Failed to retrieve base policy/, + ); + }); + + it("exits with error when base-policy YAML cannot be parsed", async () => { + mocks.getSandboxPolicy.mockReturnValue({ raw: "some output", yaml: "" }); + + await expect(SandboxPolicyGetCommand.run(["alpha"], rootDir)).rejects.toThrow( + /Failed to parse base policy YAML/, + ); + }); + + it("propagates OpenShell retrieval failures", async () => { + mocks.getSandboxPolicy.mockImplementationOnce(() => { + throw new Error("Failed to retrieve base policy for sandbox 'alpha'."); + }); + + await expect(SandboxPolicyGetCommand.run(["alpha"], rootDir)).rejects.toThrow( + /Failed to retrieve base policy for sandbox 'alpha'/, + ); + }); +}); diff --git a/src/commands/sandbox/policy/get.ts b/src/commands/sandbox/policy/get.ts new file mode 100644 index 00000000000..be50ab0bfbf --- /dev/null +++ b/src/commands/sandbox/policy/get.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; + +import { getSandboxPolicy } from "../../../lib/actions/sandbox/policy-get"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class SandboxPolicyGetCommand extends NemoClawCommand { + static id = "sandbox:policy:get"; + static strict = true; + static summary = "Export the round-trippable sandbox base policy"; + static description = + "Retrieve the OpenShell base policy for a sandbox. By default, strips the OpenShell metadata header and outputs YAML suitable for review, editing, and policy set. Use --raw to emit the unparsed --base response."; + static usage = [" [--raw]"]; + static examples = [ + "<%= config.bin %> sandbox policy get alpha", + "<%= config.bin %> sandbox policy get alpha --raw", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + raw: Flags.boolean({ + description: "Output the unparsed OpenShell --base response, including its metadata header", + default: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxPolicyGetCommand); + + const { raw, yaml } = getSandboxPolicy(args.sandboxName); + + if (!raw) { + this.error("Failed to retrieve base policy from sandbox."); + } + + if (flags.raw) { + this.log(raw); + return; + } + + if (!yaml) { + this.error("Failed to parse base policy YAML from sandbox output."); + } + + this.log(yaml); + } +} diff --git a/src/lib/actions/sandbox/policy-get.test.ts b/src/lib/actions/sandbox/policy-get.test.ts new file mode 100644 index 00000000000..86355772efb --- /dev/null +++ b/src/lib/actions/sandbox/policy-get.test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; + +import { getSandboxPolicy } from "./policy-get"; + +type FakeOpenShell = { + argsPath: string; + output: string; +}; + +const tempDirs: string[] = []; + +function createFakeOpenShell(output: string, exitCode = 0): FakeOpenShell { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-get-")); + tempDirs.push(tempDir); + const argsPath = path.join(tempDir, "args.txt"); + const outputPath = path.join(tempDir, "output.txt"); + const executablePath = path.join(tempDir, "openshell"); + fs.writeFileSync(outputPath, output); + fs.writeFileSync( + executablePath, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >${JSON.stringify(argsPath)}`, + `cat ${JSON.stringify(outputPath)}`, + `exit ${exitCode}`, + ].join("\n"), + { mode: 0o755 }, + ); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", executablePath); + return { argsPath, output }; +} + +describe("getSandboxPolicy", () => { + afterEach(() => { + vi.unstubAllEnvs(); + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("reads --base and strips OpenShell metadata into round-trippable YAML (#6052)", () => { + const yaml = [ + "version: 1", + "filesystem_policy:", + " read_only: []", + "network_policies: {}", + ].join("\n"); + const fake = createFakeOpenShell( + [ + "Version: 1", + "Hash: sha256:abc", + "Status: active", + "Active: 1", + "Created: 2026-07-01T00:00:00Z", + "Loaded: 2026-07-01T00:00:01Z", + "---", + yaml, + "", + ].join("\n"), + ); + + const result = getSandboxPolicy("alpha"); + + expect(fs.readFileSync(fake.argsPath, "utf8").trim()).toBe("policy get --base alpha"); + expect(result.raw).toBe(fake.output.trim()); + expect(result.yaml).toBe(yaml); + expect(YAML.parse(result.yaml)).toEqual({ + version: 1, + filesystem_policy: { read_only: [] }, + network_policies: {}, + }); + }); + + it("returns empty output when OpenShell succeeds without a policy", () => { + const fake = createFakeOpenShell(""); + + expect(getSandboxPolicy("alpha")).toEqual({ raw: "", yaml: "" }); + expect(fs.readFileSync(fake.argsPath, "utf8").trim()).toBe("policy get --base alpha"); + }); + + it("preserves unparsed output while rejecting malformed policy YAML", () => { + const fake = createFakeOpenShell("Version: 1\nHash: sha256:abc\nStatus: active\n"); + + expect(getSandboxPolicy("alpha")).toEqual({ + raw: fake.output.trim(), + yaml: "", + }); + }); + + it("adds sandbox context when the OpenShell subprocess fails", () => { + const fake = createFakeOpenShell("gateway unavailable\n", 42); + + expect(() => getSandboxPolicy("alpha")).toThrow( + /Failed to retrieve base policy for sandbox 'alpha'\. Command failed with status 42/, + ); + expect(fs.readFileSync(fake.argsPath, "utf8").trim()).toBe("policy get --base alpha"); + }); +}); diff --git a/src/lib/actions/sandbox/policy-get.ts b/src/lib/actions/sandbox/policy-get.ts new file mode 100644 index 00000000000..905a58f9809 --- /dev/null +++ b/src/lib/actions/sandbox/policy-get.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + assertOpenshellResolvable, + buildPolicyGetCommand, + parseCurrentPolicy, +} from "../../policy/index"; +import { runCapture } from "../../runner"; + +export interface PolicyGetResult { + raw: string; + yaml: string; +} + +/** Read the round-trippable OpenShell base policy and strip its metadata header. */ +export function getSandboxPolicy(sandboxName: string): PolicyGetResult { + assertOpenshellResolvable(); + let raw: string; + try { + raw = runCapture(buildPolicyGetCommand(sandboxName)); + } catch (cause) { + const detail = cause instanceof Error ? ` ${cause.message}` : ""; + throw new Error(`Failed to retrieve base policy for sandbox '${sandboxName}'.${detail}`, { + cause, + }); + } + return { raw, yaml: raw ? parseCurrentPolicy(raw) : "" }; +} diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 57443e5d43c..9d874ab3af4 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -341,6 +341,14 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { flags: "[--follow] [--tail |-n ] [--since ]", }, ], + "sandbox:policy:get": [ + { + group: "Policy Presets", + order: 16, + description: "Export round-trippable base policy YAML", + flags: "[--raw]", + }, + ], "sandbox:policy:add": [ { group: "Policy Presets", diff --git a/src/lib/cli/public-route-metadata.ts b/src/lib/cli/public-route-metadata.ts index 762dfc1fcc2..917d7c8d271 100644 --- a/src/lib/cli/public-route-metadata.ts +++ b/src/lib/cli/public-route-metadata.ts @@ -27,6 +27,7 @@ export const SANDBOX_ROUTE_OVERRIDES: Record = { "sandbox:hosts:remove": ["hosts-remove"], "sandbox:policy:add": ["policy-add"], "sandbox:policy:explain": ["policy-explain"], + "sandbox:policy:get": ["policy-get"], "sandbox:policy:list": ["policy-list"], "sandbox:policy:remove": ["policy-remove"], }; diff --git a/test/package-contract/cli/command-registry.test.ts b/test/package-contract/cli/command-registry.test.ts index e1f52031328..f245547d0ee 100644 --- a/test/package-contract/cli/command-registry.test.ts +++ b/test/package-contract/cli/command-registry.test.ts @@ -56,16 +56,16 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 57 entries", () => { - // 49 visible + 8 hidden (shields×3 + config get/set/rotate-token + + it("should return exactly 58 entries", () => { + // 50 visible + 8 hidden (shields×3 + config get/set/rotate-token + // inference get/set). - // 49 visible includes the sessions group (root + list + reset + delete + + // 50 visible includes the sessions group (root + list + reset + delete + // export), the agents quartet (add + apply + delete + list), the // singular `agent` passthrough that forwards to `openclaw agent`, and // the download + upload host-side openshell wrappers, plus five MCP // bridge display entries under the `mcp` parent and the gateway restart // command under the `gateway` parent. - expect(sandboxCommands()).toHaveLength(57); + expect(sandboxCommands()).toHaveLength(58); }); it("every entry has scope sandbox", () => { @@ -223,9 +223,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 32 unique action tokens including empty string", () => { + it("returns exactly 33 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(32); + expect(tokens).toHaveLength(33); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "agent", @@ -240,6 +240,7 @@ describe("command-registry", () => { "logs", "policy-add", "policy-explain", + "policy-get", "policy-remove", "policy-list", "hosts-add", diff --git a/test/package-contract/cli/public-argv-translation.test.ts b/test/package-contract/cli/public-argv-translation.test.ts index fce5c628604..991c39c060f 100644 --- a/test/package-contract/cli/public-argv-translation.test.ts +++ b/test/package-contract/cli/public-argv-translation.test.ts @@ -90,6 +90,7 @@ describe("public route/display separation", () => { "sandbox:hosts:remove", "sandbox:policy:add", "sandbox:policy:explain", + "sandbox:policy:get", "sandbox:policy:list", "sandbox:policy:remove", ]); @@ -158,6 +159,11 @@ describe("translatePublicSandboxArgv", () => { "sandbox:policy:add", ["alpha", "--from-file"], ); + expectNative( + translatePublicSandboxArgv("alpha", "policy-get", ["--raw"]), + "sandbox:policy:get", + ["alpha", "--raw"], + ); expectNative( translatePublicSandboxArgv("alpha", "gateway-token", ["--quiet"]), "sandbox:gateway:token", diff --git a/test/policy-roundtrip-docs.test.ts b/test/policy-roundtrip-docs.test.ts index efb4edeaa27..574c53d6925 100644 --- a/test/policy-roundtrip-docs.test.ts +++ b/test/policy-roundtrip-docs.test.ts @@ -1,101 +1,43 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -const DOCS = [ +const ROUND_TRIP_DOCS = [ "docs/network-policy/customize-network-policy.mdx", "docs/network-policy/integration-policy-examples.mdx", "docs/reference/cli-selection-guide.mdx", "docs/reference/network-policies.mdx", ]; -const SOURCE_REVIEW_MARKERS = [ - "invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header.", - "sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project.", - "whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here.", - "regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern.", - "removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML.", -]; - function readDoc(docPath: string): string { return readFileSync(path.join(process.cwd(), docPath), "utf8"); } -function bashBlocks(text: string): string[] { - return [...text.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1] ?? ""); -} - describe("policy round-trip documentation examples", () => { - it("executes the documented extractor against OpenShell 0.0.72 base output", () => { - const extractor = "awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }'"; - const valid = spawnSync("bash", ["-o", "pipefail", "-c", extractor], { - encoding: "utf8", - input: "Version: 1\nHash: sha256:test\n---\nversion: 1\nnetwork_policies: {}\n", - }); - expect(valid.status, valid.stderr).toBe(0); - expect(valid.stdout).toBe("version: 1\nnetwork_policies: {}\n"); - - const missingHeader = spawnSync("bash", ["-o", "pipefail", "-c", extractor], { - encoding: "utf8", - input: "version: 1\nnetwork_policies: {}\n", - }); - expect(missingHeader.status).not.toBe(0); - expect(missingHeader.stdout).toBe(""); - }); - - it("keeps raw policy get/set snippets aligned with NemoClaw's OpenShell command builders", () => { - for (const docPath of DOCS) { + it("uses the NemoClaw base-policy export instead of a metadata-stripping pipeline", () => { + for (const docPath of ROUND_TRIP_DOCS) { const text = readDoc(docPath); expect(text, docPath).toContain("OpenShell 0.0.72+"); - expect(text, docPath).toMatch(/openshell policy get --base (?:my-assistant|)/); expect(text, docPath).toMatch( - /openshell policy set --policy current-policy\.yaml --wait (?:my-assistant|)/, - ); - expect(text, docPath).not.toMatch( - /openshell policy get (?:my-assistant|) --base/, + /\$\$nemoclaw (?:my-assistant|) policy-get > current-policy\.yaml/, ); - expect(text, docPath).not.toMatch(/openshell policy get --full/); - expect(text, docPath).not.toMatch( - /openshell policy set (?:my-assistant|) --policy/, - ); - } - }); - - it("keeps metadata-stripping blocks fail-closed and source-boundary documented", () => { - for (const docPath of DOCS) { - const block = bashBlocks(readDoc(docPath)).find((candidate) => - candidate.includes("tmp_policy=$(mktemp)"), - ); - expect(block, `${docPath} extraction block`).toBeDefined(); - expect(block, docPath).toContain("# shellcheck shell=bash"); - for (const marker of SOURCE_REVIEW_MARKERS) { - expect(block, `${docPath} missing ${marker}`).toContain(marker); - } - expect(block, docPath).toContain( - "awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }'", + expect(text, docPath).toMatch( + /openshell policy set --policy current-policy\.yaml --wait (?:my-assistant|)/, ); - expect(block, docPath).toContain("grep -q '^version:'"); - expect(block, docPath).toContain("grep -q '^network_policies:'"); - expect(block, docPath).not.toContain("openshell policy set"); + expect(text, docPath).not.toContain("tmp_policy=$(mktemp)"); + expect(text, docPath).not.toContain("awk 'found { print }"); } }); - it("keeps reference pages from applying stale policy files after failed extraction", () => { - for (const docPath of [ - "docs/reference/cli-selection-guide.mdx", - "docs/reference/network-policies.mdx", - ]) { - const rawBlocks = bashBlocks(readDoc(docPath)); - const extractionBlock = rawBlocks.find((block) => block.includes("tmp_policy=$(mktemp)")); - const applyBlock = rawBlocks.find((block) => block.includes("openshell policy set --policy")); - expect(extractionBlock, `${docPath} extraction block`).toBeDefined(); - expect(applyBlock, `${docPath} apply block`).toBeDefined(); - expect(extractionBlock).not.toBe(applyBlock); - } + it("documents raw output as diagnostic-only", () => { + const commands = readDoc("docs/reference/commands.mdx"); + expect(commands).toContain("### `$$nemoclaw policy-get`"); + expect(commands).toContain("$$nemoclaw my-assistant policy-get > current-policy.yaml"); + expect(commands).toContain("$$nemoclaw my-assistant policy-get --raw"); + expect(commands).toContain("Do not pass `--raw` output to `openshell policy set`"); }); });