diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 5a63d35c2a5..a132a03ae01 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -37,6 +37,7 @@ on: - "Dockerfile" - "agents/**" - "ci/npm-audit-exceptions.json" + - "ci/pi-agent-qualification-v1-*.json" - "ci/reviewed-npm-audit.json" - "nemoclaw/**" - "nemoclaw-blueprint/**" @@ -44,6 +45,7 @@ on: - "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json" - "src/lib/actions/sandbox/mcp-bridge-*.ts" - "src/lib/actions/sandbox/rebuild-post-restore-phase.ts" + - "src/lib/agent/candidate-authority.ts" - "src/lib/core/json-types.ts" - "src/lib/core/ports.ts" - "src/lib/messaging/**" @@ -1212,11 +1214,12 @@ jobs: pi-candidate: name: Build and validate the Pi candidate managed image (${{ matrix.arch }}) - if: github.repository == 'NVIDIA/NemoClaw' && github.event_name == 'pull_request' + if: github.repository == 'NVIDIA/NemoClaw' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ${{ matrix.runner }} timeout-minutes: 120 permissions: contents: read + packages: write strategy: &pi_candidate_strategy fail-fast: false matrix: @@ -1231,14 +1234,17 @@ jobs: BASE_DOCKERFILE: agents/pi/Dockerfile.base CANDIDATE_IMAGE: nemoclaw-managed-candidate/pi DOCKERFILE: agents/pi/Dockerfile - LOCAL_BASE_REFERENCE: nemoclaw-managed-candidate/pi-base:${{ github.sha }} + LOCAL_BASE_REFERENCE: nemoclaw-managed-candidate/pi-base:${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} PLATFORM: ${{ matrix.platform }} PUBLICATION_COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} REPOSITORY: ghcr.io/nvidia/nemoclaw/pi-sandbox + # Qualification receipts compare image sources with the PR head, never GitHub's synthetic merge. + SOURCE_REVISION: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} steps: &pi_candidate_steps - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Set up Docker Buildx id: buildx @@ -1300,10 +1306,10 @@ jobs: build-contexts: nemoclaw-pi-base=oci-layout://${{ steps.base.outputs.oci }} load: true push: false - tags: nemoclaw-managed-candidate/pi:${{ github.sha }} + tags: nemoclaw-managed-candidate/pi:${{ env.SOURCE_REVISION }} labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.revision=${{ env.SOURCE_REVISION }} io.nvidia.nemoclaw.agent=pi io.nvidia.nemoclaw.managed-image.contract=1 io.nvidia.nemoclaw.managed-image.platform=${{ matrix.platform }} @@ -1319,7 +1325,7 @@ jobs: - name: Validate the Pi candidate runtime contract shell: bash env: - IMAGE_REFERENCE: nemoclaw-managed-candidate/pi:${{ github.sha }} + IMAGE_REFERENCE: nemoclaw-managed-candidate/pi:${{ env.SOURCE_REVISION }} run: | set -euo pipefail image_json="$(docker image inspect "$IMAGE_REFERENCE")" @@ -1348,7 +1354,6 @@ jobs: test -x /usr/local/bin/nemoclaw-managed-bootstrap ' - name: Log in to GHCR - if: github.event_name != 'pull_request' uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} @@ -1358,7 +1363,6 @@ jobs: # consumer alias, so a published candidate stays reachable by digest alone. - name: Publish the Pi candidate image by digest id: publish - if: github.event_name != 'pull_request' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} @@ -1369,7 +1373,7 @@ jobs: outputs: type=image,name=ghcr.io/nvidia/nemoclaw/pi-sandbox,push-by-digest=true,name-canonical=true,push=true labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.revision=${{ env.SOURCE_REVISION }} io.nvidia.nemoclaw.agent=pi io.nvidia.nemoclaw.managed-image.contract=1 io.nvidia.nemoclaw.managed-image.platform=${{ matrix.platform }} @@ -1382,11 +1386,14 @@ jobs: NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root provenance: false sbom: false + - name: Remove Pi publication credentials + if: always() + shell: bash + run: docker logout ghcr.io # The publication above rebuilds the image rather than reusing the # already-validated local one, so revalidate the exact published digest # before the contract records it as the qualified candidate. - name: Validate the published Pi candidate digest - if: github.event_name != 'pull_request' shell: bash env: DIGEST: ${{ steps.publish.outputs.digest }} @@ -1433,8 +1440,8 @@ jobs: ' # The checks above bypass /usr/local/bin/nemoclaw-start with a direct # --entrypoint override, so they never prove the entrypoint itself runs - # correctly. Start the local pull-request image or exact published digest - # through its declared entrypoint with no command, matching a real launch, + # correctly. Start the published digest through its declared entrypoint + # with no command, matching a real launch, # and prove PID 1 drops to the sandbox user, hardens its resource limits, # and persists the trusted proxy environment before it holds the sandbox # open. @@ -1442,19 +1449,13 @@ jobs: shell: bash env: DIGEST: ${{ steps.publish.outputs.digest }} - EVENT_NAME: ${{ github.event_name }} - IMAGE_REFERENCE: nemoclaw-managed-candidate/pi:${{ github.sha }} run: | set -euo pipefail - if [ "$EVENT_NAME" = "pull_request" ]; then - reference="$IMAGE_REFERENCE" - else - if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: the Pi candidate publication did not return an immutable digest." >&2 - exit 1 - fi - reference="${REPOSITORY}@${DIGEST}" + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: the Pi candidate publication did not return an immutable digest." >&2 + exit 1 fi + reference="${REPOSITORY}@${DIGEST}" corporate_ca_dir="$(mktemp -d "$RUNNER_TEMP/pi-candidate-ca.XXXXXX")" corporate_ca="$corporate_ca_dir/corporate-ca.pem" openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ @@ -1571,7 +1572,6 @@ jobs: done ' - name: Record the exact Pi candidate contract - if: github.event_name != 'pull_request' shell: bash env: DIGEST: ${{ steps.publish.outputs.digest }} @@ -1590,7 +1590,7 @@ jobs: --arg image "$REPOSITORY" \ --arg platform "$PLATFORM" \ --arg release "$release" \ - --arg revision "$GITHUB_SHA" \ + --arg revision "$SOURCE_REVISION" \ --arg reference "${REPOSITORY}@${DIGEST}" \ '{ contractVersion: 1, @@ -1615,7 +1615,6 @@ jobs: # This candidate name is deliberately outside that pattern so a published # Pi digest cannot enter the atomic release cohort. - name: Upload the exact Pi candidate contract - if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: managed-candidate-contract-${{ github.run_id }}-${{ github.run_attempt }}-pi-${{ matrix.arch }} diff --git a/ci/cli-test-timing-hints.json b/ci/cli-test-timing-hints.json index 51cbdac4e44..1f990e85f50 100644 --- a/ci/cli-test-timing-hints.json +++ b/ci/cli-test-timing-hints.json @@ -158,8 +158,6 @@ "test/mcp/mcp-add-crash-consistency.test.ts": 43147, "test/mcp/mcp-bridge-destroy-marker-recovery.test.ts": 14345, "test/mcp/mcp-bridge-servers.test.ts": 15199, - "test/mcp/mcp-policy-key-ownership.test.ts": 9878, - "test/mcp/mcp-policy-transition.test.ts": 5011, "test/mcp/mcp-provider-ownership.test.ts": 9896, "test/mcp/mcp-tool-discovery-image-contract.test.ts": 8489, "test/networking/dns-proxy.test.ts": 5827, @@ -200,12 +198,10 @@ "test/runtime/gateway/recover-port-forward.test.ts": 12821, "test/runtime/messaging/telegram-diagnostics.test.ts": 5127, "test/runtime/policy/policies.test.ts": 5377, - "test/runtime/policy/policy-add-remove-session-sync.test.ts": 10393, "test/runtime/policy/policy-channel-agent-resolution.test.ts": 8083, "test/runtime/policy/policy-explain-cli.test.ts": 5249, "test/runtime/policy/policy-openclaw-npm-compatibility.test.ts": 14295, "test/runtime/policy/policy-preset-noop-disclosure.test.ts": 9796, - "test/runtime/policy/policy-tiers-onboard-restricted-stale-otel.test.ts": 5808, "test/runtime/policy/policy-tiers-onboard.test.ts": 8804, "test/runtime/policy/portable-policy-failure-finality.test.ts": 8392, "test/runtime/sandbox/reboot-identity-drift.test.ts": 13632, diff --git a/ci/onboard-entry-composition-budget.json b/ci/onboard-entry-composition-budget.json index 8c495e298d3..2db2ee70668 100644 --- a/ci/onboard-entry-composition-budget.json +++ b/ci/onboard-entry-composition-budget.json @@ -11,9 +11,8 @@ "runOnboard.finalizationDeps.verifyDeployment.getMessagingChannels": 1 }, "policy": { - "createOnboardPolicyApplication.getRecordedPolicyTier": 1, "preflightAuthoritativeRebuildTarget": 1, - "runOnboard": 5, + "runOnboard": 4, "sandboxCreateIntentResolver.getAgentPolicyPath": 1 }, "provider": { diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index 57d88671b9e..8ff11fbbcf7 100644 --- a/ci/pi-agent-qualification-v1-linux-amd64.json +++ b/ci/pi-agent-qualification-v1-linux-amd64.json @@ -3,13 +3,13 @@ "agent": "pi", "platform": "linux/amd64", "image": "ghcr.io/nvidia/nemoclaw/pi-sandbox", - "digest": "sha256:4a50a8ce74d76a6002a1cd0fe65b329528e0e3713c221535b3cfd56237a4fdf0", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:4a50a8ce74d76a6002a1cd0fe65b329528e0e3713c221535b3cfd56237a4fdf0", + "digest": "sha256:492f2161f206644dd82c7206675e0b78f47da4f6c9a4fce23f38a4e2ef4e9b0f", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:492f2161f206644dd82c7206675e0b78f47da4f6c9a4fce23f38a4e2ef4e9b0f", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "d92acac1c40364702eaae92a169a2b06d1bfda4b", + "revision": "f53e91c199a36b1d19cca29ff0c950d9807ebbe1", "release": "v0.1.0", - "cohort": "ghrun-32678646532-1" + "cohort": "ghrun-33220887790-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/pi-agent-qualification-v1-linux-arm64.json b/ci/pi-agent-qualification-v1-linux-arm64.json index 8c39d462fcf..3fb50a14161 100644 --- a/ci/pi-agent-qualification-v1-linux-arm64.json +++ b/ci/pi-agent-qualification-v1-linux-arm64.json @@ -3,13 +3,13 @@ "agent": "pi", "platform": "linux/arm64", "image": "ghcr.io/nvidia/nemoclaw/pi-sandbox", - "digest": "sha256:2f859158f229776f6b5ff441cf4a59432cfb2c37096e4ec879fdcb88ac3e57c3", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:2f859158f229776f6b5ff441cf4a59432cfb2c37096e4ec879fdcb88ac3e57c3", + "digest": "sha256:40de3fc971d91b174ba2bc2193c45b6e3c536f3cb28746b0af97a57e0a1ec543", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:40de3fc971d91b174ba2bc2193c45b6e3c536f3cb28746b0af97a57e0a1ec543", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "d92acac1c40364702eaae92a169a2b06d1bfda4b", + "revision": "f53e91c199a36b1d19cca29ff0c950d9807ebbe1", "release": "v0.1.0", - "cohort": "ghrun-32678646532-1" + "cohort": "ghrun-33220887790-1" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 87b60637a41..04f22a30ab2 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -3,7 +3,7 @@ "fanIn": { "defaultMax": 20, "maxByFile": { - "src/lib/actions/sandbox/mcp-bridge-contracts.ts": 26, + "src/lib/actions/sandbox/mcp-bridge-contracts.ts": 25, "src/lib/actions/sandbox/process-recovery.ts": 27, "src/lib/adapters/docker/index.ts": 43, "src/lib/adapters/openshell/client.ts": 20, @@ -11,24 +11,23 @@ "src/lib/adapters/openshell/runtime.ts": 55, "src/lib/adapters/openshell/timeouts.ts": 38, "src/lib/agent/defs.ts": 33, - "src/lib/cli/branding.ts": 86, + "src/lib/cli/branding.ts": 85, "src/lib/cli/nemoclaw-oclif-command.ts": 107, "src/lib/cli/terminal-style.ts": 43, "src/lib/core/json-types.ts": 37, "src/lib/core/ports.ts": 89, "src/lib/core/shell-quote.ts": 28, "src/lib/core/url-utils.ts": 30, - "src/lib/core/wait.ts": 38, + "src/lib/core/wait.ts": 37, "src/lib/credentials/store.ts": 46, "src/lib/inference/config.ts": 30, - "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 25, "src/lib/onboard/gateway-binding.ts": 53, "src/lib/runner.ts": 86, "src/lib/security/redact.ts": 54, - "src/lib/state/onboard-session.ts": 36, "src/lib/state/mcp-lifecycle-lock.ts": 21, - "src/lib/state/registry.ts": 100, + "src/lib/state/onboard-session.ts": 35, + "src/lib/state/registry.ts": 97, "src/lib/state/state-root.ts": 21, "src/lib/subprocess-env.ts": 24, "src/lib/validation.ts": 24 @@ -40,7 +39,7 @@ "src/lib/actions/inference-set.ts": 32, "src/lib/actions/sandbox/connect.ts": 43, "src/lib/actions/sandbox/destroy.ts": 29, - "src/lib/actions/sandbox/doctor.ts": 29, + "src/lib/actions/sandbox/doctor.ts": 27, "src/lib/actions/sandbox/gateway-state.ts": 21, "src/lib/actions/sandbox/status-snapshot.ts": 19, "src/lib/actions/sandbox/policy-channel.ts": 30, @@ -51,7 +50,7 @@ "src/lib/inference/local.ts": 22, "src/lib/inference/onboard-probes.ts": 21, "src/lib/inference/vllm.ts": 21, - "src/lib/onboard.ts": 201, + "src/lib/onboard.ts": 193, "src/lib/onboard/machine/handlers/sandbox.ts": 21, "src/lib/policy/index.ts": 23, "src/lib/sandbox/config.ts": 22, @@ -60,9 +59,9 @@ }, "allowedCycles": [], "maxRootFiles": { - "src/lib/onboard": 308, + "src/lib/onboard": 306, "src/lib/actions": 19, - "src/lib/actions/sandbox": 183, + "src/lib/actions/sandbox": 182, "src/lib/state": 39, "src/lib/inference": 63, "scripts": 42 diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 8fb9442b5d0..cf40c21910c 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/generation/generate-openclaw-config.test.ts": 1898, "test/installer-integration/install-preflight.test.ts": 3025, "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4626, - "test/onboarding/onboard-messaging.test.ts": 1976, + "test/onboarding/onboard-messaging.test.ts": 1971, "test/onboarding/onboard-selection.test.ts": 4176 } } diff --git a/docs/about/how-it-works.mdx b/docs/about/how-it-works.mdx index 3fcf4c7b6cf..7146875e2ff 100644 --- a/docs/about/how-it-works.mdx +++ b/docs/about/how-it-works.mdx @@ -5,20 +5,23 @@ title: "NemoClaw Architecture Overview" sidebar-title: "Architecture Overview" description: "Learn how NemoClaw combines a host CLI, agent integration layer, versioned blueprint, and OpenShell gateway to operate supported agents." description-agent: "Describes how NemoClaw works internally: host CLI, agent integration layer, blueprint runner, OpenShell orchestration, lifecycle state, managed integrations, inference routing, and protection layers. Use for sandbox lifecycle and architecture mechanics; not for product definition (Overview) or multi-project placement (Ecosystem)." -keywords: ["how nemoclaw works", "nemoclaw sandbox lifecycle blueprint", "nemoclaw managed mcp architecture"] +keywords: + [ + "how nemoclaw works", + "nemoclaw sandbox lifecycle blueprint", + "nemoclaw managed mcp architecture", + ] content: type: "concept" --- -This page explains how NemoClaw runs supported agent runtimes inside OpenShell sandboxes. -It covers the host CLI, OpenShell gateway, agent integration layer, lifecycle state, managed Model Context Protocol (MCP) servers and other integrations, and protection layers. -NemoClaw does not replace OpenShell or the selected agent runtime. -It packages them as a repeatable setup with a versioned blueprint, agent-specific configuration, managed inference, network policy, and lifecycle operations. +This page explains how NemoClaw runs supported agent runtimes inside OpenShell sandboxes. It covers the host CLI, OpenShell gateway, agent integration layer, lifecycle state, managed Model Context Protocol (MCP) servers and other integrations, and protection layers. + +NemoClaw does not replace OpenShell or the selected agent runtime. It packages them as a repeatable setup with a versioned blueprint, agent-specific configuration, managed inference, network policy, and lifecycle operations. ## High-Level Flow -NemoClaw keeps operator control on the host while OpenShell enforces the sandbox boundary. -The OpenShell gateway coordinates sandbox lifecycle, credentials, network policy, inference routes, and approved integration traffic. +NemoClaw keeps operator control on the host while OpenShell enforces the sandbox boundary. The OpenShell gateway coordinates sandbox lifecycle, credentials, network policy, inference routes, and approved integration traffic. ```mermaid flowchart LR @@ -27,7 +30,7 @@ flowchart LR GATEWAY["OpenShell gateway
lifecycle, credentials, policy, routing"]:::gateway SANDBOX["OpenShell sandbox
agent runtime and integration layer"]:::sandbox INTERFACE["Agent interface
runtime-specific interaction path"]:::users - STATE["Managed state and artifacts
registry, workspace, policy records, snapshots"]:::state + STATE["Managed state and artifacts
registry, workspace, logs, snapshots"]:::state INFERENCE["Inference providers
supported hosted or local inference"]:::inference INTEGRATIONS["Approved integrations
MCP servers and package services"]:::integrations @@ -54,7 +57,7 @@ flowchart LR The diagram has the following components: | Component | Role in the flow | -|-------|------------------| +| --- | --- | | Users and operators | Install and operate NemoClaw from the host, then interact through the selected agent interface. | | NemoClaw host CLI | Collects configuration, runs readiness checks and onboarding, resolves the blueprint, and operates managed resources. | | OpenShell gateway | Coordinates sandbox lifecycle, credentials, networking, policy enforcement, inference routing, and approved integration egress. | @@ -62,7 +65,7 @@ The diagram has the following components: | Agent interface | Provides the interaction path exposed by the selected agent runtime. | | Inference providers | Receive managed inference requests through the OpenShell gateway. | | Approved integrations | Receive policy-approved requests to MCP servers, package indexes, and other configured services. | -| Managed state and artifacts | Preserve registry records, workspace files, policy records, logs, and manifest-declared snapshot content across supported lifecycle operations. | +| Managed state and artifacts | Preserve non-policy registry records, workspace files, logs, and manifest-declared snapshot content; OpenShell alone stores sandbox policy. | For repository layout, file paths, and deeper diagrams, refer to [Architecture](../reference/architecture). @@ -70,26 +73,19 @@ For repository layout, file paths, and deeper diagrams, refer to [Architecture]( NemoClaw follows these architecture principles. -Versioned blueprint -: The blueprint runner resolves a versioned blueprint and verifies its digest before it changes managed resources. +Versioned blueprint : The blueprint runner resolves a versioned blueprint and verifies its digest before it changes managed resources. -Host credential custody -: OpenShell stores inference provider credentials and managed MCP bearer values outside the sandbox and replaces placeholders at approved request boundaries. +Host credential custody : OpenShell stores inference provider credentials and managed MCP bearer values outside the sandbox and replaces placeholders at approved request boundaries. -Agent-specific integration -: Each supported agent runtime receives the configuration, wrappers, plugin, or adapter required for its documented workflow. +Agent-specific integration : Each supported agent runtime receives the configuration, wrappers, plugin, or adapter required for its documented workflow. -Resumable lifecycle -: NemoClaw records lifecycle progress and reconciles managed resources after supported interruptions or partial operations. +Resumable lifecycle : NemoClaw records lifecycle progress and reconciles managed resources after supported interruptions or partial operations. -Manifest-declared state -: Rebuild, snapshot, and restore operations preserve only the state declared for the selected agent runtime. - Each agent manifest and operation defines which credential-bearing files to exclude. +Manifest-declared state : Rebuild, snapshot, and restore operations preserve only the state declared for the selected agent runtime. Each agent manifest and operation defines which credential-bearing files to exclude. -Host-configured messaging credentials also use OpenShell credential delivery. -Some messaging integrations, such as QR-paired WhatsApp, retain explicitly declared session credentials inside the sandbox so supported lifecycle operations can preserve them. +Host-configured messaging credentials also use OpenShell credential delivery. Some messaging integrations, such as QR-paired WhatsApp, retain explicitly declared session credentials inside the sandbox so supported lifecycle operations can preserve them. @@ -98,11 +94,10 @@ Some messaging integrations, such as QR-paired WhatsApp, retain explicitly decla NemoClaw separates host orchestration, agent-specific behavior, and sandbox definition. - The _host CLI_ runs readiness checks and onboarding, validates provider choices, records lifecycle state, and operates OpenShell resources. + -- The _OpenClaw integration layer_ includes a TypeScript plugin that runs inside the sandbox. - It registers the managed inference provider metadata, the `/nemoclaw` slash command, and runtime context hooks. - Runtime context is prepended as system guidance, so sandbox and policy instructions stay active without appearing in the visible chat transcript. +- The _OpenClaw integration layer_ includes a TypeScript plugin that runs inside the sandbox. It registers the managed inference provider metadata, the `/nemoclaw` slash command, and runtime context hooks. Runtime context is prepended as system guidance, so sandbox and policy instructions stay active without appearing in the visible chat transcript. @@ -112,8 +107,7 @@ NemoClaw separates host orchestration, agent-specific behavior, and sandbox defi -- The _Deep Agents integration layer_ writes managed runtime configuration under `/sandbox/.deepagents`. - It includes `config.toml`, managed MCP projection state, and the inference route used by `dcode`. +- The _Deep Agents integration layer_ writes managed runtime configuration under `/sandbox/.deepagents`. It includes `config.toml`, managed MCP projection state, and the inference route used by `dcode`. - The _blueprint_ is a versioned YAML package with the sandbox image, agent manifest, network policy, inference profile, and supporting assets. @@ -123,15 +117,11 @@ This separation keeps host orchestration, agent-specific assets, and the sandbox ## Readiness and Sandbox Creation -Run `$$nemoclaw host probe` when you need a read-only system readiness report before onboarding. -The report combines host and gateway observations, capabilities, qualifications, findings, evidence, and CLI provenance without changing system state. -Onboarding consumes the same stable host and gateway entities and applies its explicit admission policy. -It revalidates live facts after permitted preparation and when a saved onboarding session resumes. +Run `$$nemoclaw host probe` when you need a read-only system readiness report before onboarding. The report combines host and gateway observations, capabilities, qualifications, findings, evidence, and CLI provenance without changing system state. Onboarding consumes the same stable host and gateway entities and applies its explicit admission policy. It revalidates live facts after permitted preparation and when a saved onboarding session resumes. When you run `$$nemoclaw onboard`, the host CLI and blueprint runner complete these operations: -1. NemoClaw resolves gateway lifecycle authority and rejects blocking system readiness results before managed resource effects. - A container-backed WSL GPU proof can run only after this admission check; explicit CPU-only intent skips it. +1. NemoClaw resolves gateway lifecycle authority and rejects blocking system readiness results before managed resource effects. A container-backed WSL GPU proof can run only after this admission check; explicit CPU-only intent skips it. 2. NemoClaw resolves the blueprint, checks version compatibility, and verifies the digest. 3. Onboarding validates the selected inference provider, credentials, agent settings, and platform requirements. 4. The runner determines which gateway, provider, policy, sandbox, and integration resources to create or update. @@ -144,7 +134,7 @@ After the sandbox starts, the selected agent uses its managed configuration and NemoClaw operates the sandbox and its manifest-declared state through host-side commands. | Operation | Result | -|---|---| +| --- | --- | | Inspect | `host probe`, `status`, and `logs` report system, sandbox, agent-runtime, inference, and recovery information without replacing the sandbox. | | Configure | Inference, policy, managed MCP, and supported agent-runtime integration commands update the applicable managed resources. | | Rebuild | Recreates the sandbox from the recorded configuration and restores supported agent state through a recorded transaction. | @@ -156,37 +146,31 @@ Refer to [Recover and Rebuild Sandboxes](../manage-sandboxes/operate-sandboxes/r ## Inference Routing -Managed agent runtimes send model requests to `inference.local` instead of an upstream endpoint. -During onboarding, NemoClaw validates the selected provider and model, configures the OpenShell inference route, and writes the matching model reference into the managed agent configuration. -OpenShell keeps the provider credential outside the sandbox and sends approved requests to the upstream endpoint. -When you select the Model Router provider, `inference.local` routes to a host-side router that chooses from the configured NVIDIA model pool for each request. +Managed agent runtimes send model requests to `inference.local` instead of an upstream endpoint. During onboarding, NemoClaw validates the selected provider and model, configures the OpenShell inference route, and writes the matching model reference into the managed agent configuration. OpenShell keeps the provider credential outside the sandbox and sends approved requests to the upstream endpoint. When you select the Model Router provider, `inference.local` routes to a host-side router that chooses from the configured NVIDIA model pool for each request. + -For Hermes, `$$nemoclaw inference set` updates `/sandbox/.hermes/config.yaml` at runtime without rebuilding the sandbox. + For Hermes, `$$nemoclaw inference set` updates `/sandbox/.hermes/config.yaml` at runtime without + rebuilding the sandbox. -For Deep Agents, the managed `dcode` runtime reads the OpenAI-compatible route that NemoClaw writes into `/sandbox/.deepagents/config.toml`. + For Deep Agents, the managed `dcode` runtime reads the OpenAI-compatible route that NemoClaw + writes into `/sandbox/.deepagents/config.toml`. ## Managed Integrations NemoClaw connects supported external services through OpenShell providers, network policy, and agent-specific adapters. -Managed MCP supports authenticated HTTPS Streamable HTTP MCP servers for OpenClaw, Hermes, and Deep Agents Code. -NemoClaw stores the credential name and ownership metadata, while OpenShell stores the raw value outside the sandbox. -The agent adapter receives a credential placeholder that OpenShell replaces only at the approved egress boundary. +Managed MCP supports authenticated HTTPS Streamable HTTP MCP servers for OpenClaw, Hermes, and Deep Agents Code. NemoClaw stores the credential name and ownership metadata, while OpenShell stores the raw value outside the sandbox. The agent adapter receives a credential placeholder that OpenShell replaces only at the approved egress boundary. -Messaging channels use agent-specific channel manifests, credential delivery, network policy, and lifecycle commands. -Some experimental webhook channels also require a route-restricted host-side public endpoint. -Refer to [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) for agent and channel status. +Messaging channels use agent-specific channel manifests, credential delivery, network policy, and lifecycle commands. Some experimental webhook channels also require a route-restricted host-side public endpoint. Refer to [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) for agent and channel status. -Deep Agents Code can also opt into bounded trace export to an operator-managed host collector. -Native LangSmith tracing and ambient OpenTelemetry exporter configuration remain disabled inside the sandbox. -Refer to [Understand Deep Agents Trace Export](../monitoring/understand-deepagents-trace-export) for the data and receiver trust boundaries. +Deep Agents Code can also opt into bounded trace export to an operator-managed host collector. Native LangSmith tracing and ambient OpenTelemetry exporter configuration remain disabled inside the sandbox. Refer to [Understand Deep Agents Trace Export](../monitoring/understand-deepagents-trace-export) for the data and receiver trust boundaries. @@ -197,18 +181,15 @@ Refer to [About Managed MCP Servers](../manage-sandboxes/mcp-servers/about-manag The sandbox starts with a baseline policy that controls network egress, filesystem access, process privileges, and inference routing. | Layer | What it protects | When it applies | -|---|---|---| +| --- | --- | --- | | Network | Blocks unauthorized outbound connections. | Hot-reloadable at runtime. | | Filesystem | Restricts system paths to read-only; `/sandbox` and `/tmp` are writable. | Locked at sandbox creation. | | Process | Blocks privilege escalation and dangerous syscalls. | Locked at sandbox creation. | | Inference | Reroutes model API calls to controlled backends. | Hot-reloadable at runtime. | -When the agent tries to reach an unapproved host, OpenShell blocks the request and surfaces it in the terminal user interface (TUI) for operator approval. -Approved endpoints persist within the current sandbox instance but are not saved to the baseline policy file. -NemoClaw's runtime context tells supported agents to try allowed network and filesystem actions first, then report whether policy denial, DNS, timeout, TLS, or filesystem access caused a failure. +When the agent tries to reach an unapproved host, OpenShell blocks the request and surfaces it in the terminal user interface (TUI) for operator approval. Approved endpoints persist within the current sandbox instance but are not saved to the baseline policy file. NemoClaw's runtime context tells supported agents to try allowed network and filesystem actions first, then report whether policy denial, DNS, timeout, TLS, or filesystem access caused a failure. -Host and platform limitations can change how individual controls apply. -Refer to [Platform Support](../reference/platform-support) and [Security Best Practices](../security/best-practices) before you treat a control as an environment-wide guarantee. +Host and platform limitations can change how individual controls apply. Refer to [Platform Support](../reference/platform-support) and [Security Best Practices](../security/best-practices) before you treat a control as an environment-wide guarantee. ## Next Steps diff --git a/docs/deployment/deploy-to-headless-server.mdx b/docs/deployment/deploy-to-headless-server.mdx index 4e2dc401c74..e58e543dcac 100644 --- a/docs/deployment/deploy-to-headless-server.mdx +++ b/docs/deployment/deploy-to-headless-server.mdx @@ -11,34 +11,33 @@ content: skill: priority: 20 --- -Run NemoClaw on a remote Linux server through SSH without exposing the OpenShell gateway or dashboard to the network. -This guide covers unattended onboarding, verified readiness, routine updates, and manual recovery after a host reboot. + +Run NemoClaw on a remote Linux server through SSH without exposing the OpenShell gateway or dashboard to the network. This guide covers unattended onboarding, verified readiness, routine updates, and manual recovery after a host reboot. -A Linux VM that you provision through Brev is one example of a headless server. -These instructions also apply to Linux hosts on other clouds, VPS services, or on-premises infrastructure. -NemoClaw setup starts after server provisioning and does not depend on Brev or its web UI. + A Linux VM that you provision through Brev is one example of a headless server. These instructions + also apply to Linux hosts on other clouds, VPS services, or on-premises infrastructure. NemoClaw + setup starts after server provisioning and does not depend on Brev or its web UI. -NemoClaw does not guarantee that Docker, the OpenShell gateway, sandboxes, tunnels, or host forwards start automatically after a host reboot. -Use the [manual recovery sequence](#recover-after-a-host-reboot) after each reboot. -Do not install an unofficial service unit as a substitute for this sequence. + NemoClaw does not guarantee that Docker, the OpenShell gateway, sandboxes, tunnels, or host + forwards start automatically after a host reboot. Use the [manual recovery + sequence](#recover-after-a-host-reboot) after each reboot. Do not install an unofficial service + unit as a substitute for this sequence. ## Check the Server -Use a Linux host that meets the supported [NemoClaw prerequisites](../get-started/prerequisites). -The primary tested server path is Linux with Docker. +Use a Linux host that meets the supported [NemoClaw prerequisites](../get-started/prerequisites). The primary tested server path is Linux with Docker. -| Resource | Minimum | Recommended | -|---|---:|---:| -| CPU | 4 vCPU | 4 or more vCPU | -| RAM | 8 GB | 16 GB | -| Free disk | 20 GB | 40 GB | +| Resource | Minimum | Recommended | +| --------- | ------: | -------------: | +| CPU | 4 vCPU | 4 or more vCPU | +| RAM | 8 GB | 16 GB | +| Free disk | 20 GB | 40 GB | -The image build, Docker daemon, and OpenShell gateway can exhaust a smaller host during onboarding. -If the host has less than 8 GB of RAM, configure at least 8 GB of swap before onboarding. +The image build, Docker daemon, and OpenShell gateway can exhaust a smaller host during onboarding. If the host has less than 8 GB of RAM, configure at least 8 GB of swap before onboarding. Run these checks from the remote host: @@ -53,17 +52,13 @@ free -h swapon --show ``` -`docker info` must succeed for the same account that runs NemoClaw. -Membership in the `docker` group grants root-level control of the Docker daemon, so grant it only to trusted accounts. +`docker info` must succeed for the same account that runs NemoClaw. Membership in the `docker` group grants root-level control of the Docker daemon, so grant it only to trusted accounts. -The host firewall must allow the outbound DNS, HTTPS, image-registry, package-registry, and inference-provider traffic selected during onboarding. -The OpenShell policy controls traffic from the sandbox and does not replace the host firewall. -Keep inbound dashboard and OpenShell gateway ports closed when you use SSH forwarding. +The host firewall must allow the outbound DNS, HTTPS, image-registry, package-registry, and inference-provider traffic selected during onboarding. The OpenShell policy controls traffic from the sandbox and does not replace the host firewall. Keep inbound dashboard and OpenShell gateway ports closed when you use SSH forwarding. ## Keep Remote Access on Loopback -The OpenShell gateway binds to `127.0.0.1` by default. -Dashboard and API forwards also stay on loopback outside WSL unless you explicitly change the bind setting. +The OpenShell gateway binds to `127.0.0.1` by default. Dashboard and API forwards also stay on loopback outside WSL unless you explicitly change the bind setting. Connect to the server from your workstation: @@ -73,40 +68,32 @@ ssh @ -After onboarding, keep the server-side forward on loopback and create a second SSH tunnel from your workstation. -The default dashboard port is `18789`, but NemoClaw can select the next free port through `18799`. -Use the port printed by `$$nemoclaw headless-agent dashboard-url`. +After onboarding, keep the server-side forward on loopback and create a second SSH tunnel from your workstation. The default dashboard port is `18789`, but NemoClaw can select the next free port through `18799`. Use the port printed by `$$nemoclaw headless-agent dashboard-url`. ```bash ssh -N -L 18789:127.0.0.1:18789 @ ``` -Then open the loopback URL printed by `$$nemoclaw headless-agent dashboard-url --quiet` on your workstation. -Replace both `18789` values when NemoClaw selected another port. +Then open the loopback URL printed by `$$nemoclaw headless-agent dashboard-url --quiet` on your workstation. Replace both `18789` values when NemoClaw selected another port. -Deep Agents Code is a terminal runtime and has no dashboard port. -Run `$$nemoclaw launch headless-agent` through the SSH session to start `dcode` in that session. -Use `$$nemoclaw headless-agent connect` instead when you want a sandbox shell. +Deep Agents Code is a terminal runtime and has no dashboard port. Run `$$nemoclaw launch headless-agent` through the SSH session to start `dcode` in that session. Use `$$nemoclaw headless-agent connect` instead when you want a sandbox shell. -Do not open port `8080` for remote access. -Do not bind the dashboard to every interface when an SSH tunnel meets the access requirement. +Do not open port `8080` for remote access. Do not bind the dashboard to every interface when an SSH tunnel meets the access requirement. ## Protect a Long Onboarding Run -Run onboarding inside a `tmux` or `screen` session so an SSH disconnect does not terminate the host process. -To start a `tmux` session, run: +Run onboarding inside a `tmux` or `screen` session so an SSH disconnect does not terminate the host process. To start a `tmux` session, run: ```bash tmux new-session -s nemoclaw-onboard ``` -Detach with `Ctrl-b`, then `d` while onboarding continues. -After you reconnect through SSH, reattach to the session: +Detach with `Ctrl-b`, then `d` while onboarding continues. After you reconnect through SSH, reattach to the session: ```bash tmux attach-session -t nemoclaw-onboard @@ -118,15 +105,13 @@ To use `screen` instead, start a session: screen -S nemoclaw-onboard ``` -Detach with `Ctrl-a`, then `d` while onboarding continues. -After you reconnect through SSH, reattach to the session: +Detach with `Ctrl-a`, then `d` while onboarding continues. After you reconnect through SSH, reattach to the session: ```bash screen -r nemoclaw-onboard ``` -Do not enable shell tracing with `set -x` in a session that contains credentials. -Do not save the session transcript when it can contain a dashboard URL or token. +Do not enable shell tracing with `set -x` in a session that contains credentials. Do not save the session transcript when it can contain a dashboard URL or token. If the onboarding process exited after it saved a resumable session, export the same required credential variables and resume it: @@ -136,18 +121,11 @@ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ $$nemoclaw onboard --resume --yes-i-accept-third-party-software --yes ``` -`--resume` uses the provider, model, sandbox name, agent, and completed non-secret choices from the saved session. -Raw credentials are not stored in the onboarding session. -If resume reports a missing credential variable, inject that variable again and repeat the command. -Use `--fresh` only when you intend to discard the saved onboarding session and start again. +`--resume` uses the provider, model, sandbox name, agent, and completed non-secret choices from the saved session. Raw credentials are not stored in the onboarding session. If resume reports a missing credential variable, inject that variable again and repeat the command. Use `--fresh` only when you intend to discard the saved onboarding session and start again. ## Run Unattended Onboarding -Select a reviewed NemoClaw commit and set its full 40-character SHA before unattended installation. -The example uses that SHA in both the immutable bootstrap URL and `NEMOCLAW_INSTALL_REF`, so the bootstrap and cloned installer payload come from the same repository state. -Do not use the mutable `lkg` or `latest` references as the primary install source for a persistent server. -Inject provider credentials from your secret manager into the host environment before you run this example. -The example fails before the network install if the commit SHA or `NVIDIA_INFERENCE_API_KEY` is missing or invalid. +Select a reviewed NemoClaw commit and set its full 40-character SHA before unattended installation. The example uses that SHA in both the immutable bootstrap URL and `NEMOCLAW_INSTALL_REF`, so the bootstrap and cloned installer payload come from the same repository state. Do not use the mutable `lkg` or `latest` references as the primary install source for a persistent server. Inject provider credentials from your secret manager into the host environment before you run this example. The example fails before the network install if the commit SHA or `NVIDIA_INFERENCE_API_KEY` is missing or invalid. @@ -195,20 +173,16 @@ curl -fsSL "https://raw.githubusercontent.com/NVIDIA/NemoClaw/${NEMOCLAW_INSTALL bash ``` -Pass every onboarding `NEMOCLAW_*` value on the `bash` side of the pipeline so the downloaded installer can read it. -The commit pin also appears in the bootstrap URL so no mutable tag selects the code that enters the pipeline. -Do not put a credential before `curl`, in a command-line argument, or in a committed script. -Unset the credential from the interactive shell after onboarding completes: +Pass every onboarding `NEMOCLAW_*` value on the `bash` side of the pipeline so the downloaded installer can read it. The commit pin also appears in the bootstrap URL so no mutable tag selects the code that enters the pipeline. Do not put a credential before `curl`, in a command-line argument, or in a committed script. Unset the credential from the interactive shell after onboarding completes: ```bash unset NVIDIA_INFERENCE_API_KEY ``` -Use the matching credential variable when you select another provider. -Refer to the [CLI commands reference](../reference/commands#nemoclaw-onboard) for provider-specific variables and accepted values. +Use the matching credential variable when you select another provider. Refer to the [CLI commands reference](../reference/commands#nemoclaw-onboard) for provider-specific variables and accepted values. | Variable | Requirement | Secret | Purpose | -|---|---|---|---| +| --- | --- | --- | --- | | `NEMOCLAW_NON_INTERACTIVE=1` | Required for unattended use | No | Disables interactive onboarding prompts. | | `NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1` | Required for unattended use | No | Records explicit acceptance for the current run. | | `NEMOCLAW_AGENT` | Required when the agent must not use the default | No | Selects `openclaw`, `hermes`, or `langchain-deepagents-code`. | @@ -222,9 +196,7 @@ Refer to the [CLI commands reference](../reference/commands#nemoclaw-onboard) fo ## Verify Readiness -Do not use process presence as the sandbox-ready signal. -The authoritative OpenShell signal is the row for `headless-agent` in phase `Ready` or `Running`. -The substring `NotReady` is not a ready state. +Do not use process presence as the sandbox-ready signal. The authoritative OpenShell signal is the row for `headless-agent` in phase `Ready` or `Running`. The substring `NotReady` is not a ready state. Run each verification on the remote host: @@ -234,25 +206,17 @@ $$nemoclaw headless-agent status $$nemoclaw headless-agent connect --probe-only ``` -`$$nemoclaw headless-agent status` exits nonzero when the sandbox, gateway, local container, or authoritative inference route is not verified. -Its main `Inference` line probes `https://inference.local/v1/models` from inside the sandbox, then sends one inference request over the same route when that probe reports the route reachable. -The line reports `healthy` when the route served the request, `unauthorized` when the route rejected it with HTTP `401` or `403`, and `unhealthy` when the route returned HTTP `500` through `599`. +`$$nemoclaw headless-agent status` exits nonzero when the sandbox, gateway, local container, or authoritative inference route is not verified. Its main `Inference` line probes `https://inference.local/v1/models` from inside the sandbox, then sends one inference request over the same route when that probe reports the route reachable. The line reports `healthy` when the route served the request, `unauthorized` when the route rejected it with HTTP `401` or `403`, and `unhealthy` when the route returned HTTP `500` through `599`. + -During an SSH session, `status` points to `$$nemoclaw headless-agent dashboard-url` only when the agent gateway is running and loopback dashboard access needs a port forward. -The printed command quotes the sandbox name so that the shell treats it as one argument. +During an SSH session, `status` points to `$$nemoclaw headless-agent dashboard-url` only when the agent gateway is running and loopback dashboard access needs a port forward. The printed command quotes the sandbox name so that the shell treats it as one argument. -`connect --probe-only` waits up to 300 seconds by default for a cold sandbox to become ready. -It then verifies or repairs the in-sandbox agent process and host forwards without opening a shell. -It does not restart or replace the shared host OpenShell gateway. +`connect --probe-only` waits up to 300 seconds by default for a cold sandbox to become ready. It then verifies or repairs the in-sandbox agent process and host forwards without opening a shell. It does not restart or replace the shared host OpenShell gateway. -The command prints one `Probe timing:` line with elapsed milliseconds for `readiness`, `authority`, `lifecycle`, `gateway`, `processes`, `forward`, `inference`, `pairing`, and `publication` stages. -Use the stage values to identify where a slow or failed deployment spent its time. -The line also reports lifecycle and forward actions and names the failed stage when available. -Timing collection is diagnostic and fail-open. -The command exit status remains the readiness decision: status `0` means the complete probe passed, and any nonzero status means the host is not ready for launch. +The command prints one `Probe timing:` line with elapsed milliseconds for `readiness`, `authority`, `lifecycle`, `gateway`, `processes`, `forward`, `inference`, `pairing`, and `publication` stages. Use the stage values to identify where a slow or failed deployment spent its time. The line also reports lifecycle and forward actions and names the failed stage when available. Timing collection is diagnostic and fail-open. The command exit status remains the readiness decision: status `0` means the complete probe passed, and any nonzero status means the host is not ready for launch. Readiness requires all of these results: @@ -262,8 +226,7 @@ Readiness requires all of these results: ## Access the Dashboard and API -Retrieve dashboard URLs and API tokens only when you need them. -Do not write either value to logs, shell history, support bundles, or version control. +Retrieve dashboard URLs and API tokens only when you need them. Do not write either value to logs, shell history, support bundles, or version control. @@ -273,8 +236,7 @@ Print the complete authenticated dashboard URL: $$nemoclaw headless-agent dashboard-url --quiet ``` -Use the raw gateway token only for automation that cannot use the tokenized dashboard URL. -This example authenticates the supported Control UI configuration endpoint on the server loopback interface: +Use the raw gateway token only for automation that cannot use the tokenized dashboard URL. This example authenticates the supported Control UI configuration endpoint on the server loopback interface: ```bash TOKEN=$($$nemoclaw headless-agent gateway-token --quiet) @@ -283,8 +245,7 @@ curl -fsS -H "Authorization: Bearer $TOKEN" \ unset TOKEN ``` -An unauthenticated request to this endpoint returns `401`. -The static path `controlui.bootstrap.config.json` does not exist and returns `404`. +An unauthenticated request to this endpoint returns `401`. The static path `controlui.bootstrap.config.json` does not exist and returns `404`. @@ -295,11 +256,7 @@ Print the Hermes dashboard URL: $$nemoclaw headless-agent dashboard-url --quiet ``` -The Hermes OpenAI-compatible API uses the loopback forward on the sandbox's own API port, which onboarding allocates from `8642` through `8652`. -Run `openshell forward list` and select the `headless-agent` row whose local port is in that range. -Replace `` below with that port. -For a Hermes sandbox, `gateway-token` is agent-aware and retrieves `API_SERVER_KEY` through the registered `bearer_token` web-auth contract. -Use it as a bearer token, then clear the shell variable: +The Hermes OpenAI-compatible API uses the loopback forward on the sandbox's own API port, which onboarding allocates from `8642` through `8652`. Run `openshell forward list` and select the `headless-agent` row whose local port is in that range. Replace `` below with that port. For a Hermes sandbox, `gateway-token` is agent-aware and retrieves `API_SERVER_KEY` through the registered `bearer_token` web-auth contract. Use it as a bearer token, then clear the shell variable: ```bash TOKEN=$($$nemoclaw headless-agent gateway-token --quiet) @@ -311,23 +268,18 @@ unset TOKEN -Deep Agents Code does not expose a dashboard URL or gateway token. -Model traffic uses the OpenShell-managed `inference.local` route. +Deep Agents Code does not expose a dashboard URL or gateway token. Model traffic uses the OpenShell-managed `inference.local` route. -OpenClaw generates a new gateway token when the sandbox container starts with mutable configuration. -If Shields are up, a non-root start preserves the sealed token because the sandbox user cannot replace the protected configuration. -Retrieve the dashboard URL or token again after the container starts or a replacement sandbox is created. +OpenClaw generates a new gateway token when the sandbox container starts with mutable configuration. If Shields are up, a non-root start preserves the sealed token because the sandbox user cannot replace the protected configuration. Retrieve the dashboard URL or token again after the container starts or a replacement sandbox is created. -Hermes preserves its `API_SERVER_KEY` when the same sandbox container restarts. -A replacement sandbox generates a new `API_SERVER_KEY`. -Retrieve the dashboard URL or token again after a replacement sandbox is created. +Hermes preserves its `API_SERVER_KEY` when the same sandbox container restarts. A replacement sandbox generates a new `API_SERVER_KEY`. Retrieve the dashboard URL or token again after a replacement sandbox is created. @@ -336,16 +288,14 @@ Retrieve the dashboard URL or token again after a replacement sandbox is created NemoClaw separates provider credentials, host metadata, and sandbox state. | Boundary | Stored data | Rebuild behavior | -|---|---|---| +| --- | --- | --- | | OpenShell gateway | Provider credentials and provider registrations | Reused when the gateway and provider binding remain available. Raw values cannot be read back. | -| `~/.nemoclaw/` on the host | Sandbox registry, provider names, policy metadata, and onboarding session state | Preserved by normal updates. The directory contains metadata, not provider credential values. | +| `~/.nemoclaw/` on the host | Sandbox registry, provider names, and onboarding session state | Preserved by normal updates. The directory contains no sandbox policy or provider credential values. | | Agent configuration in the sandbox | Generated inference routes, OpenShell resolver placeholders, and agent-specific settings | Regenerated from host registry and OpenShell state. Generated files are not a credential store. | | Manifest-defined sandbox state | Agent workspace, memory, skills, and agent-specific durable files | Snapshotted and restored according to the selected agent manifest. | | Arbitrary environment and profile edits | Direct shell exports and edits outside the manifest contract | Not guaranteed. Export host variables again and use documented host commands for durable configuration. | -NemoClaw holds an environment-supplied provider credential in memory while it registers the value with OpenShell. -The sandbox receives a resolver placeholder, and OpenShell substitutes the raw value at egress. -For details, refer to [Credential Storage](../security/credential-storage). +NemoClaw holds an environment-supplied provider credential in memory while it registers the value with OpenShell. The sandbox receives a resolver placeholder, and OpenShell substitutes the raw value at egress. For details, refer to [Credential Storage](../security/credential-storage). Install a declarative agent skill through the supported host command: @@ -353,13 +303,11 @@ Install a declarative agent skill through the supported host command: $$nemoclaw headless-agent skill install ./my-skill/ ``` -The skill directory must contain `SKILL.md` with a `name` field in its YAML frontmatter. -Do not assume that packages, shell exports, or profile edits made by a skill survive a rebuild. +The skill directory must contain `SKILL.md` with a `name` field in its YAML frontmatter. Do not assume that packages, shell exports, or profile edits made by a skill survive a rebuild. ## Add a Least-Privilege Policy -Use an additive custom preset when the sandbox needs a destination that the current policy does not allow. -Scope the host, port, method, path, and executable to the smallest required set. +Use an additive custom preset when the sandbox needs a destination that the current policy does not allow. Scope the host, port, method, path, and executable to the smallest required set. Save a reviewed preset as `./presets/internal-status.yaml`, preview it, then apply it without a prompt: @@ -369,15 +317,9 @@ $$nemoclaw headless-agent policy add --from-file ./presets/internal-status.yaml $$nemoclaw headless-agent policy list ``` -`--yes` skips the confirmation prompt but does not skip schema, destination, or SSRF validation. -NemoClaw records the full validated YAML content in the sandbox registry. -Snapshot restore and rebuild replay that recorded preset even when the original host file is unavailable. -Keep the source YAML in your configuration repository so operators can review and change it. -For the preset schema and removal workflow, refer to [Network Policies](../reference/network-policies). +`--yes` skips the confirmation prompt but does not skip schema, destination, or SSRF validation. NemoClaw merges the validated content into the current OpenShell policy and stores no second copy in the sandbox registry. Snapshot clone and rebuild carry the complete current OpenShell policy forward. Keep the source YAML in your configuration repository so operators can review and intentionally reapply changes. For the preset schema and removal workflow, refer to [Network Policies](../reference/network-policies). -An SSH command without `-t`, a service unit, and a CI job have no terminal on stdin, so the preset picker cannot run there. -Pass the preset name, `--from-file`, or `--from-dir` in such a session. -`policy add` and `policy remove` skip their confirmation prompts without a terminal on stdin, so neither needs `--yes` or `NEMOCLAW_NON_INTERACTIVE=1`. +An SSH command without `-t`, a service unit, and a CI job have no terminal on stdin, so the preset picker cannot run there. Pass the preset name, `--from-file`, or `--from-dir` in such a session. `policy add` and `policy remove` skip their confirmation prompts without a terminal on stdin, so neither needs `--yes` or `NEMOCLAW_NON_INTERACTIVE=1`. ## Plan for Updates and Rebuilds @@ -396,30 +338,31 @@ curl -fsSL "https://raw.githubusercontent.com/NVIDIA/NemoClaw/${NEMOCLAW_INSTALL $$nemoclaw upgrade-sandboxes --check ``` -Use a newly reviewed commit SHA for each planned update instead of relying on the mutable installer default. -The installer requires current backups before it changes an existing managed installation. -Use `$$nemoclaw headless-agent rebuild` when you need the current agent image while preserving supported state. +Use a newly reviewed commit SHA for each planned update instead of relying on the mutable installer default. The installer requires current backups before it changes an existing managed installation. Use `$$nemoclaw headless-agent rebuild` when you need the current agent image while preserving supported state. | Item | Same-container restart | Snapshot and restore | Rebuild or sandbox upgrade | -|---|---|---|---| +| --- | --- | --- | --- | | Provider configuration | Preserved | Provider names are recorded, but raw credentials are not in the snapshot | Regenerated from registry and OpenShell provider state | -| Custom preset YAML applied with `policy add` | Preserved in registry | Stored content is included in snapshot metadata | Replayed from stored content | +| Current OpenShell policy, including custom presets and host edits | Remains in OpenShell | Read live for a clone handoff, not stored in snapshot metadata | Read live and handed to replacement creation | | Manifest-defined user and agent state | Preserved | Preserved | Preserved when backup and restore succeed | | Arbitrary files outside manifest state | Usually remain in the same writable layer | Not preserved | Not preserved | | Manually installed system or global packages | Usually remain in the same writable layer | Not preserved | Not preserved | | Direct edits to generated profile, config, or environment files | May remain until regeneration | Agent-specific and usually excluded or filtered | Regenerated or filtered by the current manifest | + -| OpenClaw gateway token | Rotated when the container starts with mutable configuration; preserved for a non-root start while Shields are up | Not captured; a replacement sandbox generates a new token | Rotated for the replacement sandbox | + | OpenClaw gateway token | Rotated when the container starts with mutable configuration; preserved + for a non-root start while Shields are up | Not captured; a replacement sandbox generates a new + token | Rotated for the replacement sandbox | -| Hermes `API_SERVER_KEY` | Preserved | Not captured; a replacement sandbox generates a new token | Rotated for the replacement sandbox | + | Hermes `API_SERVER_KEY` | Preserved | Not captured; a replacement sandbox generates a new token + | Rotated for the replacement sandbox | -| Host tunnel process | Not applicable to a container restart | Not preserved | Not preserved | -| Dashboard, API, messaging, and agent forwards | Preserved only while their host processes remain active | Re-established during supported recovery | Re-established and verified after rebuild | +| Host tunnel process | Not applicable to a container restart | Not preserved | Not preserved | | +Dashboard, API, messaging, and agent forwards | Preserved only while their host processes remain +active | Re-established during supported recovery | Re-established and verified after rebuild | -Snapshot only the state that the current agent manifest declares. -Download any required file outside that contract before a destructive operation. -Refer to [Understand Sandbox State](../manage-sandboxes/state-and-backups/understand-sandbox-state) and [Create and Restore Snapshots](../manage-sandboxes/state-and-backups/create-and-restore-snapshots) for agent-specific exclusions. +Snapshot only the state that the current agent manifest declares. Download any required file outside that contract before a destructive operation. Refer to [Understand Sandbox State](../manage-sandboxes/state-and-backups/understand-sandbox-state) and [Create and Restore Snapshots](../manage-sandboxes/state-and-backups/create-and-restore-snapshots) for agent-specific exclusions. ## Recover After a Host Reboot @@ -460,26 +403,23 @@ If the sandbox is ready but the in-sandbox agent gateway or host forward remains $$nemoclaw headless-agent recover ``` -`recover`, `start`, and `connect --probe-only` do not restart the shared host OpenShell gateway. -If they report a host gateway RPC error, follow the printed host gateway recovery guidance. +`recover`, `start`, and `connect --probe-only` do not restart the shared host OpenShell gateway. If they report a host gateway RPC error, follow the printed host gateway recovery guidance. -Deep Agents Code has no in-sandbox gateway to recover. -If status reports a degraded terminal runtime after the sandbox becomes ready, rebuild the sandbox. +Deep Agents Code has no in-sandbox gateway to recover. If status reports a degraded terminal runtime after the sandbox becomes ready, rebuild the sandbox. -If the registry entry remains but the sandbox container is missing, rebuild from recorded metadata and the latest valid snapshot: +If the registry entry remains but the sandbox container is missing, rebuild cannot recover the sandbox because its authoritative OpenShell policy and live workspace are gone. Remove the stale local entry and create a clean replacement: ```bash -$$nemoclaw headless-agent rebuild --yes +$$nemoclaw headless-agent destroy --yes +$$nemoclaw onboard ``` -Do not destroy the registry entry before this recovery attempt because rebuild needs that metadata. -If you intentionally deleted the sandbox and want a new installation, destroy the stale registry entry and run onboarding again. -For failure-specific recovery boundaries, refer to [Recover and Rebuild Sandboxes](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes). +The missing sandbox's state cannot be recovered unless you have a separate snapshot. After onboarding, restore that snapshot explicitly. For failure-specific recovery boundaries, refer to [Recover and Rebuild Sandboxes](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes). @@ -493,20 +433,15 @@ Use the failure layer from `$$nemoclaw headless-agent status` before you choose ### Onboarding Was Interrupted -Reattach to the `tmux` or `screen` session first. -If the process exited with a resumable session, inject the required credentials and use `onboard --resume`. -Do not use `--fresh` unless discarding the saved choices and progress is intentional. +Reattach to the `tmux` or `screen` session first. If the process exited with a resumable session, inject the required credentials and use `onboard --resume`. Do not use `--fresh` unless discarding the saved choices and progress is intentional. ### The Sandbox Is Missing or Not Ready -Run `openshell sandbox list` and inspect the row for `headless-agent`. -`NotReady` does not satisfy readiness. -Run `$$nemoclaw headless-agent status`, then use its `start`, `connect --probe-only`, or `rebuild --yes` guidance. +Run `openshell sandbox list` and inspect the row for `headless-agent`. `NotReady` does not satisfy readiness. Run `$$nemoclaw headless-agent status`, then use its `start`, `connect --probe-only`, or `rebuild --yes` guidance. ### Inference Returns HTTP 5xx -An HTTP status from `500` through `599` makes the authoritative `inference.local` route unhealthy. -Check the configured provider and host egress, then run: +An HTTP status from `500` through `599` makes the authoritative `inference.local` route unhealthy. Check the configured provider and host egress, then run: ```bash $$nemoclaw headless-agent doctor @@ -520,9 +455,7 @@ Do not treat a running agent process as proof that inference works. ### Dashboard or Token Retrieval Fails -Run `$$nemoclaw headless-agent status` and `connect --probe-only` before retrieving the URL or token again. -The token command exits nonzero when the sandbox is not registered, not running, or cannot expose its agent-specific token. -Do not paste a token into diagnostics. +Run `$$nemoclaw headless-agent status` and `connect --probe-only` before retrieving the URL or token again. The token command exits nonzero when the sandbox is not registered, not running, or cannot expose its agent-specific token. Do not paste a token into diagnostics. diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 33d767d9880..3880965337f 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -5,7 +5,13 @@ title: "Quickstart with LangChain Deep Agents Code" sidebar-title: "Quickstart with Deep Agents" description: "Install NemoClaw, launch a LangChain Deep Agents Code sandbox, and run your first prompt." description-agent: "Installs NemoClaw, launches a LangChain Deep Agents Code sandbox, and runs the first prompt. Use when installing or testing dcode for the first time." -keywords: ["langchain deep agents code nemoclaw", "dcode openshell sandbox", "langchain coding agent", "dcode otlp tracing"] +keywords: + [ + "langchain deep agents code nemoclaw", + "dcode openshell sandbox", + "langchain coding agent", + "dcode otlp tracing", + ] topics: ["get-started", "terminal-runtime", "langchain-deepagents-code", "observability"] tags: ["deep-agents-code", "dcode", "managed-inference", "otlp"] difficulty: "intermediate" @@ -15,14 +21,12 @@ content: type: "get_started" agent-variants: ["deepagents"] --- -Create a sandboxed LangChain Deep Agents Code agent, then run your first prompt. -The `nemo-deepagents` command is an alias for `nemoclaw` with the `langchain-deepagents-code` agent pre-selected. + +Create a sandboxed LangChain Deep Agents Code agent, then run your first prompt. The `nemo-deepagents` command is an alias for `nemoclaw` with the `langchain-deepagents-code` agent pre-selected. ## Set Up with the Starter Prompt on Your Coding Agent -Copy this starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want it to guide the installation. -The prompt points the agent to [Use NemoClaw Docs with Your Coding Agents](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. -It asks the agent to confirm LangChain Deep Agents Code before it runs commands that create a sandbox or receive credentials and to use the checked-in local credential helper and form only after you approve the command that receives credentials. +Copy this starter prompt into Cursor, Claude Code, Codex, Copilot, or another local coding agent when you want it to guide the installation. The prompt points the agent to [Use NemoClaw Docs with Your Coding Agents](../resources/agent-skills), this quickstart, the Markdown docs, and the optional `nemoclaw-user-guide` skill. It asks the agent to confirm LangChain Deep Agents Code before it runs commands that create a sandbox or receive credentials and to use the checked-in local credential helper and form only after you approve the command that receives credentials. @@ -30,12 +34,9 @@ If you prefer to control setup directly, use [Set Up with the Interactive Instal ## Set Up with the Interactive Installer on Your Terminal -If you use the coding-agent prompt in the preceding section, you can skip this procedure or keep it as reference. -The prompt directs your coding agent to this quickstart, so it has the full setup context. +If you use the coding-agent prompt in the preceding section, you can skip this procedure or keep it as reference. The prompt directs your coding agent to this quickstart, so it has the full setup context. - -Review the [Prerequisites](prerequisites) before you begin. - +Review the [Prerequisites](prerequisites) before you begin. @@ -44,6 +45,7 @@ Review the [Prerequisites](prerequisites) before you begin. ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepagents-code NEMOCLAW_SANDBOX_NAME=my-deepagents bash ``` + @@ -68,6 +70,7 @@ Review the [Prerequisites](prerequisites) before you begin. If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Invalid or inconsistent catalog evidence fails closed before sandbox creation. An explicit `nemo-deepagents onboard --from ` remains a separate custom-image path. + @@ -76,6 +79,7 @@ Review the [Prerequisites](prerequisites) before you begin. ```bash nemo-deepagents my-deepagents status ``` + @@ -95,6 +99,7 @@ Review the [Prerequisites](prerequisites) before you begin. nemo-deepagents my-deepagents connect dcode ``` + @@ -125,14 +130,14 @@ Use these details when you need more control during setup or after the first san Refer to [Set Up vLLM](../inference/local-inference/set-up-vllm) for managed model profiles and headless setup. Refer to [Set Up vLLM on Two DGX Stations](../inference/local-inference/set-up-vllm-on-two-dgx-stations) for the Deferred paired workflow. Refer to [Platform Support](../reference/platform-support) for current validation status. + ### Installation and Onboarding Details -The hosted installer follows the last-known-good (`lkg`) release tag by default, so the install command selects the maintained Deep Agents-capable build without a version override. -If NemoClaw is already installed, run `nemo-deepagents onboard` to start Deep Agents onboarding directly. +The hosted installer follows the last-known-good (`lkg`) release tag by default, so the install command selects the maintained Deep Agents-capable build without a version override. If NemoClaw is already installed, run `nemo-deepagents onboard` to start Deep Agents onboarding directly. You can use the canonical agent ID or a short alias instead of `nemo-deepagents`. @@ -143,96 +148,43 @@ nemoclaw onboard --agent deepagents nemoclaw onboard --agent langchain ``` -The wizard asks for an inference provider, model, required credential, and sandbox name before it prints the review summary. -The review offers these actions: +The wizard asks for an inference provider, model, required credential, and sandbox name before it prints the review summary. The review offers these actions: - **Apply configuration** continues to provider registration. - **Edit inference provider or model** returns to provider and model selection. - **Edit sandbox name** prompts for the sandbox name again. - **Exit onboarding** stops onboarding before provider registration. -When you edit inference, NemoClaw clears the credential staged for the discarded selection. -NemoClaw preserves the sandbox name. -When you edit the sandbox name, NemoClaw preserves the inference selection. -The sandbox prompt shows the prior name as its default. -After you apply the configuration, routine editing ends. -If inference setup fails and offers a `back` recovery action, you can return to provider and model selection and then review the updated configuration again. -Provider registration, inference setup, policy selection, and sandbox creation then continue forward. -The default Deep Agents sandbox name is `deepagents-code`. -Use a distinct name, such as `my-deepagents`, when you run Deep Agents, Hermes, and OpenClaw sandboxes side by side. -Refer to [Choose an Inference Provider](../inference/learn-and-choose/choose-inference-provider) for provider-specific prompts. - -The image installs hash-locked Deep Agents Code `0.1.55` with NVIDIA provider support. -After the terminal smoke checks, onboarding runs `dcode --version` and compares the result with the version required by the agent manifest. -Fresh and resumed onboarding exit nonzero instead of reporting the runtime ready when the installed version is too old, uses an incompatible version scheme, or cannot be verified. -If the version check fails, review the reported version error and run `nemo-deepagents rebuild` before resuming onboarding. -NemoClaw writes `/sandbox/.deepagents/config.toml` with an OpenAI-compatible provider pointed at `https://inference.local/v1`, uses a scoped placeholder API key for that managed route, and sets `use_responses_api = false` for Chat Completions compatibility. -When onboarding records a reasoning effort on a `compatible-endpoint` route that uses `openai-completions`, managed startup writes that value to a root-owned file and Deep Agents Code model requests carry it as an `extra_body.reasoning_effort` request parameter. -Leave `NEMOCLAW_REASONING_EFFORT` unset to keep the endpoint's own default. -Deep Agents Code has no runtime `inference set` path, so re-onboard the sandbox with `nemo-deepagents onboard --fresh --name --recreate-sandbox` to change the recorded effort. -When you use NVIDIA Endpoints without selecting another model, new Deep Agents Code sandboxes default to `nvidia/nemotron-3-ultra-550b-a55b`. -For this model, the managed image maps the OpenAI-compatible route to Deep Agents `0.7.5`'s native Nemotron 3 Ultra harness profile, including model-specific tool-calling, filesystem, retry, context, and final-answer safeguards. -Rebuild existing Deep Agents Code sandboxes after upgrading to NemoClaw v0.0.76 or later so their image includes this profile. -This agent-specific default does not change the shared Nemotron 3 Super default for OpenClaw and Hermes. -NemoClaw/OpenShell keeps real provider credentials in credential handling and does not write them into the Deep Agents config file. -Deep Agents Code reaches `inference.local` through the managed OpenShell L7 proxy rather than direct sandbox DNS. -The image launcher normalizes the runtime proxy environment for interactive, login-shell, and direct-exec paths and removes inherited proxy credentials and bypass entries before `dcode` starts. -Managed interactive sessions pre-complete Deep Agents Code's optional first-run onboarding, skip its dependency and model selection screens, then open the TUI with the model selected during NemoClaw onboarding. -The image includes `ripgrep` and `dos2unix`, and ordinary sessions suppress the optional Tavily warning unless web search is configured or invoked. +When you edit inference, NemoClaw clears the credential staged for the discarded selection. NemoClaw preserves the sandbox name. When you edit the sandbox name, NemoClaw preserves the inference selection. The sandbox prompt shows the prior name as its default. After you apply the configuration, routine editing ends. If inference setup fails and offers a `back` recovery action, you can return to provider and model selection and then review the updated configuration again. Provider registration, inference setup, policy selection, and sandbox creation then continue forward. The default Deep Agents sandbox name is `deepagents-code`. Use a distinct name, such as `my-deepagents`, when you run Deep Agents, Hermes, and OpenClaw sandboxes side by side. Refer to [Choose an Inference Provider](../inference/learn-and-choose/choose-inference-provider) for provider-specific prompts. - +The image installs hash-locked Deep Agents Code `0.1.55` with NVIDIA provider support. After the terminal smoke checks, onboarding runs `dcode --version` and compares the result with the version required by the agent manifest. Fresh and resumed onboarding exit nonzero instead of reporting the runtime ready when the installed version is too old, uses an incompatible version scheme, or cannot be verified. If the version check fails, review the reported version error and run `nemo-deepagents rebuild` before resuming onboarding. NemoClaw writes `/sandbox/.deepagents/config.toml` with an OpenAI-compatible provider pointed at `https://inference.local/v1`, uses a scoped placeholder API key for that managed route, and sets `use_responses_api = false` for Chat Completions compatibility. When onboarding records a reasoning effort on a `compatible-endpoint` route that uses `openai-completions`, managed startup writes that value to a root-owned file and Deep Agents Code model requests carry it as an `extra_body.reasoning_effort` request parameter. Leave `NEMOCLAW_REASONING_EFFORT` unset to keep the endpoint's own default. Deep Agents Code has no runtime `inference set` path, so re-onboard the sandbox with `nemo-deepagents onboard --fresh --name --recreate-sandbox` to change the recorded effort. When you use NVIDIA Endpoints without selecting another model, new Deep Agents Code sandboxes default to `nvidia/nemotron-3-ultra-550b-a55b`. For this model, the managed image maps the OpenAI-compatible route to Deep Agents `0.7.5`'s native Nemotron 3 Ultra harness profile, including model-specific tool-calling, filesystem, retry, context, and final-answer safeguards. Rebuild existing Deep Agents Code sandboxes after upgrading to NemoClaw v0.0.76 or later so their image includes this profile. This agent-specific default does not change the shared Nemotron 3 Super default for OpenClaw and Hermes. NemoClaw/OpenShell keeps real provider credentials in credential handling and does not write them into the Deep Agents config file. Deep Agents Code reaches `inference.local` through the managed OpenShell L7 proxy rather than direct sandbox DNS. The image launcher normalizes the runtime proxy environment for interactive, login-shell, and direct-exec paths and removes inherited proxy credentials and bypass entries before `dcode` starts. Managed interactive sessions pre-complete Deep Agents Code's optional first-run onboarding, skip its dependency and model selection screens, then open the TUI with the model selected during NemoClaw onboarding. The image includes `ripgrep` and `dos2unix`, and ordinary sessions suppress the optional Tavily warning unless web search is configured or invoked. - - - Continue with [Run Deep Agents Code](../manage-sandboxes/operate-sandboxes/run-deep-agents-code) for sandbox selection, interactive and headless tasks, the JSON output contract, runtime restrictions, thread auto-approval, and identity checks. + + + Continue with [Run Deep Agents Code](../manage-sandboxes/operate-sandboxes/run-deep-agents-code) + for sandbox selection, interactive and headless tasks, the JSON output contract, runtime + restrictions, thread auto-approval, and identity checks. + + ### Python Environment -Deep Agents Code runs from a NemoClaw-managed Python virtual environment at `/opt/venv`. -The sandbox puts `/opt/venv/bin` on `PATH` before the system Python directories, so `python3` and `pip3` resolve to that managed environment. -NemoClaw keeps `/opt/venv` read-only to protect the installed harness. -For project-specific Python dependencies, create a separate virtual environment under `/sandbox` and activate it before installing packages. +Deep Agents Code runs from a NemoClaw-managed Python virtual environment at `/opt/venv`. The sandbox puts `/opt/venv/bin` on `PATH` before the system Python directories, so `python3` and `pip3` resolve to that managed environment. NemoClaw keeps `/opt/venv` read-only to protect the installed harness. For project-specific Python dependencies, create a separate virtual environment under `/sandbox` and activate it before installing packages. ### State and Backup -Deep Agents Code state lives under `/sandbox/.deepagents`. -NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist. -During managed re-onboarding, NemoClaw restores only these `config.toml` preferences from backup: boolean `ui.show_scrollbar`, boolean `ui.show_url_open_toast`, boolean `threads.relative_time`, and `threads.sort_order` when it is `updated_at` or `created_at`. -Freshly generated model routing, update settings, provider metadata, and all other configuration remain authoritative. -NemoClaw drops all other backup settings, including `ui.theme`, behavior-bearing keys, unknown keys, and security-sensitive keys. -It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check. -Run `nemoclaw snapshot create` after active `dcode` tasks finish. -For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. -NemoClaw intentionally does not back up `.deepagents/.env` or the user-owned `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there. -The managed `.deepagents/.nemoclaw-mcp.json` projection is also excluded because NemoClaw reconstructs it from the credential-free registry after recreation. -Service credentials remain in OpenShell provider state. -It also does not preserve `hooks.json`; executable Deep Agents Code hooks are disabled in the managed harness. -If `.deepagents/.state/auth.json` contains upstream credentials, or `.deepagents/.state/chatgpt-auth.json` exists, the managed Deep Agents Code launch paths refuse to start until that credential state is removed. -Before a managed Deep Agents Code rebuild changes the sandbox, NemoClaw selects its recorded OpenShell gateway, tests the recorded inference route through `https://inference.local`, and prepares the replacement from the recorded provider, model, policy, and build inputs with a pinned base and fingerprinted context. -Initial failures stop before backup. -After backup, NemoClaw rechecks the target, route, and retained build inputs before changing MCP state, then checks again after MCP preparation and before stopping inference or deleting the old sandbox. -If the final check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. -Rebuild also preserves the standalone Deep Agents Code `tavily` preset, the recorded observability choice unless explicitly overridden, and recorded custom policies from their stored content. +Deep Agents Code state lives under `/sandbox/.deepagents`. NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist. During managed re-onboarding, NemoClaw restores only these `config.toml` preferences from backup: boolean `ui.show_scrollbar`, boolean `ui.show_url_open_toast`, boolean `threads.relative_time`, and `threads.sort_order` when it is `updated_at` or `created_at`. Freshly generated model routing, update settings, provider metadata, and all other configuration remain authoritative. NemoClaw drops all other backup settings, including `ui.theme`, behavior-bearing keys, unknown keys, and security-sensitive keys. It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check. Run `nemoclaw snapshot create` after active `dcode` tasks finish. For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. NemoClaw intentionally does not back up `.deepagents/.env` or the user-owned `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there. The managed `.deepagents/.nemoclaw-mcp.json` projection is also excluded because NemoClaw reconstructs it from the credential-free registry after recreation. Service credentials remain in OpenShell provider state. It also does not preserve `hooks.json`; executable Deep Agents Code hooks are disabled in the managed harness. If `.deepagents/.state/auth.json` contains upstream credentials, or `.deepagents/.state/chatgpt-auth.json` exists, the managed Deep Agents Code launch paths refuse to start until that credential state is removed. Before a managed Deep Agents Code rebuild changes the sandbox, NemoClaw selects its recorded OpenShell gateway, tests the recorded inference route through `https://inference.local`, and prepares the replacement from the recorded provider, model, policy, and build inputs with a pinned base and fingerprinted context. Initial failures stop before backup. After backup, NemoClaw rechecks the target, route, and retained build inputs before changing MCP state, then checks again after MCP preparation and before stopping inference or deleting the old sandbox. If the final check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. Rebuild carries the complete current OpenShell policy forward, including Deep Agents Code `tavily`, custom entries, and trusted host-side edits. The recorded observability choice is preserved unless explicitly overridden. ### Optional Tavily Egress -Deep Agents Code does not currently have a NemoClaw-managed web-search feature. -If your project code or a manually configured tool needs Tavily, opt the sandbox Python egress path into Tavily explicitly. -Register the raw key only with the OpenShell gateway on the host, not inside the sandbox, in `.env`, or in Deep Agents config files. -The gateway injects it at egress instead. -The managed Deep Agents Code entry points reject credential-shaped process environment values, disable project `.env` and global `/sandbox/.deepagents/.env` loading, and block upstream `/auth`, `/connect`, startup/onboarding credential prompts, model-selector credential prompts, notification-service key prompts, and ChatGPT OAuth. -These controls apply to Deep Agents Code and do not sanitize arbitrary Python programs in the sandbox. -Use NemoClaw-managed credential paths when support is available instead of storing service keys inside Deep Agents Code state. -NemoClaw does not enable Tavily or observability by default for this harness. -The sandbox policy denies `api.tavily.com` until you opt into Tavily and continues to deny direct `api.smith.langchain.com` egress when you enable observability. +Deep Agents Code does not currently have a NemoClaw-managed web-search feature. If your project code or a manually configured tool needs Tavily, opt the sandbox Python egress path into Tavily explicitly. Register the raw key only with the OpenShell gateway on the host, not inside the sandbox, in `.env`, or in Deep Agents config files. The gateway injects it at egress instead. The managed Deep Agents Code entry points reject credential-shaped process environment values, disable project `.env` and global `/sandbox/.deepagents/.env` loading, and block upstream `/auth`, `/connect`, startup/onboarding credential prompts, model-selector credential prompts, notification-service key prompts, and ChatGPT OAuth. These controls apply to Deep Agents Code and do not sanitize arbitrary Python programs in the sandbox. Use NemoClaw-managed credential paths when support is available instead of storing service keys inside Deep Agents Code state. NemoClaw does not enable Tavily or observability by default for this harness. The sandbox policy denies `api.tavily.com` until you opt into Tavily and continues to deny direct `api.smith.langchain.com` egress when you enable observability. -To allow Tavily egress for the target sandbox, apply the maintained `tavily` policy preset, register the credential with the OpenShell gateway, then rebuild the sandbox so the new provider attaches. -The policy preset is a per-sandbox managed-Python opt-in, but provider registration is gateway-wide: `tavily-search` attaches to every sandbox that you build or rebuild afterward. +To allow Tavily egress for the target sandbox, apply the maintained `tavily` policy preset, register the credential with the OpenShell gateway, then rebuild the sandbox so the new provider attaches. The policy preset is a per-sandbox managed-Python opt-in, but provider registration is gateway-wide: `tavily-search` attaches to every sandbox that you build or rebuild afterward. ```bash # Preview the endpoints the preset opens: @@ -249,11 +201,7 @@ unset TAVILY_API_KEY nemo-deepagents rebuild ``` -The shared `tavily` preset only opens `POST /search` and `POST /extract` egress to `api.tavily.com:443`. -Attaching the credential provider alone does not authorize the managed Python interpreter; the explicit policy preset is the interpreter-level opt-in. -Export `TAVILY_API_KEY` only for registration, then remove it from the host shell; the gateway injects the stored value at egress, and the sandbox never sees the raw value. -NemoClaw does not bake `TAVILY_API_KEY` into the managed config or image, and the managed wrapper rejects direct service-key injection into `dcode`. -Because OpenShell attributes the harness's calls to the sandbox `python3` interpreter, this egress is process-wide for sandbox Python rather than a `dcode`-only boundary. +The shared `tavily` preset only opens `POST /search` and `POST /extract` egress to `api.tavily.com:443`. Attaching the credential provider alone does not authorize the managed Python interpreter; the explicit policy preset is the interpreter-level opt-in. Export `TAVILY_API_KEY` only for registration, then remove it from the host shell; the gateway injects the stored value at egress, and the sandbox never sees the raw value. NemoClaw does not bake `TAVILY_API_KEY` into the managed config or image, and the managed wrapper rejects direct service-key injection into `dcode`. Because OpenShell attributes the harness's calls to the sandbox `python3` interpreter, this egress is process-wide for sandbox Python rather than a `dcode`-only boundary. Remove the target sandbox's managed-Python opt-in when it is no longer needed. @@ -261,9 +209,7 @@ Remove the target sandbox's managed-Python opt-in when it is no longer needed. nemo-deepagents policy remove tavily --yes ``` -This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. -When no sandbox needs the provider, destroy those sandboxes or detach it from each one with `openshell sandbox provider detach tavily-search`, then remove it globally with `nemo-deepagents credentials reset tavily-search --yes`. -OpenShell rejects provider deletion while any sandbox still has it attached. +This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, destroy those sandboxes or detach it from each one with `openshell sandbox provider detach tavily-search`, then remove it globally with `nemo-deepagents credentials reset tavily-search --yes`. OpenShell rejects provider deletion while any sandbox still has it attached. @@ -297,16 +243,9 @@ nemo-deepagents rebuild nemo-deepagents snapshot create --name before-change ``` -If you upgrade from a release that persisted LangSmith environment values, rebuild each existing Deep Agents Code sandbox so its image includes the corrected `start.sh`. -If an existing sandbox displays `Choose a Recommended Model`, rebuild it so its image includes the managed startup behavior. +If you upgrade from a release that persisted LangSmith environment values, rebuild each existing Deep Agents Code sandbox so its image includes the corrected `start.sh`. If an existing sandbox displays `Choose a Recommended Model`, rebuild it so its image includes the managed startup behavior. -`status` reports the selected harness as a terminal runtime and prints the interactive/headless command shape. -If `status` reports `Runtime health: degraded` with an OOM kill count, rebuild the sandbox to restore the terminal runtime. -Proxy launchers and startup scripts are baked into the sandbox image. -After upgrading NemoClaw from a release with older Deep Agents Code routing, rebuild each existing sandbox before troubleshooting `inference.local` connectivity. -NemoClaw v0.0.78 and newer clients fail closed when a pre-v0.0.78 sandbox image lacks the trusted `/usr/local/lib/nemoclaw/dcode-managed-exec` route-probe helper, even when the installed Deep Agents Code version still matches the managed manifest. -Rebuild the sandbox to install that image-owned helper before retrying `status`, `doctor`, `connect`, or onboarding recovery. -There is no dashboard port or long-running gateway process for this harness. +`status` reports the selected harness as a terminal runtime and prints the interactive/headless command shape. If `status` reports `Runtime health: degraded` with an OOM kill count, rebuild the sandbox to restore the terminal runtime. Proxy launchers and startup scripts are baked into the sandbox image. After upgrading NemoClaw from a release with older Deep Agents Code routing, rebuild each existing sandbox before troubleshooting `inference.local` connectivity. NemoClaw v0.0.78 and newer clients fail closed when a pre-v0.0.78 sandbox image lacks the trusted `/usr/local/lib/nemoclaw/dcode-managed-exec` route-probe helper, even when the installed Deep Agents Code version still matches the managed manifest. Rebuild the sandbox to install that image-owned helper before retrying `status`, `doctor`, `connect`, or onboarding recovery. There is no dashboard port or long-running gateway process for this harness. ## Next Steps diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 780e22aee9d..fd06b486a1b 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -11,8 +11,8 @@ content: skill: priority: 20 --- -NemoClaw snapshots preserve manifest-defined sandbox state before destructive or state-changing operations. -They are the preferred backup and restore path. + +NemoClaw snapshots preserve manifest-defined sandbox state before destructive or state-changing operations. They are the preferred backup and restore path. ## When to Create a Snapshot @@ -34,38 +34,25 @@ They are the preferred backup and restore path. ## Understand Snapshot Contents -Snapshots capture the manifest-declared snapshot state directories and store them in `~/.nemoclaw/rebuild-backups//`. -Agent manifests can also declare durable top-level state files. -Treat snapshot directories as private local data. +Snapshots capture the manifest-declared snapshot state directories and store them in `~/.nemoclaw/rebuild-backups//`. Agent manifests can also declare durable top-level state files. Treat snapshot directories as private local data. - -Inside an OpenClaw sandbox, `~` expands to `/sandbox`, not to the OpenClaw workspace. -Files such as `~/USER.md` and `~/SOUL.md` are therefore outside OpenClaw's managed state and are not included in snapshots. -Store them as `$OPENCLAW_WORKSPACE_DIR/USER.md` and `$OPENCLAW_WORKSPACE_DIR/SOUL.md` so snapshot and restore operations preserve them. - + + Inside an OpenClaw sandbox, `~` expands to `/sandbox`, not to the OpenClaw workspace. Files such + as `~/USER.md` and `~/SOUL.md` are therefore outside OpenClaw's managed state and are not + included in snapshots. Store them as `$OPENCLAW_WORKSPACE_DIR/USER.md` and + `$OPENCLAW_WORKSPACE_DIR/SOUL.md` so snapshot and restore operations preserve them. + -Before NemoClaw marks a snapshot complete, it strips recognized credential values from copied JSON, YAML, and `.env` files. -It preserves recognized dependency lockfiles byte for byte when they contain only dependency metadata. -This behavior includes installed npm `.package-lock.json` files. -It omits a recognized lockfile when the file is invalid or contains any of these values: +Before NemoClaw marks a snapshot complete, it strips recognized credential values from copied JSON, YAML, and `.env` files. It preserves recognized dependency lockfiles byte for byte when they contain only dependency metadata. This behavior includes installed npm `.package-lock.json` files. It omits a recognized lockfile when the file is invalid or contains any of these values: - A credential field. - A provider-shaped secret outside a dependency URL. - URL user information. - A credential-bearing query parameter. -Dependency names in lockfile maps do not count as credential fields. -NemoClaw also preserves valid, credential-free `node_modules/**/package.json` manifests byte for byte because dependency names can match credential field names. -It omits an installed package manifest when the file contains invalid JSON, a credential or authentication field, a provider-shaped secret, or a credential-bearing URL. -It continues to sanitize configuration and `.env` files inside installed dependency trees. -It preserves OpenShell credential placeholders so rebuild can reattach the host-side provider. -If NemoClaw cannot sanitize a copied configuration or environment file, it omits that file from the snapshot. -If it cannot remove the unsafe file, snapshot creation returns an error. -It deletes the incomplete backup when cleanup succeeds and reports when the backup remains. -This sanitization uses an isolated `python3` helper on POSIX hosts to keep reads, replacements, and removals anchored to opened directory descriptors. -If a copied file or parent directory changes identity during the operation, snapshot creation fails closed instead of following the changed path. +Dependency names in lockfile maps do not count as credential fields. NemoClaw also preserves valid, credential-free `node_modules/**/package.json` manifests byte for byte because dependency names can match credential field names. It omits an installed package manifest when the file contains invalid JSON, a credential or authentication field, a provider-shaped secret, or a credential-bearing URL. It continues to sanitize configuration and `.env` files inside installed dependency trees. It preserves OpenShell credential placeholders so rebuild can reattach the host-side provider. If NemoClaw cannot sanitize a copied configuration or environment file, it omits that file from the snapshot. If it cannot remove the unsafe file, snapshot creation returns an error. It deletes the incomplete backup when cleanup succeeds and reports when the backup remains. This sanitization uses an isolated `python3` helper on POSIX hosts to keep reads, replacements, and removals anchored to opened directory descriptors. If a copied file or parent directory changes identity during the operation, snapshot creation fails closed instead of following the changed path. A previous release sanitized dependency lockfiles and installed package manifests. @@ -87,29 +74,24 @@ NemoClaw uses SQLite's online backup API and restores these databases through SQ After it replaces a database, NemoClaw opens a write transaction against the result and fails the restore when the database cannot be written. Named-profile cron and Discord databases under `.hermes/profiles//` use raw directory capture and can be inconsistent if a write overlaps the snapshot. -Kanban backup is limited to the backward-compatible default board in `kanban.db`. -Named boards, attachments, worker logs, scratch workspaces under `.hermes/kanban/`, and external directory or worktree targets are not included; back up that state separately. +Kanban backup is limited to the backward-compatible default board in `kanban.db`. Named boards, attachments, worker logs, scratch workspaces under `.hermes/kanban/`, and external directory or worktree targets are not included; back up that state separately. + +The dashboard profile includes `MEMORY.md` and `USER.md`. The Hermes state database can contain session metadata and message history needed for a faithful restore. -The dashboard profile includes `MEMORY.md` and `USER.md`. -The Hermes state database can contain session metadata and message history needed for a faithful restore. Deep Agents snapshots include manifest-declared state under `/sandbox/.deepagents`, including skills and runtime state, while omitting credential-bearing user files. NemoClaw refuses to create a snapshot when it detects an active `dcode` task or cannot verify that the Deep Agents state tree is idle. Wait for active `dcode` work to finish before running `$$nemoclaw snapshot create`. + -Snapshots preserve sandbox registry metadata that affects rebuild behavior, including custom policy presets applied with `policy add --from-file` or `policy add --from-dir` and baseline network policy entries excluded with `policy exclude`. -When you restore a snapshot, NemoClaw replays those recorded custom presets with their stored YAML content, so you do not need the original preset files on disk, and rebuild continues to apply the recorded baseline exclusions. +Snapshot clone reads the source sandbox policy from OpenShell and passes that complete current OpenShell policy to destination creation through a private temporary handoff. The snapshot manifest and registry contain no custom-preset copy, baseline-exclusion record, or desired-policy replay state. -The target sandbox's current agent manifest remains authoritative for directory and state-file restore behavior. -NemoClaw rejects the restore when the snapshot's agent, config directory, any snapshot directory, state-file path, or state-file strategy conflicts with that manifest. -Restore limits directory cleanup to state directories authorized by both the snapshot and the current manifest. -It preserves target-only directories and directories whose backup failed. +The target sandbox's current agent manifest remains authoritative for directory and state-file restore behavior. NemoClaw rejects the restore when the snapshot's agent, config directory, any snapshot directory, state-file path, or state-file strategy conflicts with that manifest. Restore limits directory cleanup to state directories authorized by both the snapshot and the current manifest. It preserves target-only directories and directories whose backup failed. -For managed images, NemoClaw applies the current manifest's managed config merge rules by default and does not fall back to whole-file replacement. -For Deep Agents targets, whole-file config replacement is limited to sandboxes created from a custom Dockerfile. +For managed images, NemoClaw applies the current manifest's managed config merge rules by default and does not fall back to whole-file replacement. For Deep Agents targets, whole-file config replacement is limited to sandboxes created from a custom Dockerfile. ## Create and List Snapshots @@ -118,14 +100,12 @@ $$nemoclaw my-assistant snapshot create $$nemoclaw my-assistant snapshot list ``` -`snapshot list` prints a table of version, name, timestamp, and path. -NemoClaw computes versions (`v1`, `v2`, through `vN`) from timestamp order, so `vN` is always the newest snapshot. +`snapshot list` prints a table of version, name, timestamp, and path. NemoClaw computes versions (`v1`, `v2`, through `vN`) from timestamp order, so `vN` is always the newest snapshot. + +`snapshot create` requires shields to be down. Snapshot creation and restore share the per-sandbox transition lock with the shields auto-restore timer. -`snapshot create` requires shields to be down. -Snapshot creation and restore share the per-sandbox transition lock with the shields auto-restore timer. +If a timed shields-down window expires during snapshot work, the deadline gate blocks new mutations and waits for the exact snapshot owner to finish without signaling it. Snapshot work does not bypass recovery for an expired shields-down window. -If a timed shields-down window expires during snapshot work, the deadline gate blocks new mutations and waits for the snapshot owner to finish without signaling it. -Snapshot work does not bypass recovery for an expired shields-down window. Follow [Timed Shields Windows](../configure-sandboxes/understand-runtime-changes#timed-shields-windows) to correct state-directory failures or complete generation recovery before you rerun `$$nemoclaw snapshot create`. @@ -142,10 +122,7 @@ Tag a snapshot with a human-readable label: $$nemoclaw my-assistant snapshot create --name before-upgrade ``` -When a directory or state file cannot be captured, `snapshot create` reports the failed items, removes the incomplete snapshot, and exits nonzero. -`snapshot list` shows no new entry, so a later restore cannot select a capture that never completed. -If removal fails, the command reports the listed snapshot path. -Remove that directory manually before you run `snapshot restore` because the incomplete capture remains selectable. +When a directory or state file cannot be captured, `snapshot create` reports the failed items, removes the incomplete snapshot, and exits nonzero. `snapshot list` shows no new entry, so a later restore cannot select a capture that never completed. If removal fails, the command reports the listed snapshot path. Remove that directory manually before you run `snapshot restore` because the incomplete capture remains selectable. ## Restore a Snapshot @@ -155,8 +132,7 @@ Restore the latest snapshot: $$nemoclaw my-assistant snapshot restore ``` -Pass a version, name, or timestamp to select a specific snapshot. -Use the complete timestamp from `snapshot list`; a timestamp prefix does not select a snapshot. +Pass an exact version, name, or timestamp to select a specific snapshot. Use the exact timestamp from `snapshot list`; a timestamp prefix does not select a snapshot. ```bash $$nemoclaw my-assistant snapshot restore v3 @@ -164,18 +140,7 @@ $$nemoclaw my-assistant snapshot restore before-upgrade $$nemoclaw my-assistant snapshot restore 2026-04-14T09-40-09-760Z ``` - -Post-restore policy reconciliation is best-effort. -NemoClaw warns and continues the remaining restore steps in these cases: - -- NemoClaw cannot verify whether a custom policy owns the live `observability-otlp-local` policy entry. -- The built-in `observability-otlp-local` policy preset has drifted or cannot be inspected. -- NemoClaw cannot add or remove a recorded policy preset. - -The live network policy can then retain unwanted egress or omit expected egress until you repair the named preset. -After a warning, run `$$nemoclaw policy list`. -Confirm that the named preset is recorded in the sandbox registry and active on the gateway, or absent from both. - +In-place restore does not mutate the OpenShell policy. Cross-sandbox clone reads the source live policy and uses it only as the destination creation handoff. A running Hermes gateway keeps serving its pre-restore state databases until it reopens them. @@ -183,26 +148,18 @@ After a restore that includes Hermes state databases, the CLI prints a reminder Run `$$nemoclaw gateway restart` to make the gateway open the restored databases. -To clone a snapshot into a different sandbox name, pass `--to `. -If the destination sandbox already exists, NemoClaw refuses to overwrite it unless you pass `--force`: +To clone a snapshot into a different sandbox name, pass `--to `. If the destination sandbox already exists, NemoClaw refuses to overwrite it unless you pass `--force`: ```bash $$nemoclaw my-assistant snapshot restore before-upgrade --to my-assistant-clone $$nemoclaw my-assistant snapshot restore before-upgrade --to my-assistant-clone --force --yes ``` -Cross-sandbox restore from a stopped source is available for Docker- and VM-driver sandboxes. -For a stopped source, its registry entry must record both the sandbox image and a complete inference route; NemoClaw creates the destination from the recorded image. -NemoClaw stops before creating or replacing the destination when either record is missing, and directs you to run `$$nemoclaw onboard` when no image is recorded. -For a Kubernetes-driver source, the pod image must remain resolvable through its gateway. +Cross-sandbox restore from a stopped source is available for Docker- and VM-driver sandboxes. For a stopped source, its registry entry must record both the sandbox image and a complete inference route; NemoClaw creates the destination from the recorded image. NemoClaw stops before creating or replacing the destination when either record is missing, and directs you to run `$$nemoclaw onboard` when no image is recorded. For a Kubernetes-driver source, the pod image must remain resolvable through its gateway. -For a new destination, NemoClaw waits for the owning gateway to report the sandbox as Ready with a valid live identity. -It checks that identity again immediately before registration. -NemoClaw assigns the destination a new lifecycle generation instead of copying the source sandbox's generation. +For a new destination, NemoClaw waits for the owning gateway to report the sandbox as Ready with a valid live identity. It checks that identity again immediately before registration. NemoClaw assigns the destination a new lifecycle generation instead of copying the source sandbox's generation. -If the destination is not Ready with the same valid identity, the restore exits nonzero before registration or state restore. -The OpenShell sandbox remains created but unregistered, so `--force` cannot select it for deletion. -Run the owner-scoped deletion command printed by the failure: +If the destination is not Ready with the same valid identity, the restore exits nonzero before registration or state restore. The OpenShell sandbox remains created but unregistered, so `--force` cannot select it for deletion. Run the exact owner-scoped deletion command printed by the failure: ```bash openshell sandbox delete -g '' '' @@ -210,13 +167,13 @@ openshell sandbox delete -g '' '' After OpenShell deletes the destination, rerun the original `snapshot restore --to` command. -For dashboard-enabled agents, NemoClaw allocates the destination sandbox its own dashboard port instead of reusing the source port. -If no port is available, restore stops before deleting an existing `--force` destination. +For dashboard-enabled agents, NemoClaw allocates the destination sandbox its own dashboard port instead of reusing the source port. If no port is available, restore stops before deleting an existing `--force` destination. -NemoClaw also allocates the destination sandbox its own OpenAI-compatible API port from `8642` through `8652` instead of reusing the source port. -If no port in that range is free, restore stops before deleting an existing `--force` destination. -Run `openshell forward list` to read the destination sandbox's API port. + NemoClaw also allocates the destination sandbox its own OpenAI-compatible API port from `8642` + through `8652` instead of reusing the source port. If no port in that range is free, restore stops + before deleting an existing `--force` destination. Run `openshell forward list` to read the + destination sandbox's API port. @@ -225,37 +182,27 @@ If the check fails, the command leaves the destination registered without restor Correct the reported supervisor failure, then run `$$nemoclaw destroy` or rerun the restore with `--force`. -The force-overwrite path restores and verifies lockdown on a destination with an active shields timer, then revokes that timer before it deletes the destination. -It clears the remaining local shields state only after deletion succeeds, before a same-name replacement is created. +The force-overwrite path restores and verifies lockdown on a destination with an active shields timer, then revokes that timer before it deletes the destination. It clears the remaining local shields state only after deletion succeeds, before a same-name replacement is created. ## Restore Agent Configuration Safely -The `$$nemoclaw rebuild` command uses the same snapshot mechanism automatically. -NemoClaw rejects unsafe symlinks and special files inside sandbox state during backup creation. -It records multiply-linked regular files and archives each path as a separate regular file. +The `$$nemoclaw rebuild` command uses the same snapshot mechanism automatically. NemoClaw rejects unsafe symlinks and special files inside sandbox state during backup creation. It records multiply-linked regular files and archives each path as a separate regular file. Snapshot restore performs a targeted repair for legacy `.openclaw-data` symlinks that older images created. Snapshots also preserve user-owned `openclaw.json` settings. -During rebuild or restore, NemoClaw merges those settings with the freshly generated runtime config so current provider placeholders, messaging enablement, and gateway state win over stale snapshot values. -If the restored config cannot be parsed or applied safely, NemoClaw stops the restore instead of replacing the generated config with an unsafe fallback. +During rebuild or restore, NemoClaw merges those settings with the freshly generated runtime config so current provider placeholders, messaging enablement, and gateway state win over stale snapshot values. If the restored config cannot be parsed or applied safely, NemoClaw stops the restore instead of replacing the generated config with an unsafe fallback. + +OpenClaw's device identity keys and paired-device tokens are intentionally excluded from snapshots because backup sanitization scrubs them beyond use. Snapshot state replacement does not overwrite the destination sandbox's gateway pairing files, even when an older snapshot still contains them. After a cross-sandbox restore creates the destination, NemoClaw establishes gateway pairing and verifies it with an authenticated agent run. If verification fails, the restored state remains in the destination and the command exits nonzero. Run `$$nemoclaw connect` to retry pairing before you run an agent. OpenClaw regenerates its device identity on demand. -OpenClaw's device identity keys and paired-device tokens are intentionally excluded from snapshots because backup sanitization scrubs them beyond use. -Snapshot state replacement does not overwrite the destination sandbox's gateway pairing files, even when an older snapshot still contains them. -After a cross-sandbox restore creates the destination, NemoClaw establishes gateway pairing and verifies it with an authenticated agent run. -If verification fails, the restored state remains in the destination and the command exits nonzero. -Run `$$nemoclaw connect` to retry pairing before you run an agent. -OpenClaw regenerates its device identity on demand. Credential-bearing Hermes files such as `auth.json` are intentionally excluded from snapshots. NemoClaw-regenerated Hermes config files, including `config.yaml` and `.env`, are also excluded. NemoClaw recreates model, provider, and messaging credentials from host-side onboarding and OpenShell provider state during rebuild. -If a Hermes rebuild cannot validate or release its NemoClaw cron restore gate, NemoClaw preserves the state backup. -If the rebuild already accepted the replacement sandbox, it also preserves the replacement journal. -New Hermes turns and cron dispatch remain blocked while the gate exists. +If a Hermes rebuild cannot validate or release its NemoClaw cron restore gate, NemoClaw preserves the state backup. If the rebuild already accepted the replacement sandbox, it also preserves the replacement journal. New Hermes turns and cron dispatch remain blocked while the gate exists. Do not manually remove the root-owned cron restore marker. @@ -266,31 +213,26 @@ If an independent Hermes operator drain exists, recovery leaves it active. After recovery succeeds, rerun `rebuild` with the same replacement settings so NemoClaw can retire the replacement journal. -After a rebuild restores `dashboard-home` or `profiles`, NemoClaw reruns the dashboard state migration before it reports the restore as complete. -During rebuild restore, NemoClaw moves disjoint top-level entries from the legacy dashboard directory without replacing entries in the canonical profile. -If an entry collides or migration otherwise fails, NemoClaw marks the restore incomplete instead of reporting success. +After a rebuild restores `dashboard-home` or `profiles`, NemoClaw reruns the dashboard state migration before it reports the restore as complete. During rebuild restore, NemoClaw moves disjoint top-level entries from the legacy dashboard directory without replacing entries in the canonical profile. If an entry collides or migration otherwise fails, NemoClaw marks the restore incomplete instead of reporting success. + ### Excluded Deep Agents State -Credential-bearing Deep Agents files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are intentionally excluded from snapshots. -Deep Agents auth state files such as `.deepagents/.state/auth.json` and `.deepagents/.state/chatgpt-auth.json` are also excluded because the managed launcher refuses to start when upstream credential state is present. +Credential-bearing Deep Agents files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are intentionally excluded from snapshots. Deep Agents auth state files such as `.deepagents/.state/auth.json` and `.deepagents/.state/chatgpt-auth.json` are also excluded because the managed launcher refuses to start when upstream credential state is present. -The managed `.deepagents/.nemoclaw-mcp.json` projection and `hooks.json` are excluded because NemoClaw reconstructs managed MCP state and disables executable Deep Agents Code hooks in the managed harness. -NemoClaw recreates the current inference route headers, `models` and `update` tables, managed MCP projection state, and provider credentials from host-side onboarding and OpenShell provider state during rebuild. +The managed `.deepagents/.nemoclaw-mcp.json` projection and `hooks.json` are excluded because NemoClaw reconstructs managed MCP state and disables executable Deep Agents Code hooks in the managed harness. NemoClaw recreates the current inference route headers, `models` and `update` tables, managed MCP projection state, and provider credentials from host-side onboarding and OpenShell provider state during rebuild. ### Restore Managed Deep Agents Configuration -For a NemoClaw-managed Deep Agents image, NemoClaw restores only the allowlisted `ui.show_scrollbar`, `ui.show_url_open_toast`, `threads.relative_time`, and `threads.sort_order` preferences from the previous `config.toml` when their values pass validation. -Unknown, runtime-controlled, executable, and security-sensitive backup keys are dropped instead of replacing freshly generated settings on that managed path. +For a NemoClaw-managed Deep Agents image, NemoClaw restores only the allowlisted `ui.show_scrollbar`, `ui.show_url_open_toast`, `threads.relative_time`, and `threads.sort_order` preferences from the previous `config.toml` when their values pass validation. Unknown, runtime-controlled, executable, and security-sensitive backup keys are dropped instead of replacing freshly generated settings on that managed path. -A Deep Agents target created from a custom Dockerfile restores `config.toml` as a whole file because the custom image owns its config schema. -On the managed key-level restore path, malformed config, missing managed data, an unsafe link, or an unsafe file replacement fails the restore without falling back to a whole-file copy. +A Deep Agents target created from a custom Dockerfile restores `config.toml` as a whole file because the custom image owns its config schema. On the managed key-level restore path, malformed config, missing managed data, an unsafe link, or an unsafe file replacement fails the restore without falling back to a whole-file copy. ### Validate Before Replacement -Before a Deep Agents rebuild changes the sandbox, NemoClaw verifies the recorded inference route, provider, model, reasoning settings, web search selection, base image, and policy inputs. -If a late check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. +Before a Deep Agents rebuild changes the sandbox, NemoClaw verifies the recorded inference route, provider, model, reasoning settings, web search selection, base image, and policy inputs. If a late check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. + ## Back Up Every Registered Sandbox @@ -301,24 +243,11 @@ Run `$$nemoclaw backup-all` before broad maintenance such as `$$nemoclaw update` $$nemoclaw backup-all ``` -`backup-all` walks the sandboxes registered on the host, creates a snapshot for each eligible running or temporarily started sandbox, and stores the snapshot bundles under `~/.nemoclaw/rebuild-backups//`. -If a registered docker-driver sandbox's container is stopped, `backup-all` starts the container for the duration of the backup and returns it to its stopped state afterward. -If the container cannot be returned to the stopped state, the backup run fails and reports that the container was left running. -If a sandbox is not running and its container cannot be started this way, start the sandbox or its container and rerun `$$nemoclaw backup-all`. - -For each eligible sandbox, `backup-all` holds one lifecycle transaction through the complete backup. -Within that transaction, it starts a stopped container when required, opens a 30-minute shields-down window when the sandbox starts with Shields up, copies sandbox state, restores the previous Shields state, and returns any container it started to the stopped state. -A sandbox that starts with Shields down remains down. -If the timer expires during the transaction, the deadline gate blocks new mutations and waits for the backup owner to finish without signaling it. -An initial lock or unlock failure marks that sandbox as failed, and `backup-all` continues with the next sandbox. -NemoClaw attempts to restore the previous Shields state before it processes the next sandbox, including when the backup fails. -If lockdown cannot be restored, `backup-all` stops and does not process the remaining sandboxes. -For an ordinary relock failure, correct the reported issue and follow the printed recovery command before rerunning `$$nemoclaw backup-all`. -If NemoClaw reports that Backup Shields policy recovery failed, do not retry Shields up from the mutable live policy. -Restore a trusted backup, recreate the sandbox, and then rerun `$$nemoclaw backup-all`. - -When a backup fails, NemoClaw identifies the affected state item and reports `permission denied`, `tar read error`, or `absent after extraction` when available. -Use `$$nemoclaw snapshot list` and `$$nemoclaw snapshot restore` to inspect or restore one sandbox's bundles later. +`backup-all` walks the sandboxes registered on the host, creates a snapshot for each eligible running or temporarily started sandbox, and stores the snapshot bundles under `~/.nemoclaw/rebuild-backups//`. If a registered docker-driver sandbox's container is stopped, `backup-all` starts the container for the duration of the backup and returns it to its stopped state afterward. If the container cannot be returned to the stopped state, the backup run fails and reports that the container was left running. If a sandbox is not running and its container cannot be started this way, start the sandbox or its container and rerun `$$nemoclaw backup-all`. + +For each eligible sandbox, `backup-all` holds one lifecycle transaction through the complete backup. Within that transaction, it starts a stopped container when required, opens a 30-minute shields-down window when the sandbox starts with Shields up, copies sandbox state, restores the previous Shields state, and returns any container it started to the stopped state. A sandbox that starts with Shields down remains down. If the timer expires during the transaction, the deadline gate blocks new mutations and waits for the exact backup owner to finish without signaling it. An initial lock or unlock failure marks that sandbox as failed, and `backup-all` continues with the next sandbox. NemoClaw attempts to restore the previous Shields state before it processes the next sandbox, including when the backup fails. If lockdown cannot be restored, `backup-all` stops and does not process the remaining sandboxes. For an ordinary relock failure, correct the reported issue and follow the printed recovery command before rerunning `$$nemoclaw backup-all`. If NemoClaw reports that Backup Shields policy recovery failed, do not retry Shields up from the mutable live policy. Restore a trusted backup, recreate the sandbox, and then rerun `$$nemoclaw backup-all`. + +When a backup fails, NemoClaw identifies the affected state item and reports `permission denied`, `tar read error`, or `absent after extraction` when available. Use `$$nemoclaw snapshot list` and `$$nemoclaw snapshot restore` to inspect or restore one sandbox's bundles later. ## Related Topics diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 61772bdedaa..4cc214d9d3c 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -5,12 +5,21 @@ title: "Manage MCP Servers" sidebar-title: "Manage MCP Servers" description: "Inspect advertised tools and DNS pins, probe, rotate, restart, remove, rebuild, and destroy NemoClaw-managed MCP servers." description-agent: "Explains managed MCP status, DNS pin drift, advertised tool discovery, credential-resolution probes, credential rotation, restart, removal, rebuild restoration, destroy recovery, and lifecycle locking. Use after an MCP server is registered." -keywords: ["nemoclaw mcp status", "mcp dns pin drift", "mcp tool discovery", "nemoclaw mcp restart", "nemoclaw mcp remove", "mcp credential rotation"] +keywords: + [ + "nemoclaw mcp status", + "mcp dns pin drift", + "mcp tool discovery", + "nemoclaw mcp restart", + "nemoclaw mcp remove", + "mcp credential rotation", + ] content: type: "how_to" skill: priority: 50 --- + Use the host-side MCP commands to inspect and change registered servers. ## List and Inspect Servers @@ -20,19 +29,13 @@ $$nemoclaw my-sandbox mcp list $$nemoclaw my-sandbox mcp status github --json ``` -`list --json` and `status --json` never include environment values. -They report provider presence, provider attachment, whether live policy matches registered policy, environment readiness, and adapter registration state. +`list --json` and `status --json` never include environment values. They report provider presence, provider attachment, whether live policy matches registered policy, environment readiness, and adapter registration state. -The per-server `warnings` array reports unsupported stored boundaries, trusted-private DNS pin changes, and credential-resolution findings. -The `env.missing` field lists recorded host variable names that are currently unset. +The per-server `warnings` array reports unsupported stored boundaries, trusted-private DNS pin changes, and credential-resolution findings. The `env.missing` field lists recorded host variable names that are currently unset. -An existing valid provider can remain ready when a host variable is unset because OpenShell retains the credential. -The JSON value `support.mode: "bridge"` identifies the agent's config-adapter capability, not a host-side traffic bridge. +An existing valid provider can remain ready when a host variable is unset because OpenShell retains the credential. The JSON value `support.mode: "bridge"` identifies the agent's config-adapter capability, not a host-side traffic bridge. -For a trusted private server, status resolves the endpoint without changing managed state. -Text output reports `private address pins: match`, `drift`, or `unresolved`. -JSON output reports the same value in `trustedPrivateTarget.state` and includes the recorded pins. -Status never adds a new address to the policy. +For a trusted private server, status resolves the endpoint without changing managed state. Text output reports `private address pins: match`, `drift`, or `unresolved`. JSON output reports the same value in `trustedPrivateTarget.state` and includes the recorded pins. Status never adds a new address to the policy. ## Discover Advertised Tools @@ -43,21 +46,13 @@ $$nemoclaw my-sandbox mcp status github --tools $$nemoclaw my-sandbox mcp status github --tools --json ``` -The shared discovery runtime uses the managed registration's existing OpenShell credential provider and generated policy. -OpenShell injects the credential at the policy boundary; it is not passed to the runtime through arguments, environment values, or an authorization option. -The same runtime and thin adapter ancestry are used across OpenClaw, Hermes, and LangChain Deep Agents Code sandboxes. +The shared discovery runtime uses the managed registration's existing OpenShell credential provider and generated policy. OpenShell injects the credential at the policy boundary; it is not passed to the runtime through arguments, environment values, or an authorization option. The same runtime and thin adapter ancestry are used across OpenClaw, Hermes, and LangChain Deep Agents Code sandboxes. -Run discovery only against a configured endpoint you trust to advertise names while authenticated. -The endpoint controls every returned tool name and can derive those names from the request or credential it receives; NemoClaw validates and bounds the returned text but cannot prove that an authenticated endpoint did not encode credential-derived data in an otherwise valid name. +Run discovery only against a configured endpoint you trust to advertise names while authenticated. The endpoint controls every returned tool name and can derive those names from the request or credential it receives; NemoClaw validates and bounds the returned text but cannot prove that an authenticated endpoint did not encode credential-derived data in an otherwise valid name. -A discovery result is a point-in-time list from the configured MCP server, not an attestation of the tools visible to the model in an active agent session. -Agent configuration, progressive disclosure, runtime filters, and session state can further limit model-visible tools. +A discovery result is a point-in-time list from the configured MCP server, not an attestation of the exact tools visible to the model in an active agent session. Agent configuration, progressive disclosure, runtime filters, and session state can further limit model-visible tools. -NemoClaw runs the standard MCP `initialize`, `notifications/initialized`, and paginated `tools/list` lifecycle. -It retains and returns tool names only, never prints the other tool-definition fields returned by `tools/list`, and never calls a tool. -The client bounds total time, per-request time, cumulative response bytes, pages, tool count, cursor length, and tool-name length. -It attempts to close the MCP session and transport on both success and failure. -Cleanup errors do not replace the bounded discovery result. +NemoClaw runs the standard MCP `initialize`, `notifications/initialized`, and paginated `tools/list` lifecycle. It retains and returns tool names only, never prints the other tool-definition fields returned by `tools/list`, and never calls a tool. The client bounds total time, per-request time, cumulative response bytes, pages, tool count, cursor length, and tool-name length. It attempts to close the MCP session and transport on both success and failure. Cleanup errors do not replace the bounded discovery result. JSON output adds a per-server `toolDiscovery` object without changing the existing status fields: @@ -72,44 +67,31 @@ JSON output adds a per-server `toolDiscovery` object without changing the existi } ``` -Discovery is opt-in and sends authenticated network traffic to the configured endpoint. -`--tools` requires one server name and suppresses the named-server credential-resolution probe that would otherwise run by default. -Pass `--probe --tools` when you intentionally want both live checks. +Discovery is opt-in and sends authenticated network traffic to the configured endpoint. `--tools` requires one server name and suppresses the named-server credential-resolution probe that would otherwise run by default. Pass `--probe --tools` when you intentionally want both live checks. -An older sandbox image that does not contain the shared discovery client reports that a rebuild is required. -Run `$$nemoclaw my-sandbox rebuild` and retry the command. +An older sandbox image that does not contain the shared discovery client reports that a rebuild is required. Run `$$nemoclaw my-sandbox rebuild` and retry the command. -A discovery failure sets `toolDiscovery.ok` to `false` and leaves the ordinary provider, policy, environment, and adapter status available. -Bounded partial results set `truncated` to `true` and include a redacted `detail` value. +A discovery failure sets `toolDiscovery.ok` to `false` and leaves the ordinary provider, policy, environment, and adapter status available. Bounded partial results set `truncated` to `true` and include a redacted `detail` value. ## Verify Credential Resolution -Provider presence and metadata cannot prove that OpenShell rewrites the recorded resolver placeholder when a request leaves the sandbox. -`mcp status ` requests a differential wire-level credential-resolution probe by default. +Provider presence and metadata cannot prove that OpenShell rewrites the recorded resolver placeholder when a request leaves the sandbox. `mcp status ` requests a differential wire-level credential-resolution probe by default. -Before sending probe traffic, NemoClaw verifies generated policy, expected provider attachment, provider ID, `nemoclaw-mcp-v1` type, valid resource version, and exactly one matching credential key. -If readiness does not match, it reports `ok: null` with `probe skipped` and sends no request. +Before sending probe traffic, NemoClaw verifies exact generated policy, expected provider attachment, provider ID, `nemoclaw-mcp-v1` type, valid resource version, and exactly one matching credential key. If readiness does not match, it reports `ok: null` with `probe skipped` and sends no request. -When readiness passes, the probe sends the same idempotent MCP `initialize` request twice from inside the sandbox through the adapter runtime. -One request carries the real placeholder header, and the control request carries a deliberately unresolvable literal bearer. +When readiness passes, the probe sends the same idempotent MCP `initialize` request twice from inside the sandbox through the adapter runtime. One request carries the real placeholder header, and the control request carries a deliberately unresolvable literal bearer. -A working rewrite makes the two requests reach the endpoint with different bearers. -Only a placeholder HTTP 2xx paired with a rejected control verifies resolution because an accepted request proves a valid credential was on the wire. +A working rewrite makes the two requests reach the endpoint with different bearers. Only a placeholder HTTP 2xx paired with a rejected control verifies resolution because an accepted request proves a valid credential was on the wire. -Every non-2xx placeholder outcome is inconclusive. -Identical HTTP 400, 401, or 403 rejections tell you to verify the stored credential first. +Every non-2xx placeholder outcome is inconclusive. Identical HTTP 400, 401, or 403 rejections tell you to verify the stored credential first. -For HTTP 401 or 403, a confirmed-valid credential means the host is not rewriting placeholders. -HTTP 400 remains inconclusive because the endpoint may reject the probe request itself. +For HTTP 401 or 403, a confirmed-valid credential means the host is not rewriting placeholders. HTTP 400 remains inconclusive because the endpoint may reject the probe request itself. -The probe never captures or prints endpoint response bodies. -It refuses to run against a persisted URL that no longer satisfies the authenticated-endpoint boundary. +The probe never captures or prints endpoint response bodies. It refuses to run against a persisted URL that no longer satisfies the authenticated-endpoint boundary. -The verdict appears as `provider.credentialResolution` in JSON and as a `credential resolution:` line in text output. -Pass `--no-probe` to skip the probe or `--probe` to request it for every server in multi-server status. +The verdict appears as `provider.credentialResolution` in JSON and as a `credential resolution:` line in text output. Pass `--no-probe` to skip the probe or `--probe` to request it for every server in multi-server status. -The bare `mcp list` and `mcp status` forms never probe, so they stay fast. -Endpoint outages, policy denials, timeouts, unreachable sandboxes, and endpoints that accept both probes report `ok: null` with evidence in `detail`. +The bare `mcp list` and `mcp status` forms never probe, so they stay fast. Endpoint outages, policy denials, timeouts, unreachable sandboxes, and endpoints that accept both probes report `ok: null` with evidence in `detail`. ## Rotate a Credential @@ -121,31 +103,21 @@ $$nemoclaw my-sandbox mcp restart github unset GITHUB_MCP_TOKEN ``` -`restart` imports or verifies the endpointless `nemoclaw-mcp-v1` profile, applies the generated policy without a credential binding, updates or reuses the provider, and attaches it. -It then applies the endpoint-bound policy, waits for credential readiness, and refreshes the adapter. -An ambiguous or failed update is not treated as successful merely because another writer advanced the provider revision. +`restart` imports or verifies the endpointless `nemoclaw-mcp-v1` profile, applies the generated policy without a credential binding, updates or reuses the provider, and attaches it. It then applies the endpoint-bound policy, waits for credential readiness, and refreshes the adapter. An ambiguous or failed update is not treated as successful merely because another writer advanced the provider revision. -For a trusted-private entry, restart replays the address pins recorded by `mcp add`. -It does not resolve that endpoint again or widen its policy from ambient DNS. -For a public entry, restart resolves the hostname again and refreshes the generated policy with the current validated public addresses. +For a trusted-private entry, restart replays the exact address pins recorded by `mcp add`. It does not resolve that endpoint again or widen its policy from ambient DNS. For a public entry, restart resolves the hostname again and refreshes the generated policy with the current validated public addresses. -The raw value passes only through the OpenShell provider command's process environment and is not added to argv, NemoClaw state, or sandbox config. -Revoke the old credential upstream after restart succeeds. +The raw value passes only through the OpenShell provider command's process environment and is not added to argv, NemoClaw state, or sandbox config. Revoke the old credential upstream after restart succeeds. If the provider was deleted, restart recreates it from the exported value. -When the host variable is unset, restart reuses an existing provider whose current ID and credential-key metadata match the registry and whose type is `nemoclaw-mcp-v1`. -If the provider is missing, export the recorded variable before retrying. -If the provider has the profile-less legacy `generic` type, remove the server and add it again with the recorded variable exported. -Restart and rebuild refuse to activate that provider because OpenShell cannot bind it to an endpoint. +When the host variable is unset, restart reuses an existing provider whose current ID and credential-key metadata match the registry and whose type is `nemoclaw-mcp-v1`. If the provider is missing, export the recorded variable before retrying. If the provider has the profile-less legacy `generic` type, remove the server and add it again with the recorded variable exported. Restart and rebuild refuse to activate that provider because OpenShell cannot bind it to an endpoint. -Running restart without a server name refreshes every managed server. -Export only the variables whose credentials you intend to replace. +Running restart without a server name refreshes every managed server. Export only the variables whose credentials you intend to replace. ## Change Endpoint Pins -For a trusted-private server, review every destination change before NemoClaw records new pins. -Neither status, restart, rebuild, nor restore changes the recorded address set. +For a trusted-private server, review every destination change before NemoClaw records new pins. Neither status, restart, rebuild, nor restore changes the recorded address set. If the endpoint moves to another address, remove and re-add the server: @@ -159,8 +131,7 @@ $$nemoclaw my-sandbox mcp add local-tools \ unset LOCAL_MCP_TOKEN ``` -Removing the server deletes the registry-owned provider, policy, and adapter state after the existing ownership checks pass. -The re-add performs a new DNS preflight and records the reviewed address set. +Removing the server deletes the exact provider and adapter state recorded for that bridge and removes its generated key from the current OpenShell policy. The re-add performs a new DNS preflight and records the reviewed exact address set. ## Remove a Server @@ -168,70 +139,43 @@ The re-add performs a new DNS preflight and records the reviewed address set. $$nemoclaw my-sandbox mcp remove github ``` -Removing a server blocks new requests and reconnects but does not terminate a response or SSE stream that is already open. -For immediate revocation, revoke the upstream credential first, then run `$$nemoclaw rebuild --yes` or destroy the sandbox to terminate an existing stream. +Removing a server blocks new requests and reconnects but does not terminate a response or SSE stream that is already open. For immediate revocation, revoke the upstream credential first, then run `$$nemoclaw rebuild --yes` or destroy the sandbox to terminate an existing stream. -`remove --force` may remove a modified same-name adapter entry so an operator can clear local config. -Provider deletion still requires the recorded provider ID and credential key plus an accepted managed provider type. -Legacy `generic` providers are accepted only for cleanup. +`remove --force` may remove a modified same-name adapter entry so an operator can clear local config. Provider deletion still requires the recorded provider ID and credential key plus an accepted managed provider type. Exact legacy `generic` providers are accepted only for cleanup. -Policy deletion still requires live policy content to equal the recorded owned content. -Force never claims an unowned or drifted provider or same-key live policy. +Policy deletion still requires exact owned content. Force never claims an unowned or drifted provider or same-key live policy. -For an ordinary managed entry, NemoClaw cleans up the adapter, removes the live policy only when it equals the recorded owned policy, and then detaches its provider because OpenShell rejects detach while `credential_binding.provider` still references that provider. -For a stored legacy entry whose credential name is no longer accepted, it first detaches the provider so adapter cleanup cannot start with that credential attached. -It deletes the provider only after detach and credential-removal checks succeed. +For an ordinary managed entry, NemoClaw cleans up the adapter, removes the exact owned policy, and then detaches its provider because OpenShell rejects detach while `credential_binding.provider` still references that provider. For a stored legacy entry whose credential name is no longer accepted, it first detaches the exact provider so adapter cleanup cannot start with that credential attached. It deletes the provider only after detach and credential-removal checks succeed. -If cleanup leaves a residual, the command exits nonzero and preserves the registry entry so cleanup can be retried. -It never detaches the provider from other sandboxes. +If cleanup leaves a residual, the command exits nonzero and preserves the registry entry so cleanup can be retried. It never detaches the provider from other sandboxes. ## Rebuild with Managed MCP State -`rebuild` preserves providers that match the recorded ID and credential-key metadata and the active `nemoclaw-mcp-v1` type. -It removes adapter entries and live policies that equal the recorded owned policies, then detaches providers before replacing the sandbox. -Before changing each managed adapter, a fresh sandbox process must expose a revision-scoped OpenShell credential placeholder for that adapter. -If an observation is absent, unscoped, or unavailable, NemoClaw leaves the affected adapter and every provider unchanged. -It attempts to restore adapter entries prepared earlier in the operation and reports rollback failures. -It does not substitute the provider resource version for the credential revision. -Follow the reported [credential-revision recovery](../../reference/troubleshoot-mcp-servers#rebuild-or-destroy-cannot-prove-a-credential-revision), then retry rebuild. -Restoration applies the credential-free policy, reattaches each provider, applies the endpoint-bound policy, waits for credential readiness, and restores adapters. -For a trusted-private entry, the restored policy uses the recorded address pins and does not widen them from current DNS answers. -Public entries continue to resolve and validate their endpoint addresses during restoration. +`rebuild` preserves providers that match the recorded ID and credential-key metadata and the active `nemoclaw-mcp-v1` type. It removes adapter entries and exact owned policies, then detaches providers before replacing the sandbox. Before changing each managed adapter, a fresh sandbox process must expose a revision-scoped OpenShell credential placeholder for that adapter. If an observation is absent, unscoped, or unavailable, NemoClaw leaves the affected adapter and every provider unchanged. It attempts to restore adapter entries prepared earlier in the operation and reports rollback failures. It does not substitute the provider resource version for the credential revision. Follow the reported [credential-revision recovery](../../reference/troubleshoot-mcp-servers#rebuild-or-destroy-cannot-prove-a-credential-revision), then retry rebuild. Restoration applies the credential-free policy, reattaches each provider, applies the endpoint-bound policy, waits for credential readiness, and restores adapters. For a trusted-private entry, the restored policy uses the recorded exact address pins and does not widen them from current DNS answers. Public entries continue to resolve and validate their endpoint addresses during restoration. NemoClaw revalidates the prepared Deep Agents replacement after MCP preparation and before stopping inference or deleting the old sandbox. If that check fails, it restores prior MCP attachment and adapter state and keeps the old sandbox. -For a Deep Agents v1 image, remove, rebuild, and destroy scrub only the matching registry-owned legacy entry from `.deepagents/.mcp.json`. -Other user servers and unrelated top-level content remain unchanged. +For a Deep Agents v1 image, remove, rebuild, and destroy scrub only the matching registry-owned legacy entry from `.deepagents/.mcp.json`. Other user servers and unrelated top-level content remain unchanged. The replacement image must expose managed MCP capability v2 before NemoClaw restores MCP runtime state. + -If sandbox replacement fails, NemoClaw attempts to restore the previous attachment and adapter state at the path used by the surviving image. -A later `mcp restart` can retry an incomplete post-rebuild restore. +If sandbox replacement fails, NemoClaw attempts to restore the previous attachment and adapter state at the path used by the surviving image. A later `mcp restart` can retry an incomplete post-rebuild restore. ## Destroy a Sandbox with MCP State -Destroy removes adapter entries and live policies that equal the recorded owned policies, then detaches providers that match recorded metadata before asking OpenShell to delete the sandbox. -Before changing each managed adapter, a fresh sandbox process must expose a revision-scoped OpenShell credential placeholder for that adapter. -If an observation is absent, unscoped, or unavailable, NemoClaw leaves the affected adapter and every provider unchanged. -It attempts to restore adapter entries prepared earlier in the operation and reports rollback failures. -Follow the reported [credential-revision recovery](../../reference/troubleshoot-mcp-servers#rebuild-or-destroy-cannot-prove-a-credential-revision), then retry destroy. -If deletion is refused, NemoClaw attempts to restore previous MCP state, reports rollback failures, and preserves recovery state. -Provider deletion and registry cleanup happen only after OpenShell confirms the sandbox is gone. +Destroy removes adapter entries and exact owned policies, then detaches providers that match recorded metadata before asking OpenShell to delete the sandbox. Before changing each managed adapter, a fresh sandbox process must expose a revision-scoped OpenShell credential placeholder for that adapter. If an observation is absent, unscoped, or unavailable, NemoClaw leaves the affected adapter and every provider unchanged. It attempts to restore adapter entries prepared earlier in the operation and reports rollback failures. Follow the reported [credential-revision recovery](../../reference/troubleshoot-mcp-servers#rebuild-or-destroy-cannot-prove-a-credential-revision), then retry destroy. If deletion is refused, NemoClaw attempts to restore previous MCP state, reports rollback failures, and preserves recovery state. Provider deletion and registry cleanup happen only after OpenShell confirms the sandbox is gone. -An interrupted destroy can leave a durable transaction marker. -A prepared-only marker means deletion is not durably confirmed. -If the sandbox is still live, remove each affected server with force until the managed manifest is empty: +An interrupted destroy can leave a durable transaction marker. A prepared-only marker means deletion is not durably confirmed. If the sandbox is still live, remove each affected server with force until the managed manifest is empty: ```bash $$nemoclaw my-sandbox mcp remove --force ``` -A pending marker means the registry records that OpenShell deletion was already confirmed. -`mcp remove --force` refuses that state because provider or policy cleanup can still be owed. -Finish the idempotent destroy instead: +A pending marker means the registry records that OpenShell deletion was already confirmed. `mcp remove --force` refuses that state because provider or policy cleanup can still be owed. Finish the idempotent destroy instead: ```bash $$nemoclaw my-sandbox destroy @@ -257,11 +201,9 @@ If a mutating command times out waiting for the per-sandbox lifecycle lock, conf Every mutating command recovers a lock whose local process is provably dead or whose PID now has a different process-start identity. -NemoClaw does not expose a force-unlock flag. -A live owner, different host or PID namespace, or incomplete legacy owner record fails closed because removing it could overlap a provider, policy, or adapter mutation. +NemoClaw does not expose a force-unlock flag. A live owner, different host or PID namespace, or incomplete legacy owner record fails closed because removing it could overlap a provider, policy, or adapter mutation. -For state shared across hosts or PID namespaces, resolve the owner on that host or stop sharing the state directory before retrying. -Do not delete the lock file while ownership is ambiguous. +For state shared across hosts or PID namespaces, resolve the owner on that host or stop sharing the state directory before retrying. Do not delete the lock file while ownership is ambiguous. ## Related Topics diff --git a/docs/manage-sandboxes/manage-messaging-channels.mdx b/docs/manage-sandboxes/manage-messaging-channels.mdx index c5bad7afcdd..98befeb6050 100644 --- a/docs/manage-sandboxes/manage-messaging-channels.mdx +++ b/docs/manage-sandboxes/manage-messaging-channels.mdx @@ -5,29 +5,31 @@ title: "Manage Messaging Channels" sidebar-title: "Manage Messaging Channels" description: "Rotate, pause, resume, remove, and conflict-check messaging channels on an existing sandbox." description-agent: "Explains channel credential rotation, destructive removal, pause and resume behavior, duplicate credential and port conflicts, and full messaging stop behavior. Use after a channel is configured." -keywords: ["nemoclaw channels remove", "nemoclaw channels stop", "messaging credential rotation", "channel conflicts"] +keywords: + [ + "nemoclaw channels remove", + "nemoclaw channels stop", + "messaging credential rotation", + "channel conflicts", + ] content: type: "how_to" agent-variants: ["openclaw", "hermes"] --- + Use host-side channel commands to change a configured messaging channel. ## Rotate Credentials -Running `channels add` for a channel that is already configured overwrites stored tokens and registers the updated bridge provider. -Rebuild the sandbox after the update so the image reflects the current channel set. +Running `channels add` for a channel that is already configured overwrites stored tokens and registers the updated bridge provider. Rebuild the sandbox after the update so the image reflects the current channel set. -For WeChat, the cached-token shortcut applies. -Remove WeChat first when you intend to acquire a fresh account through a new QR scan. +For WeChat, the cached-token shortcut applies. Remove WeChat first when you intend to acquire a fresh account through a new QR scan. -For Google Chat, re-add the channel and paste the replacement service-account JSON. -NemoClaw updates the gateway-side refresh material. -The sandbox keeps only the OpenShell credential placeholder. -OpenShell keeps refreshed access tokens at the gateway and substitutes them at approved egress boundaries. +For Google Chat, re-add the channel and paste the replacement service-account JSON. NemoClaw updates the gateway-side refresh material. The sandbox keeps only the OpenShell credential placeholder. OpenShell keeps refreshed access tokens at the gateway and substitutes them at approved egress boundaries. -Re-adding Google Chat prompts again for the project ID, complete Pub/Sub subscription name, and email sender allowlist. -It does not create a public webhook endpoint. + Re-adding Google Chat prompts again for the project ID, complete Pub/Sub subscription name, and + email sender allowlist. It does not create a public webhook endpoint. For detailed token rotation procedures, refer to [Credential Rotation](../../security/credential-rotation). @@ -42,8 +44,7 @@ $$nemoclaw my-assistant channels remove wechat $$nemoclaw my-assistant channels remove teams ``` -`channels remove wechat` clears the bot token, deletes the `-wechat-bridge` provider, and removes `wechat` from the enabled-channel set. -The next rebuild omits WeChat configuration and per-account state files. +`channels remove wechat` clears the bot token, deletes the `-wechat-bridge` provider, and removes `wechat` from the enabled-channel set. The next rebuild omits WeChat configuration and per-account state files. `channels remove googlechat` detaches and deletes the `-googlechat-bridge` provider before the rebuild removes Google Chat configuration and the matching policy preset. @@ -54,8 +55,8 @@ If endpoint teardown fails, the command exits nonzero without changing the chann -Hermes Google Chat has no dedicated host-side endpoint to stop. -The next rebuild omits its Pub/Sub project, subscription, sender allowlist, and runtime adapter configuration. + Hermes Google Chat has no dedicated host-side endpoint to stop. The next rebuild omits its Pub/Sub + project, subscription, sender allowlist, and runtime adapter configuration. For in-sandbox QR-paired channels such as WhatsApp, `channels remove` destructively clears the session directory before rebuild so stale auth files do not reconnect the channel. @@ -68,13 +69,11 @@ The cleanup targets `/sandbox/.openclaw//`. The cleanup targets `/sandbox/.hermes/platforms//`. -It tries `openshell sandbox exec` and falls back to SSH if the first transport does not produce the success sentinel. -If neither transport can reach a running sandbox, the command exits nonzero and asks you to start the sandbox and rerun it. +It tries `openshell sandbox exec` and falls back to SSH if the first transport does not produce the success sentinel. If neither transport can reach a running sandbox, the command exits nonzero and asks you to start the sandbox and rerun it. -NemoClaw leaves the registry, policy preset, and `session.policyPresets` unchanged on that failure path so a later retry can complete cleanly. +NemoClaw leaves the registry and current OpenShell policy unchanged on that failure path so a later retry can complete cleanly. -`channels remove whatsapp` clears the client-side Baileys session but cannot deregister the linked device with WhatsApp's servers after the local connection is gone. -The phone continues to list the sandbox as a Linked Device until you remove it manually or WhatsApp's 14-day inactivity timeout expires. +`channels remove whatsapp` clears the client-side Baileys session but cannot deregister the linked device with WhatsApp's servers after the local connection is gone. The phone continues to list the sandbox as a Linked Device until you remove it manually or WhatsApp's 14-day inactivity timeout expires. Remove the phone entry before pairing the same account with another sandbox. @@ -90,69 +89,66 @@ $$nemoclaw my-assistant channels start wechat ``` -For WeChat, `channels stop wechat` followed by rebuild keeps the per-account state under `/sandbox/.openclaw/openclaw-weixin/accounts/` even though the bridge is no longer wired into `openclaw.json`. + For WeChat, `channels stop wechat` followed by rebuild keeps the per-account state under + `/sandbox/.openclaw/openclaw-weixin/accounts/` even though the bridge is no longer wired into + `openclaw.json`. -For WeChat, `channels stop wechat` followed by rebuild keeps the per-account state under `/sandbox/.hermes/` even though the bridge is no longer wired into Hermes config. + For WeChat, `channels stop wechat` followed by rebuild keeps the per-account state under + `/sandbox/.hermes/` even though the bridge is no longer wired into Hermes config. -A later `channels start wechat` plus rebuild revives the bridge against the same iLink account without a fresh QR scan. -The bot token remains in the OpenShell provider across the stop and start cycle. +A later `channels start wechat` plus rebuild revives the bridge against the same iLink account +without a fresh QR scan. The bot token remains in the OpenShell provider across the stop and start +cycle. Google Chat stop and start cycles also preserve the bridge provider and its gateway-side refresh material. -They preserve the dedicated public webhook endpoint so the Google Cloud configuration can keep the same URL. -The next rebuild reuses that provider without requiring the service-account JSON again. -`$$nemoclaw tunnel stop` does not stop the dedicated Google Chat endpoint. -It controls the separate full-dashboard tunnel. + They preserve the dedicated public webhook endpoint so the Google Cloud configuration can keep the + same URL. The next rebuild reuses that provider without requiring the service-account JSON again. + `$$nemoclaw tunnel stop` does not stop the dedicated Google Chat endpoint. It controls the + separate full-dashboard tunnel. -They preserve the project ID, Pub/Sub subscription name, and email sender allowlist. -The next rebuild reuses the bridge provider without requiring the service-account JSON again. -Hermes Google Chat does not use the dedicated webhook endpoint or `$$nemoclaw tunnel` commands. + They preserve the project ID, Pub/Sub subscription name, and email sender allowlist. The next + rebuild reuses the bridge provider without requiring the service-account JSON again. Hermes Google + Chat does not use the dedicated webhook endpoint or `$$nemoclaw tunnel` commands. -When `channels start` re-enables a channel, NemoClaw records the channel as enabled in the messaging plan. -The rebuild attaches the existing bridge provider before applying its matching built-in policy preset to the replacement sandbox. -While a channel remains stopped, the rebuild omits its runtime configuration, token upsert, and channel startup effects. -Generic providers and refresh bridges remain detached. +When `channels start` re-enables a channel, NemoClaw records the channel as enabled in the messaging plan. The rebuild attaches the existing bridge provider before applying its matching built-in policy preset to the replacement sandbox. While a channel remains stopped, the rebuild omits its runtime configuration, token upsert, and channel startup effects. Generic providers and refresh bridges remain detached. + -The rebuild also omits the stopped channel's inactive built-in messaging preset. -Exact custom policies remain preserved for separate replay, including a custom policy whose name matches a built-in messaging preset. -For stopped Hermes Discord, a preserved custom credential-bound policy requires the exact validated static provider, so the rebuild retains and attaches only that provider without starting Discord or recreating its credentials. -A missing or incompatible required provider stops the rebuild before the replacement can use the policy. + The rebuild also omits the stopped channel's inactive built-in messaging preset from the + command-time plan. The messaging plan does not persist policy references; rebuild starts from the + current OpenShell policy and derives any requested channel change from current manifests. For + stopped Hermes Discord, a preserved custom credential-bound policy requires the exact validated + static provider, so the rebuild retains and attaches only that provider without starting Discord + or recreating its credentials. A missing or incompatible required provider stops the rebuild + before the replacement can use the policy. -If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you rebuild it. +If the command queues the change without rebuilding, the running sandbox keeps its existing bridge +and network policy until you rebuild it. ## Avoid Cross-Sandbox Conflicts -NemoClaw checks only the sandboxes recorded in the selected OpenShell gateway's sandbox registry. -It cannot detect or prevent Slack credential reuse across independent OpenShell gateways. + NemoClaw checks only the sandboxes recorded in the selected OpenShell gateway's sandbox registry. + It cannot detect or prevent Slack credential reuse across independent OpenShell gateways. -Use distinct credentials and resources for each active messaging sandbox. -Follow these channel-specific rules: +Use distinct credentials and resources for each active messaging sandbox. Follow these channel-specific rules: - Use a distinct iLink `accountId` for each WeChat sandbox. - Run only one active Slack sandbox on each OpenShell gateway. - Use distinct bot and Socket Mode app tokens across OpenShell gateways. - Use a different local webhook port for each Microsoft Teams sandbox. -When you onboard, rebuild, or add a channel, the command aborts on a conflict or an incomplete required check within the selected OpenShell gateway's sandbox registry. -Legacy entries without credential hashes count as incomplete. -An unreadable messaging registry also causes onboarding and rebuild to abort. -Onboarding and rebuild have no conflict override. +When you onboard, rebuild, or add a channel, the command aborts on a conflict or an incomplete required check within the selected OpenShell gateway's sandbox registry. Legacy entries without credential hashes count as incomplete. An unreadable messaging registry also causes onboarding and rebuild to abort. Onboarding and rebuild have no conflict override. -For `channels add` only, `--force` overrides conflict and incomplete-check aborts. -Use it only when you accept the duplicate-consumer or shared-resource risk. -Rerun `channels add ` with the intended token to refresh stored non-secret identity metadata. +For `channels add` only, `--force` overrides conflict and incomplete-check aborts. Use it only when you accept the duplicate-consumer or shared-resource risk. Rerun `channels add ` with the intended token to refresh stored non-secret identity metadata. -Before a rebuild, NemoClaw checks the messaging plan before backup or deletion. -A conflict leaves the original sandbox intact. -Resolve any conflict, then rerun the operation. -`$$nemoclaw status` reports cross-sandbox overlaps within the selected OpenShell gateway's sandbox registry. +Before a rebuild, NemoClaw checks the messaging plan before backup or deletion. A conflict leaves the original sandbox intact. Resolve any conflict, then rerun the operation. `$$nemoclaw status` reports cross-sandbox overlaps within the selected OpenShell gateway's sandbox registry. ## Stop All Delivery @@ -163,11 +159,15 @@ Use `channels stop` for an individual bridge or stop the sandbox when you need t The deprecated full `$$nemoclaw stop` command also attempts to release an unshared OpenShell gateway port whose ownership NemoClaw can verify. Stopping the in-sandbox gateway stops all channel delivery for that sandbox until you restart the sandbox or gateway. + -`$$nemoclaw tunnel stop` stops the dashboard tunnel services that `$$nemoclaw tunnel start` created without stopping the supervisor-owned Hermes gateway, agent-owned host forwards, or managed OpenShell gateway port. -The deprecated full `$$nemoclaw stop` command attempts to stop host forwards and safely release an unshared OpenShell gateway port while the Hermes gateway remains under sandbox supervision. + `$$nemoclaw tunnel stop` stops the dashboard tunnel services that `$$nemoclaw tunnel start` + created without stopping the supervisor-owned Hermes gateway, agent-owned host forwards, or + managed OpenShell gateway port. The deprecated full `$$nemoclaw stop` command attempts to stop + host forwards and safely release an unshared OpenShell gateway port while the Hermes gateway + remains under sandbox supervision. Full stop preserves a shared gateway and fails closed without releasing its port when ownership is ambiguous. diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 031c9a589b9..ad7c940939d 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -11,6 +11,7 @@ content: skill: priority: 30 --- + Use the lightest recovery operation that repairs the sandbox while preserving its supported state. ## Restart a Stopped Sandbox Container @@ -21,13 +22,13 @@ If NemoClaw reports that a Docker-driver sandbox is stopped, restart the existin $$nemoclaw start ``` -This path preserves the sandbox workspace and repairs the agent runtime and host-side forwards after the container starts. -If the container is paused, follow the printed `docker unpause` guidance instead. -If Docker no longer has the container, follow the printed `rebuild --yes` guidance so NemoClaw can recreate the sandbox from its recorded metadata. +This path preserves the sandbox workspace and repairs the agent runtime and host-side forwards after the container starts. If the container is paused, follow the printed `docker unpause` guidance instead. If Docker no longer has the container, follow the printed `rebuild --yes` guidance so NemoClaw can recreate the sandbox from its recorded metadata. -The `start` command returns success only after it authenticates the recovered agent runtime, OpenShell reports the sandbox ready, and host-side port forwards pass their checks. -If a check fails, the command exits nonzero, identifies the failure, and prints recovery guidance before you retry `start`. + The `start` command returns success only after it authenticates the recovered agent runtime, + OpenShell reports the sandbox ready, and host-side port forwards pass their checks. If a check + fails, the command exits nonzero, identifies the failure, and prints recovery guidance before you + retry `start`. @@ -39,23 +40,17 @@ If the sandbox has shields up and the OpenClaw gateway does not start after the $$nemoclaw shields down ``` -While holding the config mutation lock, NemoClaw confirms that no startup process runs and no readiness lease exists. -Only then does it accept `shields down`. -Other locked-config operations still require the lease. -This failed-startup recovery path requires a sandbox image that includes the in-container OpenClaw config and state guards. -If NemoClaw reports that the config guard is absent, upgrade the CLI. -Then rebuild the sandbox before you retry recovery. -After shields are down, start the sandbox again. -When the OpenClaw gateway is healthy, rerun `shields up`. +While holding the config mutation lock, NemoClaw confirms that no startup process runs and no readiness lease exists. Only then does it accept `shields down`. Other locked-config operations still require the lease. This failed-startup recovery path requires a sandbox image that includes the in-container OpenClaw config and state guards. If NemoClaw reports that the config guard is absent, upgrade the CLI. Then rebuild the sandbox before you retry recovery. After shields are down, start the sandbox again. When the OpenClaw gateway is healthy, rerun `shields up`. + ## Recover the Agent Runtime -`recover` can start an existing stopped Docker-driver container before it repairs the agent runtime. -It starts only a non-paused container that Docker still associates with the registered sandbox. -It leaves a running or paused container unchanged. -If Docker cannot start the container, recovery continues to the OpenShell readiness check and reports the resulting failure. + `recover` can start an existing stopped Docker-driver container before it repairs the agent + runtime. It starts only a non-paused container that Docker still associates with the registered + sandbox. It leaves a running or paused container unchanged. If Docker cannot start the container, + recovery continues to the OpenShell readiness check and reports the resulting failure. @@ -65,10 +60,7 @@ If `$$nemoclaw status` reports the sandbox container or gateway is not ru $$nemoclaw recover ``` -The command repairs a stopped in-sandbox gateway and re-establishes the dashboard port-forward in one step. -It is idempotent and safe to script. -If the gateway is already healthy, `recover` exits after the probe and does not restart it. -If the host forward is already active, recovery accepts it only after OpenShell ownership is reconciled and the local endpoint is reachable. +The command repairs a stopped in-sandbox gateway and re-establishes the dashboard port-forward in one step. It is idempotent and safe to script. If the gateway is already healthy, `recover` exits after the probe and does not restart it. If the host forward is already active, recovery accepts it only after OpenShell ownership is reconciled and the local endpoint is reachable. Use `gateway restart` when you intentionally need a supported OpenClaw gateway to reload runtime configuration or plugins. @@ -76,9 +68,8 @@ Use `gateway restart` when you intentionally need a supported OpenClaw gateway t $$nemoclaw gateway restart ``` -The restart command asks the topology-specific controller to stop the tracked gateway child, wait for the entrypoint to launch a replacement, and prove listener and HTTP health. -The host then checks or recovers host-side dashboard, messaging, and agent forwards. -Refer to [`$$nemoclaw recover`](../../reference/commands#$$nemoclaw-name-recover) and [`$$nemoclaw gateway restart`](../../reference/commands#$$nemoclaw-name-gateway-restart) for details. +The restart command asks the topology-specific controller to stop the tracked gateway child, wait for the entrypoint to launch a replacement, and prove listener and HTTP health. The host then checks or recovers host-side dashboard, messaging, and agent forwards. Refer to [`$$nemoclaw recover`](../../reference/commands#$$nemoclaw-name-recover) and [`$$nemoclaw gateway restart`](../../reference/commands#$$nemoclaw-name-gateway-restart) for details. + @@ -88,20 +79,9 @@ If `$$nemoclaw status` reports the sandbox container or Hermes gateway is $$nemoclaw recover ``` -The command repairs a stopped in-sandbox gateway and re-establishes the dashboard port-forward in one step. -It is idempotent and safe to script. -If the gateway is already healthy, `recover` does not restart it. -If the host forward is already active, recovery accepts it only after OpenShell ownership is reconciled and the local endpoint is reachable. - -Before it repairs the gateway, `recover` checks for a NemoClaw cron restore gate or release recovery record left by an interrupted rebuild. -The gate continues to block new Hermes turns and cron dispatch across gateway and container restarts in the same sandbox. -If release rollback could not restore the gate, `recover` uses the root-owned recovery record to reacquire it before gateway repair can start dispatch. -After gateway repair, `recover` waits for active agent work to finish and validates the restored cron jobs and scripts. -It clears NemoClaw-owned gate and release recovery state only after validation succeeds. -If no independent operator drain exists, successful recovery prints `Hermes cron dispatch resumed after restored jobs and scripts were validated.` -If an operator drain exists, recovery prints `Hermes cron restore gate cleared; the independent operator drain remains active.` -The command does not own or clear the Hermes operator drain, so new Hermes turns and cron dispatch remain blocked while that drain is active. -If gate reacquisition or cron validation fails, `recover` exits nonzero and retains the recovery state for another attempt. +The command repairs a stopped in-sandbox gateway and re-establishes the dashboard port-forward in one step. It is idempotent and safe to script. If the gateway is already healthy, `recover` does not restart it. If the host forward is already active, recovery accepts it only after OpenShell ownership is reconciled and the local endpoint is reachable. + +Before it repairs the gateway, `recover` checks for a NemoClaw cron restore gate or release recovery record left by an interrupted rebuild. The gate continues to block new Hermes turns and cron dispatch across gateway and container restarts in the same sandbox. If release rollback could not restore the gate, `recover` uses the root-owned recovery record to reacquire it before gateway repair can start dispatch. After gateway repair, `recover` waits for active agent work to finish and validates the restored cron jobs and scripts. It clears NemoClaw-owned gate and release recovery state only after validation succeeds. If no independent operator drain exists, successful recovery prints `Hermes cron dispatch resumed after restored jobs and scripts were validated.` If an operator drain exists, recovery prints `Hermes cron restore gate cleared; the independent operator drain remains active.` The command does not own or clear the Hermes operator drain, so new Hermes turns and cron dispatch remain blocked while that drain is active. If gate reacquisition or cron validation fails, `recover` exits nonzero and retains the recovery state for another attempt. Portable Hermes recovery follows the exact receipt-bound start, authenticated-health, and rollback contract in the [`recover` command reference](../../reference/commands#nemoclaw-name-recover). @@ -111,36 +91,22 @@ Use `gateway restart` when you intentionally need a supported Hermes gateway to $$nemoclaw gateway restart ``` -The restart command asks the topology-specific controller to stop the tracked gateway child, wait for the entrypoint to launch a replacement, and prove listener and HTTP health. -The host then checks or recovers host-side dashboard, messaging, and agent forwards. +The restart command asks the topology-specific controller to stop the tracked gateway child, wait for the entrypoint to launch a replacement, and prove listener and HTTP health. The host then checks or recovers host-side dashboard, messaging, and agent forwards. + +For Hermes, the entrypoint supervisor owns the gateway, dashboard process, internal API relay, dashboard relay, and gateway log stream. The nonroot managed supervisor repairs those processes continuously, stops an alive but deaf gateway after four consecutive failed health checks, and quarantines relaunch after five exits within 60 seconds until sandbox recreation. -For Hermes, the entrypoint supervisor owns the gateway, dashboard process, internal API relay, dashboard relay, and gateway log stream. -The nonroot managed supervisor repairs those processes continuously, stops an alive but deaf gateway after four consecutive failed health checks, and quarantines relaunch after five exits within 60 seconds until sandbox recreation. +The host does not start the in-sandbox processes independently. Refer to [`$$nemoclaw recover`](../../reference/commands#$$nemoclaw-name-recover) and [`$$nemoclaw gateway restart`](../../reference/commands#$$nemoclaw-name-gateway-restart) for details. -The host does not start the in-sandbox processes independently. -Refer to [`$$nemoclaw recover`](../../reference/commands#$$nemoclaw-name-recover) and [`$$nemoclaw gateway restart`](../../reference/commands#$$nemoclaw-name-gateway-restart) for details. Recovery uses registry-scoped privileged direct-container control and does not fall back to ordinary `openshell sandbox exec` or a manual in-sandbox relaunch. For a local Docker-driver sandbox whose container still uses the legacy keepalive startup, `recover` can transactionally recreate the registered container with a credential-free managed startup command. -NemoClaw keeps the previous container available throughout the replacement health, OpenShell readiness, state restoration, gateway restart, and settle checks. -Before recreation, NemoClaw backs up the state directories and files declared by the agent manifest. -NemoClaw waits for OpenShell to re-register the replacement before it restores state. -After state restoration, it restarts the gateway in that replacement and reruns the managed health and settle checks. -It commits only after the replacement identity, state restoration, gateway restart, and settle check pass. -NemoClaw removes the temporary state backup after a successful restore or rollback. -If state restoration and rollback both fail, it retains the backup and prints host recovery guidance. -Mounted state remains available, but a committed swap does not retain other writable-layer changes. -After a transactional recreation, NemoClaw waits 120 seconds for OpenShell to re-register the sandbox before state restoration and replacement commit. -Set `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` before the recovery command to change this budget. -A definitive managed-health failure still stops immediately. -If re-registration, state restoration, or a later gateway check fails, NemoClaw attempts to roll back the replacement and leaves the primary dashboard or API host forward stopped. -If NemoClaw cannot confirm rollback to the previous container, inspect Docker state before you retry recovery. - -For the controller topology, trust boundary, and fail-closed conditions, refer to [Understand Gateway Lifecycle Control](../configure-sandboxes/understand-gateway-lifecycle-control). -If recovery cannot repair a sandbox that needs credentials or a current controller contract, rebuild it. +NemoClaw keeps the previous container available throughout the replacement health, OpenShell readiness, state restoration, gateway restart, and settle checks. Before recreation, NemoClaw backs up the state directories and files declared by the agent manifest. NemoClaw waits for OpenShell to re-register the exact replacement before it restores state. After state restoration, it restarts the gateway in that replacement and reruns the managed health and settle checks. It commits only after the replacement identity, state restoration, gateway restart, and settle check pass. NemoClaw removes the temporary state backup after a successful restore or rollback. If state restoration and rollback both fail, it retains the backup and prints host recovery guidance. Mounted state remains available, but a committed swap does not retain other writable-layer changes. After a transactional recreation, NemoClaw waits 120 seconds for OpenShell to re-register the sandbox before state restoration and replacement commit. Set `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` before the recovery command to change this budget. A definitive managed-health failure still stops immediately. If re-registration, state restoration, or a later gateway check fails, NemoClaw attempts to roll back the replacement and leaves the primary dashboard or API host forward stopped. If NemoClaw cannot confirm rollback to the previous container, inspect Docker state before you retry recovery. + +For the controller topology, trust boundary, and fail-closed conditions, refer to [Understand Gateway Lifecycle Control](../configure-sandboxes/understand-gateway-lifecycle-control). If recovery cannot repair a sandbox that needs credentials or a current controller contract, rebuild it. + Deep Agents sandboxes are terminal runtimes and do not expose an OpenClaw or Hermes in-sandbox gateway. @@ -150,12 +116,10 @@ If the terminal runtime reports degraded health, rebuild the sandbox instead of ### Understand Launch Readiness Leases -A successful complete preflight for `$$nemoclaw launch ` can publish a credential-free launch-readiness lease on Linux. -Linux infrastructure can publish the same evidence with `$$nemoclaw connect --probe-only`. -The lease has a fixed 24-hour lifetime that repeated launches do not extend. -Leaving the agent with `/exit` does not revoke it, and users do not refresh it manually. +A successful complete preflight for `$$nemoclaw launch ` can publish a credential-free launch-readiness lease on Linux. Linux infrastructure can publish the same evidence with `$$nemoclaw connect --probe-only`. The lease has a fixed 24-hour lifetime that repeated launches do not extend. Leaving the agent with `/exit` does not revoke it, and users do not refresh it manually. + +During the lease, `launch` still verifies the owning OpenShell gateway, exact live sandbox identity, registry and agent configuration, effective policy, inference route, required forwards, and semantic runtime health. -During the lease, `launch` still verifies the owning OpenShell gateway, live sandbox identity, registry and agent configuration, effective policy, inference route, required forwards, and semantic runtime health. Configured inference must return HTTP 2xx from the semantic `inference.local` probe, which is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. @@ -167,163 +131,116 @@ A failed request rejects launch readiness and identifies the inference request a -For OpenClaw, `connect --probe-only` settles the existing allowlisted pairing flow before it publishes a credential-free pairing qualification with the lease. -The readiness evidence binds the OpenClaw version and trusted registry and agent manifest configuration. -Its pairing qualification binds the canonical CLI client, paired device identity, required operator role and scopes, owning OpenShell gateway, sandbox lifecycle identity, and fixed lease epoch. -Before lease acceptance, `launch` makes a bounded, read-only observation of the current OpenClaw-owned pairing state through the owning OpenShell gateway. -It skips the complete pairing approval pass only when the qualification still matches exactly and no relevant allowlisted request is pending. -Any missing, unreadable, malformed, ambiguous, or changed observation runs the complete pairing approval pass. -A relevant allowlisted pending request also runs that complete path. +For OpenClaw, `connect --probe-only` settles the existing allowlisted pairing flow before it publishes a credential-free pairing qualification with the lease. The readiness evidence binds the OpenClaw version and trusted registry and agent manifest configuration. Its pairing qualification binds the canonical CLI client, exact paired device identity, required operator role and scopes, owning OpenShell gateway, sandbox lifecycle identity, and fixed lease epoch. Before lease acceptance, `launch` makes a bounded, read-only observation of the current OpenClaw-owned pairing state through the owning OpenShell gateway. It skips the complete pairing approval pass only when the qualification still matches exactly and no relevant allowlisted request is pending. Any missing, unreadable, malformed, ambiguous, or changed observation runs the complete pairing approval pass. A relevant allowlisted pending request also runs that complete path. -For a current Portable OpenClaw lifecycle receipt, NemoClaw also requires a finalized onboarding policy step and strictly settled local CLI operator pairing. -If only the paired device exists and no request is pending, recovery runs the canonical OpenClaw request producer once and makes at most one approval attempt. -An ambiguous approval result receives one final observation and no approval retry. -NemoClaw publishes no lease when the policy step is incomplete or the receipt, runtime identity, or pairing state is invalid or ambiguous. -The command exits nonzero with an incomplete-onboarding diagnostic and tells you to resume or rerun onboarding. +For a current Portable OpenClaw lifecycle receipt, NemoClaw also requires a finalized onboarding policy step and strictly settled local CLI operator pairing. If only the paired device exists and no request is pending, recovery runs the canonical OpenClaw request producer once and makes at most one approval attempt. An ambiguous approval result receives one final observation and no approval retry. NemoClaw publishes no lease when the policy step is incomplete or the receipt, runtime identity, or pairing state is invalid or ambiguous. The command exits nonzero with an incomplete-onboarding diagnostic and tells you to resume or rerun onboarding. Hermes and LangChain Deep Agents Code retain their existing session setup on the lease-accepted path. When those checks pass, it can skip duplicate recovery, readiness polling, and inference-route repair. The lease is not a health guarantee or repair authority. -For missing, unsafe, malformed, expired, mismatched, changed, or unhealthy evidence, NemoClaw fences any prior acceptable evidence before it runs the complete preflight. -Ordinary launch continues only when NemoClaw proves that no old authority or evidence can exist, or durably rotates the runtime epoch. -If an old epoch might exist and cannot be durably rotated, `launch` and `connect --probe-only` stop before complete preflight or recovery. -Their redacted guidance asks you to repair the current user's secure OS runtime authority and NemoClaw state permissions, then retry. -If NemoClaw securely proves that both the authority and receipt are absent but cannot create new authority, ordinary `launch` can run the complete preflight without optimization; on Linux, `connect --probe-only` exits nonzero because it could not publish evidence. -If that preflight succeeds before the lease expires, replacement evidence keeps the original start and expiry time. -After expiry, a successful complete preflight starts a new 24-hour lease only when publication succeeds. - -Before the first mutation in the complete preflight, the producer revalidates its sandbox-global runtime epoch under the sandbox lifecycle lock followed by the owning gateway lock. -It holds both locks through all mutations in the complete preflight, final state capture, and publication. -A stale producer makes no changes and re-inspects the newer lease. - -If unsafe or malformed authority history makes the prior lease timeline untrustworthy, NemoClaw durably invalidates the old epoch and starts one conservative 24-hour quarantine. -Publication remains disabled until both wall time and monotonic uptime span the full quarantine. -Repeated attempts do not extend it. -After it elapses, the next successful complete preflight can publish a new fixed 24-hour lease. - -Lease acceptance and publication are currently Linux-only and require a secure, independently writable OS per-user runtime authority under `/run/user/`. -It never uses caller-provided environment variables to select this authority. - -On macOS, `launch` runs the complete preflight every time and does not publish a launch-readiness lease. -`connect --probe-only` also runs the complete preflight, including recovery and probes. -After a successful probe and recovery, it prints a note that launch-readiness evidence is unavailable on this platform and exits zero. -On Linux, the publication-failure diagnostic is redacted and does not print filesystem paths or environment values. - -Infrastructure must run `connect --probe-only` as the same final numeric user that later runs `launch`. -Run it after the final durable home and state volume is mounted and after policy and network provisioning is complete. -On completion, `connect --probe-only` writes one credential-free `Probe timing:` line with elapsed milliseconds for readiness, authority, lifecycle, gateway, processes, forward, inference, pairing, and publication stages. -The line also reports the lifecycle and forward actions, the result, and the failed stage when the probe fails. -Timing output is diagnostic only and does not change probe success or failure. -NemoClaw rejects evidence after a bound sandbox, configuration, policy, or network identity changes. -Deployment ordering remains responsible for external changes that OpenShell and NemoClaw cannot observe. +For missing, unsafe, malformed, expired, mismatched, changed, or unhealthy evidence, NemoClaw fences any prior acceptable evidence before it runs the complete preflight. Ordinary launch continues only when NemoClaw proves that no old authority or evidence can exist, or durably rotates the runtime epoch. If an old epoch might exist and cannot be durably rotated, `launch` and `connect --probe-only` stop before complete preflight or recovery. Their redacted guidance asks you to repair the current user's secure OS runtime authority and NemoClaw state permissions, then retry. If NemoClaw securely proves that both the authority and receipt are absent but cannot create new authority, ordinary `launch` can run the complete preflight without optimization; on Linux, `connect --probe-only` exits nonzero because it could not publish evidence. If that preflight succeeds before the lease expires, replacement evidence keeps the original start and expiry time. After expiry, a successful complete preflight starts a new 24-hour lease only when publication succeeds. + +Before the first mutation in the complete preflight, the producer revalidates its sandbox-global runtime epoch under the sandbox lifecycle lock followed by the owning gateway lock. It holds both locks through all mutations in the complete preflight, final state capture, and publication. A stale producer makes no changes and re-inspects the newer lease. + +If unsafe or malformed authority history makes the prior lease timeline untrustworthy, NemoClaw durably invalidates the old epoch and starts one conservative 24-hour quarantine. Publication remains disabled until both wall time and monotonic uptime span the full quarantine. Repeated attempts do not extend it. After it elapses, the next successful complete preflight can publish a new fixed 24-hour lease. + +Lease acceptance and publication are currently Linux-only and require a secure, independently writable OS per-user runtime authority under `/run/user/`. It never uses caller-provided environment variables to select this authority. + +On macOS, `launch` runs the complete preflight every time and does not publish a launch-readiness lease. `connect --probe-only` also runs the complete preflight, including recovery and probes. After a successful probe and recovery, it prints a note that launch-readiness evidence is unavailable on this platform and exits zero. On Linux, the publication-failure diagnostic is redacted and does not print filesystem paths or environment values. + +Infrastructure must run `connect --probe-only` as the same final numeric user that later runs `launch`. Run it after the final durable home and state volume is mounted and after policy and network provisioning is complete. On completion, `connect --probe-only` writes one credential-free `Probe timing:` line with elapsed milliseconds for readiness, authority, lifecycle, gateway, processes, forward, inference, pairing, and publication stages. The line also reports the lifecycle and forward actions, the result, and the failed stage when the probe fails. Timing output is diagnostic only and does not change probe success or failure. NemoClaw rejects evidence after a bound sandbox, configuration, policy, or network identity changes. Deployment ordering remains responsible for external changes that OpenShell and NemoClaw cannot observe. ### Host OpenShell Gateway Versus In-Sandbox Agent Recovery -`recover` and `start` repair the in-sandbox agent gateway and the host-side port forwards for one named sandbox. -`connect --probe-only` waits for that sandbox to become ready, rechecks it on its recorded gateway, and then verifies or repairs the same sandbox-scoped processes and forwards. -These commands do not restart, replace, or reap the shared host OpenShell gateway process. -If the host gateway RPC returns an error while these sandbox-scoped commands run, they surface the error with explicit next-step guidance and exit rather than swapping the shared gateway out from under other sandboxes. - -The OpenShell CLI has no command that starts a gateway, and `openshell status` only reports the gateway state. -If NemoClaw starts the gateway on your host, run `$$nemoclaw onboard` again to repair the host gateway itself. -If a deployment outside NemoClaw owns the gateway process, start the gateway with that deployment, then run `openshell gateway select `. -NemoClaw prints the applicable recovery guidance when a sandbox-scoped command reports that the host gateway is down. -Other workflows, including onboarding, rebuild, and `doctor --fix`, can explicitly recover the named host gateway when their operation requires it. -`$$nemoclaw gateway restart` instead restarts only the supported agent gateway inside the named sandbox. +`recover` and `start` repair the in-sandbox agent gateway and the host-side port forwards for one named sandbox. `connect --probe-only` waits for that sandbox to become ready, rechecks it on its recorded gateway, and then verifies or repairs the same sandbox-scoped processes and forwards. These commands do not restart, replace, or reap the shared host OpenShell gateway process. If the host gateway RPC returns an error while these sandbox-scoped commands run, they surface the error with explicit next-step guidance and exit rather than swapping the shared gateway out from under other sandboxes. + +The OpenShell CLI has no command that starts a gateway, and `openshell status` only reports the gateway state. If NemoClaw starts the gateway on your host, run `$$nemoclaw onboard` again to repair the host gateway itself. If a deployment outside NemoClaw owns the gateway process, start the gateway with that deployment, then run `openshell gateway select `. NemoClaw prints the applicable recovery guidance when a sandbox-scoped command reports that the host gateway is down. Other workflows, including onboarding, rebuild, and `doctor --fix`, can explicitly recover the named host gateway when their operation requires it. `$$nemoclaw gateway restart` instead restarts only the supported agent gateway inside the named sandbox. + ### Recover Portable Local Ollama -For a portable experimental-profile sandbox with the recorded `ollama-local` provider, `connect --probe-only` and `recover` also verify the host-side inference chain. -Before it decides whether to start Ollama, the command probes `http://127.0.0.1:11434/api/tags` and leaves a healthy daemon unchanged. -When that API is unhealthy, it starts the fixed user-local executable only if NemoClaw has a valid ownership receipt. -NemoClaw releases that predate this receipt do not claim an existing executable after an upgrade. -To authorize recovery for a previous NemoClaw user-local install, first verify that `${HOME}/.local/bin/ollama` is the executable you want NemoClaw to manage. -Then run `NEMOCLAW_PORTABLE_OLLAMA_REENROLL=1 nemoclaw recover` once. -The command rejects a symbolic link or non-executable file before it records ownership. -It refuses to launch a duplicate when another `ollama` process exists but the API remains unhealthy. -After a launch, recovery waits up to 30 seconds for `/api/tags` to return valid JSON with a `models` array. -It does not take over a system service or an unrelated user-managed daemon. -It refuses a symbolic link, non-regular file, or non-executable file at the receipt-bound executable path. - -On every `ollama-local` completion path, the command verifies the authenticated proxy on port `11435`. -It also requires HTTP 2xx from `https://inference.local/v1/models` before it reports success. -If Ollama does not become healthy within 30 seconds, the command identifies the receipt-bound executable and its `serve` argument, then tells you to retry recovery. -An Ollama startup or route failure exits non-zero and prints the available recovery guidance. +For a portable experimental-profile sandbox with the recorded `ollama-local` provider, `connect --probe-only` and `recover` also verify the host-side inference chain. Before it decides whether to start Ollama, the command probes `http://127.0.0.1:11434/api/tags` and leaves a healthy daemon unchanged. When that API is unhealthy, it starts the fixed user-local executable only if NemoClaw has a valid ownership receipt. NemoClaw releases that predate this receipt do not claim an existing executable after an upgrade. To authorize recovery for a previous NemoClaw user-local install, first verify that `${HOME}/.local/bin/ollama` is the executable you want NemoClaw to manage. Then run `NEMOCLAW_PORTABLE_OLLAMA_REENROLL=1 nemoclaw recover` once. The command rejects a symbolic link or non-executable file before it records ownership. It refuses to launch a duplicate when another `ollama` process exists but the API remains unhealthy. After a launch, recovery waits up to 30 seconds for `/api/tags` to return valid JSON with a `models` array. It does not take over a system service or an unrelated user-managed daemon. It refuses a symbolic link, non-regular file, or non-executable file at the receipt-bound executable path. + +On every `ollama-local` completion path, the command verifies the authenticated proxy on port `11435`. It also requires HTTP 2xx from `https://inference.local/v1/models` before it reports success. If Ollama does not become healthy within 30 seconds, the command identifies the exact receipt-bound executable and its `serve` argument, then tells you to retry recovery. An Ollama startup or route failure exits non-zero and prints the available recovery guidance. + ## Rebuild While Preserving State -If you changed the underlying Dockerfile, upgraded OpenClaw, or want to pick up a new base image without losing your sandbox's workspace files, use `rebuild` instead of destroying and recreating. + If you changed the underlying Dockerfile, upgraded OpenClaw, or want to pick up a new base image + without losing your sandbox's workspace files, use `rebuild` instead of destroying and recreating. -If you changed the underlying Dockerfile, upgraded Hermes, or want to pick up a new base image without losing your sandbox's state files, use `rebuild` instead of destroying and recreating. + If you changed the underlying Dockerfile, upgraded Hermes, or want to pick up a new base image + without losing your sandbox's state files, use `rebuild` instead of destroying and recreating. -If you changed the underlying Dockerfile, upgraded Deep Agents Code, enabled Tavily Search, or want to pick up a new base image without losing manifest-defined Deep Agents state, use `rebuild` instead of destroying and recreating. + If you changed the underlying Dockerfile, upgraded Deep Agents Code, enabled Tavily Search, or + want to pick up a new base image without losing manifest-defined Deep Agents state, use `rebuild` + instead of destroying and recreating. - -When the installer offers prepared backup recovery for a legacy sandbox, the recreate restores only the managed state directory recorded in the validated backup manifest, such as `/sandbox/.openclaw` or `/sandbox/.hermes`. -It does not preserve files outside that recorded path, including `/sandbox/user-data`. -Back up those paths outside the sandbox before you approve legacy recovery. - + + When the installer offers prepared backup recovery for a legacy sandbox, the recreate restores + only the managed state directory recorded in the validated backup manifest, such as + `/sandbox/.openclaw` or `/sandbox/.hermes`. It does not preserve files outside that recorded + path, including `/sandbox/user-data`. Back up those paths outside the sandbox before you approve + legacy recovery. + ```bash $$nemoclaw rebuild ``` -On WSL with Docker Desktop, a generated replacement image build uses a temporary credential-free Docker configuration when the configured Docker Desktop credential helper is unavailable. -NemoClaw removes the temporary configuration after the build and does not modify your Docker configuration. -An explicit custom Dockerfile continues to use your configured Docker credentials because its base image or build steps might require a private registry. -If that custom rebuild cannot reach the credential helper, restore the Docker Desktop session or credential-helper access before retrying. +On WSL with Docker Desktop, a generated replacement image build uses a temporary credential-free Docker configuration when the configured Docker Desktop credential helper is unavailable. NemoClaw removes the temporary configuration after the build and does not modify your Docker configuration. An explicit custom Dockerfile continues to use your configured Docker credentials because its base image or build steps might require a private registry. If that custom rebuild cannot reach the credential helper, restore the Docker Desktop session or credential-helper access before retrying. -After post-restore writes, rebuild verifies that the final `openclaw.json` and `.config-hash` pair match. -If verification fails, NemoClaw restores Shields, exits nonzero, and does not report a successful rebuild. +After post-restore writes, rebuild verifies that the final `openclaw.json` and `.config-hash` pair match. If verification fails, NemoClaw restores Shields, exits nonzero, and does not report a successful rebuild. ### Resolve Rebuild Preflight Stops -Before it backs up or deletes the existing sandbox, `rebuild` validates the recorded sandbox, gateway, inference route, policy, MCP, agent, and operation-lock state. -When one of these checks fails, NemoClaw prints `Rebuild preflight failed`, explains how to recover, and ends with `Aborting rebuild`. -At this boundary, the existing sandbox is unchanged and no sandbox data has been removed. +Before it backs up or deletes the existing sandbox, `rebuild` validates the recorded sandbox, gateway, inference route, policy, MCP, agent, and operation-lock state. When one of these checks fails, NemoClaw prints `Rebuild preflight failed`, explains how to recover, and ends with `Aborting rebuild`. At this boundary, the existing sandbox is unchanged and no sandbox data has been removed. Use the recovery guidance that matches the reported check: - Verify the sandbox name when its registry entry is missing. - Follow the printed OpenShell gateway recovery steps when the gateway schema is incompatible. -- Repair the named pending baseline policy transition, then rerun `rebuild`. +- Restore access to the current OpenShell policy when rebuild reports that the live policy cannot be read, then rerun `rebuild`. NemoClaw does not reconstruct policy from registry state. - Resolve an incomplete MCP destroy transaction before retrying. - Back up the sandbox state and recreate it with `$$nemoclaw onboard` when the record contains multiple agents. Transactional multi-agent rebuild is not supported. - Wait for another onboarding or rebuild operation to finish before retrying. If verified stale-lock cleanup is still in progress, wait briefly and rerun the command. Do not delete the lock manually. - Set the live OpenShell inference route to the sandbox's recorded provider and model when rebuild reports route drift. -A gateway that reports no live inference route does not stop the rebuild. -Replacement onboarding configures and verifies the recorded route before it recreates the sandbox. +A gateway that reports no live inference route does not stop the rebuild. Replacement onboarding configures and verifies the recorded route before it recreates the sandbox. -The rebuild command preserves the mounted workspace and registered policies while recreating the container. -When no host web-search key is staged, rebuild preflight reuses an existing Brave or Tavily credential only when the provider name, type, and credential key match the sandbox's binding on its recorded OpenShell gateway. -A missing or mismatched binding stops before recreation and requires the matching host environment variable before you retry. -After state restoration, NemoClaw clears a session's stale model and provider pin when it still targets the managed `inference` provider but no longer matches the configured default model. -The session then follows the current default selected through `inference set`, while sessions pinned to another provider remain unchanged. + The rebuild command preserves the mounted workspace and carries the complete current OpenShell + policy into sandbox recreation. When no host web-search key is staged, rebuild preflight reuses an + existing Brave or Tavily credential only when the provider name, type, and credential key match + the sandbox's binding on its recorded OpenShell gateway. A missing or mismatched binding stops + before recreation and requires the matching host environment variable before you retry. After + state restoration, NemoClaw clears a session's stale model and provider pin when it still targets + the managed `inference` provider but no longer matches the configured default model. The session + then follows the current default selected through `inference set`, while sessions pinned to + another provider remain unchanged. -The rebuild command preserves Hermes state, registered policies, and managed MCP configuration while recreating the container. +The rebuild command preserves Hermes state, the complete current OpenShell policy, and managed MCP configuration while recreating the container. It reuses a messaging provider only when its exact type and credential keys match the recorded channel binding. A stopped channel remains inactive and contributes no token upsert, rendered runtime configuration, channel startup effect, or inactive built-in messaging preset. -Exact custom policies remain preserved for separate replay. +Custom and host-edited policy entries are preserved as part of that one OpenShell policy handoff, not by a separate replay system. A policy-required provider attachment is the narrow exception: when a preserved custom policy still credential-binds stopped Hermes Discord, rebuild attaches its exact validated static provider without starting Discord. If that required provider is missing or incompatible, restore or re-add the channel credentials, then rerun the rebuild. A rebuild creates a new sandbox home and a new Hermes API bearer token. @@ -333,60 +250,34 @@ After the rebuild succeeds, retrieve the replacement token before reconnecting A $$nemoclaw gateway-token --quiet ``` -Before post-restore repairs, NemoClaw verifies that the recreated sandbox still identifies as Hermes and exits nonzero if its identity does not match the rebuild target. -After state restore, NemoClaw restarts the Hermes gateway so it reads the restored durable state, then restores managed MCP configuration through the normal lifecycle. -MCP restoration performs an acknowledged gateway reload, so NemoClaw finishes by verifying the final running gateway and its managed MCP state without replacing that verified process again. -The gateway starts during recreation and reads its durable state before the restore replaces it, which is why the first post-restore restart must happen before managed MCP restoration. -`rebuild` exits nonzero instead of reporting success when it cannot verify final gateway health or managed MCP state. -Follow the printed recovery guidance, using `$$nemoclaw gateway restart` first for gateway health, `$$nemoclaw recover` when the restart does not restore verified health, and `$$nemoclaw mcp restart` for incomplete managed MCP restoration. - -When the rebuild backup contains active Hermes cron jobs that reference scripts, NemoClaw validates those script references before it deletes the existing sandbox. -The check covers the default profile and named profiles. -Each referenced script must exist, be readable, be a regular file, and remain inside its profile's `scripts` directory. -Disabled and paused jobs do not require their referenced scripts. -If this validation fails, the rebuild keeps the existing sandbox and reports the preserved backup path. - -After NemoClaw creates the replacement, it acquires an independent root-owned gate that blocks new Hermes turns and cron dispatch. -The gate remains active across gateway and container restarts in the replacement sandbox. -NemoClaw waits for active agent work to finish before restoring state. -It validates the restored jobs and scripts before the gateway replacement, then keeps dispatch blocked while it restarts and verifies that replacement. -It records the replacement process identity around managed health verification and clears the gate only if that same live process completes the final cron validation. -If an operator already drained the gateway, NemoClaw clears its gate and release recovery record while leaving the operator drain active. -If state restore, managed MCP restoration, gateway replacement, or cron validation fails after gate acquisition, the command exits nonzero and preserves the backup. -Those failures retain the NemoClaw gate unless the output explicitly reports that release rollback could not restore its marker. -In that exceptional case, NemoClaw preserves a root-owned release recovery record, but you must not assume dispatch is blocked. -Run `$$nemoclaw recover` immediately so it can reacquire the gate before validating the restored cron state. -If gate reacquisition fails, recovery exits nonzero and leaves the recovery record in place for another attempt. -Failures before gate acquisition do not create a new gate. -Do not manually remove the root-owned cron restore marker or release recovery record because removal bypasses restored cron validation. -If managed MCP restoration failed, correct the reported cause and run `$$nemoclaw mcp restart` first. -Then run `$$nemoclaw recover` to repair and probe the gateway, validate the restored cron tree, and clear NemoClaw-owned cron restore recovery state. +Before post-restore repairs, NemoClaw verifies that the recreated sandbox still identifies as Hermes and exits nonzero if its identity does not match the rebuild target. After state restore, NemoClaw restarts the Hermes gateway so it reads the restored durable state, then restores managed MCP configuration through the normal lifecycle. MCP restoration performs an acknowledged gateway reload, so NemoClaw finishes by verifying the final running gateway and its managed MCP state without replacing that verified process again. The gateway starts during recreation and reads its durable state before the restore replaces it, which is why the first post-restore restart must happen before managed MCP restoration. `rebuild` exits nonzero instead of reporting success when it cannot verify final gateway health or managed MCP state. Follow the printed recovery guidance, using `$$nemoclaw gateway restart` first for gateway health, `$$nemoclaw recover` when the restart does not restore verified health, and `$$nemoclaw mcp restart` for incomplete managed MCP restoration. + +When the rebuild backup contains active Hermes cron jobs that reference scripts, NemoClaw validates those script references before it deletes the existing sandbox. The check covers the default profile and named profiles. Each referenced script must exist, be readable, be a regular file, and remain inside its profile's `scripts` directory. Disabled and paused jobs do not require their referenced scripts. If this validation fails, the rebuild keeps the existing sandbox and reports the preserved backup path. + +After NemoClaw creates the replacement, it acquires an independent root-owned gate that blocks new Hermes turns and cron dispatch. The gate remains active across gateway and container restarts in the replacement sandbox. NemoClaw waits for active agent work to finish before restoring state. It validates the restored jobs and scripts before the gateway replacement, then keeps dispatch blocked while it restarts and verifies that replacement. It records the replacement process identity around managed health verification and clears the gate only if that same live process completes the final cron validation. If an operator already drained the gateway, NemoClaw clears its gate and release recovery record while leaving the operator drain active. If state restore, managed MCP restoration, gateway replacement, or cron validation fails after gate acquisition, the command exits nonzero and preserves the backup. Those failures retain the NemoClaw gate unless the output explicitly reports that release rollback could not restore its marker. In that exceptional case, NemoClaw preserves a root-owned release recovery record, but you must not assume dispatch is blocked. Run `$$nemoclaw recover` immediately so it can reacquire the gate before validating the restored cron state. If gate reacquisition fails, recovery exits nonzero and leaves the recovery record in place for another attempt. Failures before gate acquisition do not create a new gate. Do not manually remove the root-owned cron restore marker or release recovery record because removal bypasses restored cron validation. If managed MCP restoration failed, correct the reported cause and run `$$nemoclaw mcp restart` first. Then run `$$nemoclaw recover` to repair and probe the gateway, validate the restored cron tree, and clear NemoClaw-owned cron restore recovery state. + -The rebuild command preserves manifest-defined Deep Agents state, regenerates `config.toml`, reconstructs managed MCP projection state, and reapplies registered policies while recreating the container. + The rebuild command preserves manifest-defined Deep Agents state, regenerates `config.toml`, + reconstructs managed MCP projection state, and passes the complete current OpenShell policy to + replacement creation. ### Continue an Interrupted Replacement -Before `rebuild` deletes the existing sandbox, NemoClaw records a replacement journal in the onboarding session. -The journal binds the operation to the sandbox name, recorded OpenShell gateway, source identity, and replacement settings. -It stores fingerprints instead of credential values or raw OpenShell sandbox IDs. +Before `rebuild` deletes the existing sandbox, NemoClaw records a replacement journal in the onboarding session. The journal binds the operation to the sandbox name, recorded OpenShell gateway, source identity, and replacement settings. It stores fingerprints instead of credential values or raw OpenShell sandbox IDs. -If `rebuild` stops after recording the journal, rerun the command with the same replacement settings. -The rerun takes one of these actions: +If `rebuild` stops after recording the journal, rerun the command with the same replacement settings. The rerun takes one of these actions: - It continues deletion when the live sandbox still has the journaled source identity. - It continues creation when the recorded OpenShell gateway explicitly reports the source sandbox as absent. - It accepts an existing replacement only when its live identity and sandbox registry generation match the journal. -A mount-free journal written before NemoClaw bound host-mount identity remains resumable. -An older journal for a rebuild with one or more host mounts stops as incompatible, even when the visible mount settings are unchanged, because it cannot prove the original host source identity. -Preserve the live sandbox, onboarding session, printed backup, error, and the sandbox name, gateway, and journal phase from the `Journaled replacement` diagnostic. -Do not change the target settings, edit the session, or delete the same-name sandbox. -Ask a NemoClaw maintainer to review that retained recovery state before taking another recovery action. +A mount-free journal written before NemoClaw bound host-mount identity remains resumable. An older journal for a rebuild with one or more host mounts stops as incompatible, even when the visible mount settings are unchanged, because it cannot prove the original host source identity. Preserve the live sandbox, onboarding session, printed backup, exact error, and the sandbox name, gateway, and journal phase from the `Journaled replacement` diagnostic. Do not change the target settings, edit the session, or delete the same-name sandbox. Ask a NemoClaw maintainer to review that retained recovery state before taking another recovery action. An accepted replacement is not deleted again. + A rerun that accepts a journaled Hermes replacement checks for any retained NemoClaw gate before it retires the replacement journal. When the gate exists, the rerun validates the restored cron tree and releases the gate first. @@ -398,46 +289,41 @@ After recovery succeeds, rerun `rebuild` with the same replacement settings so N The command reports `Sandbox '' already holds the replacement from the interrupted rebuild.` and preserves the state backup path when one exists. Pass `--verbose` to include the replacement identifier, OpenShell gateway, and journal phase in rebuild diagnostics. -After the sandbox registry proves the journaled replacement identity and generation, NemoClaw removes an obsolete source image that it owns. -It retains the image when the source is shared or the registered replacement reuses it. -If image removal fails, NemoClaw keeps the accepted replacement and tells you to run `$$nemoclaw gc` for cleanup. +After the sandbox registry proves the journaled replacement identity and generation, NemoClaw removes an obsolete source image that it owns. It retains the image when the source is shared or the registered replacement reuses it. If image removal fails, NemoClaw keeps the accepted replacement and tells you to run `$$nemoclaw gc` for cleanup. -NemoClaw fails closed when the selected gateway, replacement settings, durable source registry fields, or live source or target identity no longer matches the journal. -The error names the sandbox and the mismatch that stopped recovery. -Do not delete a same-name sandbox to bypass this check. -Inspect the named OpenShell gateway and sandbox, correct the reported drift, and rerun the original command. -Visible settings cannot correct the legacy host-mount journal case described above. +NemoClaw fails closed when the selected gateway, replacement settings, durable source registry fields, or live source or target identity no longer matches the journal. The error names the sandbox and the mismatch that stopped recovery. Do not delete a same-name sandbox to bypass this check. Inspect the named OpenShell gateway and sandbox, correct the reported drift, and rerun the original command. Visible settings cannot correct the legacy host-mount journal case described above. -A same-name recreation started by `$$nemoclaw onboard` uses the same replacement journal. -If that recreation is interrupted after the `Journaled replacement` message, rerun the original onboarding command with the same target settings. -The active replacement can continue without adding `--resume`. -Use `--resume` for interrupted onboarding steps that occur before a replacement journal exists. +A same-name recreation started by `$$nemoclaw onboard` uses the same replacement journal. If that recreation is interrupted after the `Journaled replacement` message, rerun the original onboarding command with the same target settings. The active replacement can continue without adding `--resume`. Use `--resume` for interrupted onboarding steps that occur before a replacement journal exists. -If an archive command preserves at least one state directory, NemoClaw keeps the usable entries and reports the manifest-defined paths that could not be archived. -If a manifest-declared state file fails, NemoClaw stops before deleting the original sandbox even when it preserved state directories, unless you explicitly pass `--force`. -If every state directory fails, NemoClaw stops before deleting the original sandbox even when it captured loose files, unless you explicitly pass `--force`. +If an archive command preserves at least one state directory, NemoClaw keeps the usable entries and reports the manifest-defined paths that could not be archived. If a manifest-declared state file fails, NemoClaw stops before deleting the original sandbox even when it preserved state directories, unless you explicitly pass `--force`. If every state directory fails, NemoClaw stops before deleting the original sandbox even when it captured loose files, unless you explicitly pass `--force`. -`rebuild --force` can continue when no state directory was preserved or a manifest-declared state file failed. -NemoClaw restores any entries captured in the partial backup; if nothing usable was captured, it recreates the sandbox from recorded registry metadata without restoring prior sandbox state. -Use this recovery path only when losing the state that could not be backed up is acceptable. -When a sandbox with managed MCP servers cannot run a pre-mutation no-op, explicit `--force` uses its complete registry entries plus the live generated policies and provider identities to preserve MCP intent without scrubbing the unreachable in-sandbox adapter. -Every bridge entry must record the adapter for the sandbox's recorded agent. -The registered policy must match the policy NemoClaw generates for that adapter, server name, endpoint URL, and resolved addresses. -NemoClaw rechecks that read-only snapshot immediately before deletion and stops if the target, registry, policy, provider, or recorded gateway changed. -NemoClaw sends the delete request and every deletion-confirmation lookup to the sandbox's recorded gateway. -Across every rebuild path, NemoClaw does not attempt to stop the local NIM through the delete attempt, and cleanup is attempted on a best-effort basis only after deletion is positively confirmed. -After a nonzero delete, an explicit missing result converges as deleted. -A `Ready` or `Running` result triggers an attempt to restore prepared MCP state and any shields lockdown that rebuild temporarily opened. -NemoClaw reports any MCP or shields restoration failure and does not present the operation as a successful rollback. -Any partial or unreachable result remains ambiguous. -NemoClaw preserves the MCP ownership and rebuild-recovery records, does not attempt to stop NIM, skips the rebuild process's immediate shields relock, and does not claim that the original sandbox is intact. -Inspect the live sandbox and gateway state before retrying recovery. -This recovery also stops for incomplete MCP adds or ambiguous ownership; an error after a successful no-op does not fall back to the host-side path. + `rebuild --force` can continue when no state directory was preserved or a manifest-declared state + file failed. NemoClaw restores any entries captured in the partial backup; if nothing usable was + captured, it recreates the sandbox from recorded registry metadata without restoring prior sandbox + state. Use this recovery path only when losing the state that could not be backed up is + acceptable. When a sandbox with managed MCP servers cannot run a pre-mutation no-op, explicit + `--force` uses its complete bridge entries plus exact provider and target identities to preserve + MCP intent without scrubbing the unreachable in-sandbox adapter. Every bridge entry must record + the adapter for the sandbox's recorded agent. NemoClaw rechecks that read-only bridge snapshot + immediately before deletion and stops if the target, registry, provider, or recorded gateway + changed. Policy is not part of that ownership proof; the independently captured live OpenShell + policy is handed to replacement creation unchanged. NemoClaw sends the delete request and every + deletion-confirmation lookup to the sandbox's exact recorded gateway. Across every rebuild path, + NemoClaw does not attempt to stop the local NIM through the delete attempt, and cleanup is + attempted on a best-effort basis only after deletion is positively confirmed. After a nonzero + delete, an explicit missing result converges as deleted. A `Ready` or `Running` result triggers an + attempt to restore prepared MCP state and any shields lockdown that rebuild temporarily opened. + NemoClaw reports any MCP or shields restoration failure and does not present the operation as a + successful rollback. Any partial or unreachable result remains ambiguous. NemoClaw preserves the + MCP ownership and rebuild-recovery records, does not attempt to stop NIM, skips the rebuild + process's immediate shields relock, and does not claim that the original sandbox is intact. + Inspect the live sandbox and gateway state before retrying recovery. This recovery also stops for + incomplete MCP adds or ambiguous ownership; an error after a successful no-op does not fall back + to the host-side path. -When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down window for backup and recreation. -A detached auto-lock timer remains the recovery authority until NemoClaw commits a successful shields-up state, including when the host rebuild process exits unexpectedly. +When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down window for backup and recreation. A detached auto-lock timer remains the recovery authority until NemoClaw commits a successful shields-up state, including when the host rebuild process exits unexpectedly. If a failed shields transition on a sandbox from an older NemoClaw release quarantined the OpenClaw config, the bytes are preserved as `/sandbox/.openclaw/.nemoclaw-rejected-openclaw.json-` rather than deleted. @@ -449,8 +335,11 @@ Sandboxes with the updated guard report quarantine filenames and synthesize a mi -For an older Hermes image that predates sealed shields transitions, only the rebuild workflow may use the descriptor-safe compatibility transition needed to archive and replace the sandbox. -That transition verifies the strict and compatibility hashes and publishes fresh config inodes before changing their lock posture, while ordinary `shields up` and `shields down` commands continue to refuse the older protocol. + For an older Hermes image that predates sealed shields transitions, only the rebuild workflow may + use the descriptor-safe compatibility transition needed to archive and replace the sandbox. That + transition verifies the strict and compatibility hashes and publishes fresh config inodes before + changing their lock posture, while ordinary `shields up` and `shields down` commands continue to + refuse the older protocol. Refer to [`$$nemoclaw rebuild`](../../reference/commands#$$nemoclaw-name-rebuild) for flag details. diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index 4ec202eaf4d..9cd9ed4eb91 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -12,15 +12,15 @@ skill: priority: 10 agent-variants: ["openclaw", "hermes"] --- -Use this matrix to choose the operation that makes a sandbox change take effect. -NemoClaw applies its security posture in three layers: what onboarding writes into the sandbox image, what the running sandbox can hot-reload, and what requires a rebuild or re-onboard. + +Use this matrix to choose the operation that makes a sandbox change take effect. NemoClaw applies its security posture in three layers: what onboarding writes into the sandbox image, what the running sandbox can hot-reload, and what requires a rebuild or re-onboard. ## OpenClaw Runtime Changes | Item | When the change takes effect | How to change it | -|---|---|---| +| --- | --- | --- | | Inference provider | Runtime route and config update while shields are down; rebuild only if you need to recreate the image | Run `$$nemoclaw shields down`, then `$$nemoclaw inference set`, then restore shields | | Inference model on the current provider | Runtime route and config update while shields are down | Run `$$nemoclaw shields down`, then `$$nemoclaw inference set`, then restore shields | | Sub-agent | Re-onboard required because the sub-agent and workspace are baked at onboard | `$$nemoclaw onboard --recreate-sandbox` | @@ -39,25 +39,17 @@ NemoClaw applies its security posture in three layers: what onboarding writes in | `agents.list` | Runtime; OpenClaw hot-reloads on config change | Prefer agent or NemoClaw commands that keep host and sandbox state aligned | | `openclaw.json` keys | Mixed; supported config and inference updates run while shields are down, while image, policy, web search, and channel changes can require rebuild | Use `$$nemoclaw inference set` or `$$nemoclaw config set` so the config and integrity hash change together | -For a new or pristine OpenClaw workspace, `NEMOCLAW_MINIMAL_BOOTSTRAP=1` avoids roughly 3,000 tokens of per-turn project-context overhead by skipping the default template seed. -It does not delete existing workspace files. +For a new or pristine OpenClaw workspace, `NEMOCLAW_MINIMAL_BOOTSTRAP=1` avoids roughly 3,000 tokens of per-turn project-context overhead by skipping the default template seed. It does not delete existing workspace files. -The runtime source of truth is `/sandbox/.openclaw/openclaw.json`. -The host registry caches metadata, but the image and OpenClaw read from the in-sandbox file. +The runtime source of truth is `/sandbox/.openclaw/openclaw.json`. The host registry caches metadata, but the image and OpenClaw read from the in-sandbox file. -OpenClaw config and inference changes are refused while shields are up. -Run `$$nemoclaw shields down` before the change, then restore lockdown with `$$nemoclaw shields up`. +OpenClaw config and inference changes are refused while shields are up. Run `$$nemoclaw shields down` before the change, then restore lockdown with `$$nemoclaw shields up`. -Host-side OpenClaw config writes run under the per-sandbox transition lock and bind the replacement to the SHA-256 digest of the matching read. -Before `config set` replaces the live file, NemoClaw validates the complete candidate with the installed OpenClaw runtime. -If candidate validation fails, the command preserves the existing config and does not reach the gateway restart path. -The root-only config guard validates bounded JSON input, transactionally publishes fresh config and hash inodes, and restores the prior mutable posture without adopting concurrent path changes. +Host-side OpenClaw config writes run under the per-sandbox transition lock and bind the replacement to the SHA-256 digest of the matching read. Before `config set` replaces the live file, NemoClaw validates the complete candidate with the installed OpenClaw runtime. If candidate validation fails, the command preserves the existing config and does not reach the gateway restart path. The root-only config guard validates bounded JSON input, transactionally publishes fresh config and hash inodes, and restores the prior mutable posture without adopting concurrent path changes. -In the direct root-entrypoint topology, gateway restart performs a read-only config and hash preflight, temporarily seals fresh inodes while the root PID 1 supervisor replaces the gateway child, and then restores the prior shields posture. -In the OpenShell-managed topology, the installed root controller performs the config preflight while the nonroot `nemoclaw-start` supervisor replaces the gateway child. +In the direct root-entrypoint topology, gateway restart performs a read-only config and hash preflight, temporarily seals fresh inodes while the root PID 1 supervisor replaces the gateway child, and then restores the prior shields posture. In the OpenShell-managed topology, the installed root controller performs the config preflight while the nonroot `nemoclaw-start` supervisor replaces the gateway child. -Mutable config in the managed topology keeps the same trust and time-of-check/time-of-use limits as a managed cold start and does not receive the direct root-entrypoint restart seal. -If preflight detects an unsafe path, invalid config, invalid ownership posture, or locked hash drift, restart refuses while the old healthy gateway is still serving. +Mutable config in the managed topology keeps the same trust and time-of-check/time-of-use limits as a managed cold start and does not receive the direct root-entrypoint restart seal. If preflight detects an unsafe path, invalid config, invalid ownership posture, or locked hash drift, restart refuses while the old healthy gateway is still serving. @@ -65,7 +57,7 @@ If preflight detects an unsafe path, invalid config, invalid ownership posture, ## Hermes Runtime Changes | Item | When the change takes effect | How to change it | -|---|---|---| +| --- | --- | --- | | Inference provider | Runtime route changes apply immediately; rebuild if you need to rebake model metadata into the image | `$$nemoclaw inference set` for route changes, or `$$nemoclaw rebuild` after changing build-time settings | | Inference model on the current provider | Hot-reloadable through the Hermes config sync path | `$$nemoclaw inference set` | | Agent runtime | Re-onboard required because the agent and state layout are baked at onboard | `$$nemoclaw onboard --recreate-sandbox` or `nemoclaw onboard --agent openclaw --recreate-sandbox` | @@ -81,100 +73,59 @@ If preflight detects an unsafe path, invalid config, invalid ownership posture, | GPU passthrough or device selector | Locked at creation | Re-onboard with `--gpu` or `--sandbox-gpu-device` | | Hermes `config.yaml` keys | Mixed; inference and supported config keys can be patched by host commands, while image, policy, and channel changes still require rebuild | Use `$$nemoclaw inference set` or `$$nemoclaw config set` so the config and root-owned trust anchor change together | -The runtime source of truth is `/sandbox/.hermes/config.yaml` plus `/sandbox/.hermes/.env`. -The host registry caches metadata, but the image and Hermes runtime read from the in-sandbox files. +The runtime source of truth is `/sandbox/.hermes/config.yaml` plus `/sandbox/.hermes/.env`. The host registry caches metadata, but the image and Hermes runtime read from the in-sandbox files. -Do not edit those files or their hash files directly and then expect `gateway restart` to establish the bytes as trusted. -Use supported host config and inference commands so NemoClaw updates the managed config metadata together. +Do not edit those files or their hash files directly and then expect `gateway restart` to establish the bytes as trusted. Use supported host config and inference commands so NemoClaw updates the managed config metadata together. -Hermes host-side config writes run as a sealed transaction. -NemoClaw binds the write to the SHA-256 digest of the matching read, temporarily seals the mutable config paths, atomically installs fresh config inodes, refreshes the strict and compatibility hashes, and then restores the prior shields posture. +Hermes host-side config writes run as a sealed transaction. NemoClaw binds the write to the SHA-256 digest of the matching read, temporarily seals the mutable config paths, atomically installs fresh config inodes, refreshes the strict and compatibility hashes, and then restores the prior shields posture. -`shields up` also publishes fresh config, environment, and compatibility-hash inodes so a descriptor opened before lockdown cannot retain write authority. -Shields up keeps `/sandbox/.hermes/profiles/dashboard-home/` sandbox-owned at mode `0700` so the dashboard can update its isolated profile. -Other Hermes profiles remain read-only during lockdown. -The root-only mutation lock stays held through every Hermes host-side config write. -On the sealed-plan and compatibility Shields paths, it also stays held through the full `shields up` or `shields down` filesystem transition and verification, and lifecycle recovery that needs to seal those paths. +`shields up` also publishes fresh config, environment, and compatibility-hash inodes so a descriptor opened before lockdown cannot retain write authority. Shields up keeps `/sandbox/.hermes/profiles/dashboard-home/` sandbox-owned at mode `0700` so the dashboard can update its isolated profile. Other Hermes profiles remain read-only during lockdown. The root-only mutation lock stays held through every Hermes host-side config write. On the sealed-plan and compatibility Shields paths, it also stays held through the full `shields up` or `shields down` filesystem transition and verification, and lifecycle recovery that needs to seal those paths. -Current NemoClaw-managed Hermes images on the Docker driver use the `provider-state-mutation-v2` runtime provider state mutation contract for Shields filesystem transitions. -NemoClaw selects this contract only when all these conditions apply: +Current NemoClaw-managed Hermes images on the Docker driver use the `provider-state-mutation-v2` runtime provider state mutation contract for Shields filesystem transitions. NemoClaw selects this contract only when all these conditions apply: - The registered sandbox is a managed Hermes image. - The sandbox registry records its lifecycle generation. - The image exposes the root-owned capability installed by NemoClaw. -An older managed image uses the sealed-plan transition only after NemoClaw proves that the capability is absent. -A custom image, including a legacy Dockerfile workflow, remains on its existing sealed-plan or compatibility contract and cannot opt in by adding a similarly named file. -An invalid capability that is present fails closed and requires a rebuild with a current managed image. +An older managed image uses the sealed-plan transition only after NemoClaw proves that the capability is absent. A custom image, including a legacy Dockerfile workflow, remains on its existing sealed-plan or compatibility contract and cannot opt in by adding a similarly named file. An invalid capability that is present fails closed and requires a rebuild with a current managed image. -The Docker provider binds each transition to the registered lifecycle generation, container, qualified Docker engine authority, mount namespace, `/sandbox/.hermes` inode, AgentDefinition-derived plan and projection, target posture, rollback posture, and fresh nonce. -Before the provider fence becomes active, NemoClaw drains any earlier privileged sandbox execution lease. -While the fence is active, new direct-container, SSH, and OpenShell command transports are rejected before a sandbox command starts. -The installed root-owned controller and Hermes publisher apply and verify the target posture, then the provider starts only the bound Hermes entrypoint and proves the replacement gateway and startup checkpoint before it releases the fence. +The Docker provider binds each transition to the registered lifecycle generation, exact container, qualified Docker engine authority, mount namespace, `/sandbox/.hermes` inode, AgentDefinition-derived plan and projection, target posture, rollback posture, and fresh nonce. Before the provider fence becomes active, NemoClaw drains any earlier privileged sandbox execution lease. While the fence is active, new direct-container, SSH, and OpenShell command transports are rejected before a sandbox command starts. The installed root-owned controller and Hermes publisher apply and verify the exact target posture, then the provider starts only the bound Hermes entrypoint and proves the replacement gateway and startup checkpoint before it releases the fence. -The owner-only ledger under `~/.nemoclaw/state/runtime-provider-lifecycle/` survives a host controller restart and remains authoritative for recovery of the target. -The next Shields command, including `shields status`, must recover a retained fence before it can report or change posture. -Hermes startup also checks the image-owned startup gate before it reads mutable state and remains held when the runtime provider state mutation is active or cannot be authenticated. -While recovery is incomplete, do not: +The owner-only ledger under `~/.nemoclaw/state/runtime-provider-lifecycle/` survives a host controller restart and remains authoritative for recovery of the exact target. The next Shields command, including `shields status`, must recover a retained fence before it can report or change posture. Hermes startup also checks the image-owned startup gate before it reads mutable state and remains held when the runtime provider state mutation is active or cannot be authenticated. While recovery is incomplete, do not: - Delete the ledger. - Kill a held entrypoint. - Use a manual container command to bypass the fence. -If another host mutation is active, the command reports `Hermes config mutation is already in progress`. -If another lifecycle request owns the supervisor, it reports `SUPERVISOR_BUSY`. -Both errors are retryable. +If another host mutation is active, the command reports `Hermes config mutation is already in progress`. If another lifecycle request owns the supervisor, it reports `SUPERVISOR_BUSY`. Both errors are retryable. -Let the active command finish, then retry instead of editing lock or seal files manually. -Hermes config and inference changes are refused while shields are up. -Run `$$nemoclaw shields down` before the change, then restore lockdown with `$$nemoclaw shields up`. +Let the active command finish, then retry instead of editing lock or seal files manually. Hermes config and inference changes are refused while shields are up. Run `$$nemoclaw shields down` before the change, then restore lockdown with `$$nemoclaw shields up`. ## Timed Shields Windows -NemoClaw serializes host-side gateway recovery, config and inference writes, snapshot mutation, sandbox destruction, and shields transitions for each sandbox. -When `shields down --timeout` is active, each mutation binds to that timer generation so a replaced or expired timer cannot race a later command or a new sandbox that reuses the same name. +NemoClaw serializes host-side gateway recovery, config and inference writes, snapshot mutation, sandbox destruction, and shields transitions for each sandbox. When `shields down --timeout` is active, each mutation binds to that exact timer generation so a replaced or expired timer cannot race a later command or a new sandbox that reuses the same name. ### Restore Lockdown After Expiration -If the timeout expires while a mutation is changing sandbox state, auto-restore closes the per-sandbox lifecycle deadline gate. -The gate blocks new mutations and waits for the recorded live owner to release its lock generation. -NemoClaw does not signal that process because portable process inspection cannot prove that all descendants stopped. -After the owner releases the lock, auto-restore restores the restrictive policy and configuration posture. -The ownership check includes both the process ID and process start identity so PID reuse does not grant control over an unrelated process. +If the timeout expires while a mutation is changing sandbox state, auto-restore closes the per-sandbox lifecycle deadline gate. The gate blocks new mutations and waits for the recorded live owner to release its exact lock generation. NemoClaw does not signal that process because portable process inspection cannot prove that all descendants stopped. After the owner releases the lock, auto-restore restores the restrictive policy and configuration posture. The ownership check includes both the process ID and process start identity so PID reuse does not grant control over an unrelated process. ### Complete Generation Recovery -An interactive command can take over an expired timer. -Interactive recovery has separate transition-takeover and restoration phases. -Each phase makes up to 7 attempts and waits 5 seconds between failures, for up to 30 seconds of retry delay per phase. -Detached recovery uses one 7-attempt budget across deadline setup, main-generation publication, and restoration. -The deadline gate remains closed during those attempts. +An interactive command can take over an expired timer. Interactive recovery has separate transition-takeover and restoration phases. Each phase makes up to 7 attempts and waits 5 seconds between failures, for up to 30 seconds of retry delay per phase. Detached recovery uses one 7-attempt budget across deadline setup, main-generation publication, and restoration. The deadline gate remains closed during those attempts. -If restoration cannot commit, NemoClaw attempts to record durable containment. -If that containment commit also fails, NemoClaw retains any lifecycle and deadline gates it already owns. -A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition. -Correct the reported state-directory write failure, then run `$$nemoclaw shields status` to resume recovery or receive generation recovery guidance. -When recovery cannot complete, an interactive command returns an error, or the detached timer exits with a failure status. +If restoration cannot commit, NemoClaw attempts to record durable containment. If that containment commit also fails, NemoClaw retains any exact lifecycle and deadline gates it already owns. A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition. Correct the reported state-directory write failure, then run `$$nemoclaw shields status` to resume recovery or receive exact-generation recovery guidance. When recovery cannot complete, an interactive command returns an error, or the detached timer exits with a failure status. -NemoClaw also records durable containment when an owner exits before it can prove that the owner's descendants stopped, or when ownership becomes ambiguous. -Durable containment, retained gates, or the fail-closed state-directory error keeps new mutations blocked until you complete generation operator recovery. -A `--dry-run` run of a `channels` or `policy` command takes no mutation lock, so you can still preview the change while mutations are blocked. +NemoClaw also records durable containment when an owner exits before it can prove that the owner's descendants stopped, or when ownership becomes ambiguous. Durable containment, retained exact gates, or the fail-closed state-directory error keeps new mutations blocked until you complete exact-generation operator recovery. A `--dry-run` run of a `channels` or `policy` command takes no mutation lock, so you can still preview the change while mutations are blocked. -Stop all NemoClaw processes for the sandbox, then follow the paths, identities, tokens, and removal order in the reported error. -Verify each recorded generation is unchanged, remove only the stale generations first, and remove the containment generation last. +Stop all NemoClaw processes for the sandbox, then follow the paths, identities, tokens, and removal order in the reported error. Verify each recorded generation is unchanged, remove only the exact stale generations first, and remove the exact containment generation last. -### Preserve Managed MCP Policy +### Preserve Live MCP Policy -Before a manual Shields transition replaces a policy, NemoClaw requires the managed Model Context Protocol (MCP) entries to match among the sandbox registry, generated-policy record, and live gateway policy. -`shields down` carries the proven managed MCP policy entries into the relaxed policy. -Restoration removes snapshot-time managed MCP entries before it overlays current entries. -If agreement is absent, a manual Shields transition refuses the replacement policy. +`shields down` reads MCP policy entries from the current OpenShell policy and carries those exact live values into the temporary relaxed policy. It does not compare them to a NemoClaw ownership record. -At an expired deadline, auto-restore omits unproven managed MCP policy entries, restores lockdown, and records the omission count in its audit entry. -An MCP server removed during the shields-down window stays removed. -A surviving server keeps its recorded endpoint and address pins while it retains policy ownership. +Restoration reverses only the policy delta introduced by the active Shields transaction. Host-side changes made during the window, including edits to MCP entries, remain intact. The bounded before/forward artifacts are removed when recovery completes. ## Related Topics diff --git a/docs/network-policy/apply-policy-presets.mdx b/docs/network-policy/apply-policy-presets.mdx index b136dca0a0f..4ca11fe8939 100644 --- a/docs/network-policy/apply-policy-presets.mdx +++ b/docs/network-policy/apply-policy-presets.mdx @@ -4,15 +4,15 @@ title: "Apply Policy Presets" sidebar-title: "Apply Policy Presets" description: "Add, reapply, list, or remove policy presets for a running NemoClaw sandbox." -description-agent: "Applies and manages policy presets for a running sandbox. Use when adding maintained integration access, previewing preset scope, reapplying an edited preset, removing access, or removing a preset the gateway enforces without a local record." -keywords: ["nemoclaw policy presets", "policy add", "policy remove", "active on gateway missing from local state"] +description-agent: "Applies and manages policy presets in the current OpenShell policy for a running sandbox." +keywords: ["nemoclaw policy presets", "policy add", "policy remove", "openshell policy"] content: type: "how_to" skill: priority: 10 --- -Use policy presets to add reviewed network access to one running sandbox without replacing its current policy. -NemoClaw records applied presets so rebuild and restore operations can replay them. + +Use policy presets to add reviewed network access to one running sandbox without replacing its current policy. NemoClaw provides the convenient merge and removal commands; OpenShell remains the only durable policy state. Use `$$nemoclaw policy add` to merge a preset into the running policy. @@ -22,9 +22,7 @@ Follow [Replace the Live Network Policy](replace-live-network-policy) only when ## Choose a Maintained Preset -During onboarding, the selected [policy tier](../../reference/network-policies#policy-tiers) determines which maintained presets are enabled by default. -The interactive preset screen lets you add or remove individual presets. -Messaging channel choices are scoped to the active agent, so unsupported channel presets do not appear. +During onboarding, the selected [policy tier](../../reference/network-policies#policy-tiers) determines which maintained presets are enabled by default. The interactive preset screen lets you add or remove individual presets. Messaging channel choices are scoped to the active agent, so unsupported channel presets do not appear. List the presets available to the sandbox: @@ -33,10 +31,12 @@ $$nemoclaw policy list ``` -For the maintained preset catalog and guided service workflows, refer to [Common Integration Policy Examples](../integration-policy-examples). + For the maintained preset catalog and guided service workflows, refer to [Common Integration + Policy Examples](../integration-policy-examples). -For Deep Agents baseline, tier, Tavily, and observability preset behavior, refer to [Network Policies](../../reference/network-policies#policy-tiers). + For Deep Agents baseline, tier, Tavily, and observability preset behavior, refer to [Network + Policies](../../reference/network-policies#policy-tiers). ## Preview and Apply a Preset @@ -66,27 +66,19 @@ Omit the preset name to use the interactive picker: $$nemoclaw my-assistant policy add ``` -The picker requires a terminal on stdin. -A run without a terminal, such as an SSH command without `-t`, a service unit, or a CI job, exits non-zero and reports that no input is available on stdin. +The picker requires a terminal on stdin. A run without a terminal, such as an SSH command without `-t`, a service unit, or a CI job, exits non-zero and reports that no input is available on stdin. -Pass a preset name with `--yes` for scripted workflows. -Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` to use the same non-interactive flow through an environment variable. -With that variable set, a missing preset name instead reports that non-interactive mode requires a preset name. +Pass a preset name with `--yes` for scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` to use the same non-interactive flow through an environment variable. With that variable set, a missing preset name instead reports that non-interactive mode requires a preset name. ## Reapply an Edited Preset -Run the same `policy add` command after you edit a maintained or recorded custom preset. -NemoClaw compares the preset with the live policy. -If the content differs, it applies the changed content. -You do not need to remove the preset first. +Run the same `policy add` command after you edit a maintained preset or a custom source file. NemoClaw compares the preset with the live policy. If the content differs, it applies the changed content. You do not need to remove the preset first. -The merge starts from the round-trippable base policy returned by `openshell policy get --base`. -It excludes provider-composed `_provider_*` entries because OpenShell reserves that namespace and rejects it in `policy set`. -Existing presets and baseline entries remain in place. +The merge starts from the round-trippable base policy returned by `openshell policy get --base`. It excludes provider-composed `_provider_*` entries because OpenShell reserves that namespace and rejects it in `policy set`. Existing presets and baseline entries remain in place. ## List and Remove Presets -List every preset recorded for the sandbox: +List available presets and identify which ones match the current OpenShell policy: ```bash $$nemoclaw policy list @@ -111,46 +103,27 @@ $$nemoclaw my-assistant policy remove weather --yes `policy remove` accepts maintained and custom preset names. -## Remove a Preset the Gateway Enforces Without a Local Record +## OpenShell Is the Source of Truth -`policy list` marks a preset that the OpenShell gateway enforces while no local record explains it: +`policy list` derives applied state by comparing preset content with the current OpenShell policy: ```bash $$nemoclaw my-assistant policy list ``` -Expected output: - -```text - ● github [source unverified] — GitHub.com and GitHub API access (git) (active on gateway, missing from local state) -``` - -`policy remove` accepts that preset: +`policy remove` reads the live policy, removes the keys defined by the selected preset, writes the complete result, and verifies it: ```bash $$nemoclaw my-assistant policy remove github --yes ``` -It narrows the live policy and clears whatever local record remains. - -When NemoClaw cannot reach the gateway, `policy remove ` has only the local record to check. -It refuses an unrecorded preset and reports that it could not query the gateway, rather than treating an unanswered query as absence. -The interactive picker lists the recorded presets only in that case. +When NemoClaw cannot read the OpenShell policy, it refuses the mutation rather than falling back to local policy state. ## Understand Persistence -Dynamic changes apply to the current live policy. -NemoClaw also records maintained presets and custom presets applied through `--from-file` or `--from-dir`. -The custom preset record includes the full YAML content. -Snapshot restore and rebuild replay the recorded presets, even when the original custom file no longer exists. - -A sandbox that is absent from the local registry has nothing to record a preset against. -For a maintained preset, `policy add` still applies it to the gateway and warns that `policy list` reports it as active on gateway, missing from local state. -For a custom preset applied with `--from-file` or `--from-dir`, `policy add` reaches the gateway but exits non-zero, because a custom preset is discoverable only through the registry and would appear in neither `policy list` nor `status`. -Recover or re-onboard the sandbox to restore the record, then re-apply any custom preset that failed this way. +Dynamic changes exist only in the current live OpenShell policy. Maintained preset names are inferred by comparing their current content. Custom presets applied through `--from-file` or `--from-dir` use namespaced keys in that same live document so `policy list` and `policy remove` can discover them without a second registry. -`$$nemoclaw rebuild` reapplies every recorded policy preset to the recreated sandbox. -For baseline changes that apply to every future sandbox, follow [Change the Baseline Network Policy](change-baseline-network-policy). +`$$nemoclaw rebuild` and snapshot clone hand the complete current OpenShell policy to sandbox creation. They do not reconstruct it from preset records, so trusted changes made through the OpenShell TUI or another host process are preserved too. For baseline changes that apply to every future sandbox, follow [Change the Baseline Network Policy](change-baseline-network-policy). ## Approve One Request @@ -160,8 +133,7 @@ For one-off access, approve a blocked request in the OpenShell TUI: openshell term ``` -Use the TUI to test a destination before deciding whether it belongs in a maintained or custom preset. -For the complete approval workflow, refer to [Approve or Deny Network Requests](../approve-network-requests). +Use the TUI to test a destination before deciding whether it belongs in a maintained or custom preset. For the complete approval workflow, refer to [Approve or Deny Network Requests](../approve-network-requests). ## Related Topics diff --git a/docs/network-policy/create-custom-policy-presets.mdx b/docs/network-policy/create-custom-policy-presets.mdx index 96916bb314b..965410bef23 100644 --- a/docs/network-policy/create-custom-policy-presets.mdx +++ b/docs/network-policy/create-custom-policy-presets.mdx @@ -11,12 +11,12 @@ content: skill: priority: 10 --- -Create a custom preset when a sandbox needs a reviewed endpoint that no maintained NemoClaw preset covers. -Custom presets add scoped access to one sandbox without changing the baseline policy. + +Create a custom preset when a sandbox needs a reviewed endpoint that no maintained NemoClaw preset covers. Custom presets add scoped access to one sandbox without changing the baseline policy. -Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress. -Review every host before applying a custom preset, especially when the file originates outside your team. + Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress. Review every + host before applying a custom preset, especially when the file originates outside your team. ## Author a Preset @@ -41,44 +41,36 @@ network_policies: - { path: /path/to/requesting-binary } ``` -Replace `/path/to/requesting-binary` with the executable path reported for the blocked request in `openshell term`. +Replace `/path/to/requesting-binary` with the exact executable path reported for the blocked request in `openshell term`. + -For Deep Agents Code, OpenShell commonly reports `/usr/local/bin/dcode` or `/opt/venv/bin/python3*`. -Authorize only the process that needs the reviewed endpoint. + For Deep Agents Code, OpenShell commonly reports `/usr/local/bin/dcode` or + `/opt/venv/bin/python3*`. Authorize only the process that needs the reviewed endpoint. -The top-level `preset.name` must be a lowercase RFC 1123 label with letters, digits, and hyphens. -It must not collide with a maintained preset name such as `slack` or `pypi`. -Rename `preset.name` if NemoClaw reports a collision. -Custom preset `network_policies` entries must not use `npm_yarn`. -NemoClaw reserves that key for the maintained `npm` preset and rejects the file before applying it. +The top-level `preset.name` must be a lowercase RFC 1123 label with letters, digits, and hyphens. It must not collide with a maintained preset name such as `slack` or `pypi`. Rename `preset.name` if NemoClaw reports a collision. Custom preset `network_policies` entries must not use `npm_yarn`. NemoClaw reserves that key for the maintained `npm` preset and rejects the file before applying it. -Each endpoint must name a specific host or a scoped subdomain wildcard such as `*.example.com`. -NemoClaw rejects catch-all destinations, including `*`, `0.0.0.0`, `0.0.0.0/0`, `::`, and `::/0`. -Rule matchers must match the endpoint protocol. +Each endpoint must name a specific host or a scoped subdomain wildcard such as `*.example.com`. NemoClaw rejects catch-all destinations, including `*`, `0.0.0.0`, `0.0.0.0/0`, `::`, and `::/0`. Rule matchers must match the endpoint protocol. -| Protocol | Rule fields | -|----------|-------------| -| REST | `method` and `path`; `method` accepts standard HTTP methods or `*` | +| Protocol | Rule fields | +| --------- | --------------------------------------------------------------------- | +| REST | `method` and `path`; `method` accepts standard HTTP methods or `*` | | WebSocket | `method` and `path`; `method` accepts `GET`, `WEBSOCKET_TEXT`, or `*` | -| JSON-RPC | `method` only | -| MCP | `method` with optional `tool` or `params.name` | +| JSON-RPC | `method` only | +| MCP | `method` with optional `tool` or `params.name` | The same protocol-specific matcher shape applies to `deny_rules`. -User-authored presets must not declare `allowed_ips` for ordinary endpoints. -NemoClaw rejects that field in files passed through `--from-file` or `--from-dir` because it can widen the private ranges that OpenShell checks during SSRF protection. -Use hostnames, ports, protocols, methods, paths, and binary restrictions instead. -The only exception is the `host.openshell.internal` bridge endpoint for explicit sandbox-to-host service access. +User-authored presets must not declare `allowed_ips` for ordinary endpoints. NemoClaw rejects that field in files passed through `--from-file` or `--from-dir` because it can widen the private ranges that OpenShell checks during SSRF protection. Use hostnames, ports, protocols, methods, paths, and binary restrictions instead. The only exception is the `host.openshell.internal` bridge endpoint for explicit sandbox-to-host service access. ## Admit a Private Host -Use explicit private-host trust when a custom preset targets an operator-controlled endpoint on RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local address space. -This flow applies to REST, WebSocket, JSON-RPC, and MCP endpoint protocols. +Use explicit private-host trust when a custom preset targets an operator-controlled endpoint on RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local address space. This flow applies to REST, WebSocket, JSON-RPC, and MCP endpoint protocols. -The `--trusted-private-host` option and `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` grant the custom preset access to each exact matching private host. -Review the preset, resolved addresses, requesting binaries, methods, and paths before you apply it. + The `--trusted-private-host` option and `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` grant the custom preset + access to each matching exact private host. Review the preset, resolved addresses, requesting + binaries, methods, and paths before you apply it. Pass the endpoint host with `--from-file` or `--from-dir`: @@ -90,26 +82,13 @@ $$nemoclaw my-assistant policy add \ --dry-run ``` -The option is invalid for a built-in preset because maintained presets own their reviewed destinations. -NemoClaw rejects unused, unrelated, wildcard, suffix, CIDR, URL-shaped, duplicate, or malformed `--trusted-private-host` declarations. -It also rejects loopback, link-local, metadata, unspecified, multicast, documentation, translation, benchmarking, and other reserved ranges. +The option is invalid for a built-in preset because maintained presets own their reviewed destinations. NemoClaw rejects unused, unrelated, wildcard, suffix, CIDR, URL-shaped, duplicate, or malformed `--trusted-private-host` declarations. It also rejects loopback, link-local, metadata, unspecified, multicast, documentation, translation, benchmarking, and other reserved ranges. -As an alternative, set `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` to a comma-separated list of exact hosts for the current command. -NemoClaw combines the environment list with any `--trusted-private-host` options. -It normalizes and deduplicates environment entries and ignores entries unrelated to the custom preset batch. +As an alternative, set `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` to a comma-separated list of exact hosts for the current command. NemoClaw combines the environment list with any `--trusted-private-host` options. It normalizes and deduplicates environment entries and ignores entries unrelated to the custom preset batch. -After schema validation, NemoClaw resolves each declared endpoint and inserts every validated address as an `allowed_ips` value in memory. -A trusted host can return both public and supported private addresses. -NemoClaw pins every canonical answer. -If any answer is a disallowed private, reserved, or special-purpose address, validation rejects the preset instead of discarding that answer. -The dry-run output shows the generated pins for review. -NemoClaw applies and records the transformed preset instead of the unpinned source file. -Rebuild replays recorded pins from the sandbox registry without depending on the ambient environment. -A snapshot does not grant private-host authority to a clean target by itself; reapply the source preset with explicit trust after a cross-sandbox restore. +After schema validation, NemoClaw resolves each declared endpoint and inserts every validated address as an exact `allowed_ips` value in memory. An exact trusted host can return both public and supported private addresses. NemoClaw pins every canonical answer. If any answer is a disallowed private, reserved, or special-purpose address, validation rejects the preset instead of discarding that answer. The dry-run output shows the generated pins for review. NemoClaw applies the transformed preset directly to the current OpenShell policy instead of the unpinned source file. Rebuild and cross-sandbox snapshot restore carry those live pins forward as part of the complete OpenShell policy without depending on ambient DNS. Reapply the source preset with explicit trust only when you intend to refresh the trusted host's address pins or change its endpoint policy. -To change a recorded address set, apply the source preset again with explicit trust. -NemoClaw performs a new preflight and shows the changed pins before it applies them. -Do not add `allowed_ips` to the source YAML. +To change the live address set, apply the source preset again with explicit trust. NemoClaw performs a new preflight and shows the changed pins before it applies them. Do not add `allowed_ips` to the source YAML. ## Apply a Single File @@ -120,8 +99,7 @@ $$nemoclaw my-assistant policy add --from-file ./presets/my-service-api.yaml --d $$nemoclaw my-assistant policy add --from-file ./presets/my-service-api.yaml --yes ``` -NemoClaw records the complete YAML content with the sandbox. -You can remove the preset later without keeping the original file. +NemoClaw namespaces the preset keys in the current OpenShell policy. You can remove the preset later by name without keeping the original file while that live policy remains available. ## Apply Every File in a Directory @@ -131,16 +109,11 @@ Apply preset files in lexicographic order: $$nemoclaw my-assistant policy add --from-dir ./presets/ --yes ``` -Processing stops at the first failure. -NemoClaw does not remove presets that it already applied. -Fix the failing file and run the command again to continue. +Processing stops at the first failure. NemoClaw does not remove presets that it already applied. Fix the failing file and run the command again to continue. ## Add a Preset to the Source Catalog -Save a maintained local preset under `nemoclaw-blueprint/policies/presets/`. -The filename without `.yaml` must match `preset.name`. -The preset catalog reads `preset.name`, while `policy add ` loads `presets/.yaml`. -A mismatch can list a preset that the named command cannot load. +Save a maintained local preset under `nemoclaw-blueprint/policies/presets/`. The filename without `.yaml` must match `preset.name`. The preset catalog reads `preset.name`, while `policy add ` loads `presets/.yaml`. A mismatch can list a preset that the named command cannot load. Apply the catalog preset by name: @@ -148,37 +121,29 @@ Apply the catalog preset by name: $$nemoclaw my-assistant policy add my-service-api ``` -Run the same command after editing the file. -NemoClaw compares the preset with the live policy and applies changed content. +Run the same command after editing the file. NemoClaw compares the preset with the live policy and applies changed content. ## Remove a Custom Preset -Remove the preset by its recorded name: +Remove the preset by its name: ```bash $$nemoclaw my-assistant policy remove my-service-api --yes ``` -Run `$$nemoclaw policy list` to see every maintained and custom preset recorded for the sandbox. +Run `$$nemoclaw policy list` to see every maintained and custom preset present in the current OpenShell policy. ## Configure a URL-Based MCP Server -Prefer the managed workflow in [Add an MCP Server](../../manage-sandboxes/mcp-servers/add-an-mcp-server) when the server uses authenticated HTTPS Streamable HTTP. -Use this custom policy recipe only for an agent-native URL registration that is outside the managed workflow. -Adding a URL such as `https://mcp.example.com/mcp` can cause a denied CONNECT tunnel. -The proxy returns `HTTP 403 Forbidden` when the target host is not in the default allowlist. -The related MCP client output contains this message: +Prefer the managed workflow in [Add an MCP Server](../../manage-sandboxes/mcp-servers/add-an-mcp-server) when the server uses authenticated HTTPS Streamable HTTP. Use this custom policy recipe only for an agent-native URL registration that is outside the managed workflow. Adding a URL such as `https://mcp.example.com/mcp` can cause a denied CONNECT tunnel. The proxy returns `HTTP 403 Forbidden` when the target host is not in the default allowlist. The related MCP client output contains this message: ```text CONNECT tunnel failed, response 403 ``` -This recipe applies only when URL-based MCP traffic uses the sandbox proxy and fails with this CONNECT response. -An OAuth MCP login failure such as `getaddrinfo EAI_AGAIN` is a different transport problem. -A direct-DNS path that bypasses the proxy is also a different problem. -Widening this allowlist does not fix either case. +This recipe applies only when URL-based MCP traffic uses the sandbox proxy and fails with this CONNECT response. An OAuth MCP login failure such as `getaddrinfo EAI_AGAIN` is a different transport problem. A direct-DNS path that bypasses the proxy is also a different problem. Widening this allowlist does not fix either case. Add the MCP host, Streamable HTTP route, required methods, and only the binary that opens the connection: @@ -202,37 +167,25 @@ network_policies: - { path: /usr/local/bin/node } ``` -Streamable HTTP clients can use `DELETE` on the same endpoint to terminate a session. -Keep that method scoped to the MCP route. -Do not replace the route with `/**` unless the server contract requires every path. +Streamable HTTP clients can use `DELETE` on the same endpoint to terminate a session. Keep that method scoped to the exact MCP route. Do not replace the route with `/**` unless the server contract requires every path. -Save the file as `nemoclaw-blueprint/policies/presets/my-mcp.yaml`. -Apply it by name: +Save the file as `nemoclaw-blueprint/policies/presets/my-mcp.yaml`. Apply it by name: ```bash $$nemoclaw my-assistant policy add my-mcp ``` -NemoClaw previews the effective egress scope and prompts for confirmation before applying it. -For a publicly routed host that passes SSRF checks, invoke the MCP tool again and confirm that the CONNECT tunnel succeeds. +NemoClaw previews the effective egress scope and prompts for confirmation before applying it. For a publicly routed host that passes SSRF checks, invoke the MCP tool again and confirm that the CONNECT tunnel succeeds. -The `binaries` list must include only the process that opens the connection. -The example assumes the Node runtime opens the MCP connection. -Replace the example path with the requesting binary that OpenShell reports in `openshell term`. -Shell-invoked clients need their own binary path, such as `/usr/bin/curl`. -Confirm a candidate path inside the sandbox: +The `binaries` list must include only the process that opens the connection. The example assumes the Node runtime opens the MCP connection. Replace the example path with the requesting binary that OpenShell reports in `openshell term`. Shell-invoked clients need their own binary path, such as `/usr/bin/curl`. Confirm a candidate path inside the sandbox: ```bash $$nemoclaw my-assistant exec -- which node ``` -A preset with an endpoint but no matching binary authorizes no process, so requests still fail. -OpenShell uses `protocol: rest` for this HTTP-based policy even though Streamable HTTP MCP carries JSON-RPC. +A preset with an endpoint but no matching binary authorizes no process, so requests still fail. OpenShell uses `protocol: rest` for this HTTP-based policy even though Streamable HTTP MCP carries JSON-RPC. -An allowlist entry does not disable OpenShell SSRF protection or create host routes. -If the hostname resolves to a private, loopback, or link-local address, establish the required host or VPN route. -Then follow the approved private-destination configuration. -Refer to [Agent cannot reach a host-side HTTP service](../../reference/troubleshooting#agent-cannot-reach-a-host-side-http-service) for routing and private-destination diagnostics. +An allowlist entry does not disable OpenShell SSRF protection or create host routes. If the hostname resolves to a private, loopback, or link-local address, establish the required host or VPN route. Then follow the approved private-destination configuration. Refer to [Agent cannot reach a host-side HTTP service](../../reference/troubleshooting#agent-cannot-reach-a-host-side-http-service) for routing and private-destination diagnostics. diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 90fc949b8a4..0b51c24bf54 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -11,22 +11,27 @@ content: skill: priority: 10 --- -Choose the policy workflow that matches the scope and persistence of the network access change. -NemoClaw declares sandbox policy in YAML, and [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) enforces it at runtime. + +Choose the policy workflow that matches the scope and persistence of the network access change. NemoClaw declares sandbox policy in YAML, and [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) enforces it at runtime. | Goal | Use this workflow | -|---|---| +| --- | --- | | Change every future sandbox for an agent | [Change the Baseline Network Policy](configure-policies/change-baseline-network-policy) | | Add or remove a maintained preset for one sandbox | [Apply Policy Presets](configure-policies/apply-policy-presets) | | Add a reviewed endpoint that no maintained preset covers | [Create Custom Policy Presets](configure-policies/create-custom-policy-presets) | + -| Allow direct TLS negotiation for an endpoint | [Configure Raw TLS Passthrough](configure-policies/configure-raw-tls-passthrough) | + | Allow direct TLS negotiation for an exact endpoint | [Configure Raw TLS + Passthrough](configure-policies/configure-raw-tls-passthrough) | -| Replace the complete live policy | [Replace the Live Network Policy](configure-policies/replace-live-network-policy) | +| Replace the complete live policy | [Replace the Live Network +Policy](configure-policies/replace-live-network-policy) | -| Give the sandbox agent a redacted policy summary | [Explain Network Policy to Agents](explain-network-policy-to-agents) | + | Give the sandbox agent a redacted policy summary | [Explain Network Policy to + Agents](explain-network-policy-to-agents) | -| Approve or deny one blocked request | [Approve or Deny Network Requests](approve-network-requests) | +| Approve or deny one blocked request | [Approve or Deny Network Requests](approve-network-requests) +| If a sandbox needs an HTTP service on the host, expose the service on a host IP that the OpenShell gateway can reach. @@ -36,11 +41,12 @@ Refer to [Agent cannot reach a host-side HTTP service](../reference/troubleshoot -Adding a host to the egress policy permits a connection only when the endpoint, port, method, and binary rules match. -OpenShell applies SSRF protection separately. -It can deny a request when the final address resolves to a loopback, private, link-local, or blocked internal range. -If a package installer or browser download still fails after you allow the public host, install the binary at build time. -Use [`$$nemoclaw onboard --from`](../reference/commands#--from-dockerfile) instead of runtime egress. + Adding a host to the egress policy permits a connection only when the endpoint, port, method, and + binary rules match. OpenShell applies SSRF protection separately. It can deny a request when the + final address resolves to a loopback, private, link-local, or blocked internal range. If a package + installer or browser download still fails after you allow the public host, install the binary at + build time. Use [`$$nemoclaw onboard --from`](../reference/commands#--from-dockerfile) instead of + runtime egress. ## Static Changes @@ -50,32 +56,34 @@ Use [`$$nemoclaw onboard --from`](../reference/commands#--from-dockerfile) inste - + -Static changes modify the policy source that NemoClaw reads during sandbox creation. -Follow [Change the Baseline Network Policy](configure-policies/change-baseline-network-policy) to edit the agent policy file, rerun onboarding, and verify the result. +Static changes modify the policy source that NemoClaw reads during sandbox creation. Follow [Change the Baseline Network Policy](configure-policies/change-baseline-network-policy) to edit the agent policy file, rerun onboarding, and verify the result. ## Dynamic Changes -Dynamic changes update a running sandbox. -Use [Apply Policy Presets](configure-policies/apply-policy-presets) for reviewed additions that NemoClaw records and replays. +Dynamic changes update the OpenShell policy for a running sandbox. Use [Apply Policy Presets](configure-policies/apply-policy-presets) for convenient reviewed additions. NemoClaw reads, modifies, writes, and verifies the live OpenShell policy without recording a second desired-policy state. + -Use [Approve or Deny Network Requests](approve-network-requests) for one-off access. -Use [Replace the Live Network Policy](configure-policies/replace-live-network-policy) only when a preset cannot express the complete change. +Use [Approve or Deny Network Requests](approve-network-requests) for one-off access. Use [Replace +the Live Network Policy](configure-policies/replace-live-network-policy) only when a preset cannot +express the complete change. ## Policy Presets -Maintained policy presets cover common integrations and package services. -Follow [Apply Policy Presets](configure-policies/apply-policy-presets) to preview, apply, reapply, list, or remove them. +Maintained policy presets cover common integrations and package services. Follow [Apply Policy Presets](configure-policies/apply-policy-presets) to preview, apply, reapply, list, or remove them. + -For guided service workflows, refer to [Common Integration Policy Examples](integration-policy-examples). + For guided service workflows, refer to [Common Integration Policy + Examples](integration-policy-examples). -Review [Network Policies](../reference/network-policies#policy-tiers) for the maintained presets available to Deep Agents. + Review [Network Policies](../reference/network-policies#policy-tiers) for the maintained presets + available to Deep Agents. ## Custom Preset Files @@ -85,13 +93,13 @@ Review [Network Policies](../reference/network-policies#policy-tiers) for the ma - + -Custom preset files add reviewed endpoint access without changing the baseline. -Follow [Create Custom Policy Presets](configure-policies/create-custom-policy-presets) to author, validate, apply, and remove a custom preset. +Custom preset files add reviewed endpoint access without changing the baseline. Follow [Create Custom Policy Presets](configure-policies/create-custom-policy-presets) to author, validate, apply, and remove a custom preset. + -That page also contains the URL-based MCP server recipe formerly located in this guide. + That page also contains the URL-based MCP server recipe formerly located in this guide. @@ -100,8 +108,7 @@ That page also contains the URL-based MCP server recipe formerly located in this -Some endpoints require direct TLS negotiation and fail through inspected L7 proxying. -Follow [Configure Raw TLS Passthrough](configure-policies/configure-raw-tls-passthrough) for the bounded `access: full` and `tls: skip` recipe. +Some endpoints require direct TLS negotiation and fail through inspected L7 proxying. Follow [Configure Raw TLS Passthrough](configure-policies/configure-raw-tls-passthrough) for the bounded `access: full` and `tls: skip` recipe. @@ -109,23 +116,24 @@ Follow [Configure Raw TLS Passthrough](configure-policies/configure-raw-tls-pass -OpenShell `policy set` replaces the complete live policy. -Follow [Replace the Live Network Policy](configure-policies/replace-live-network-policy) to export the round-trippable base, preserve existing entries, and apply a validated replacement. +OpenShell `policy set` replaces the complete live policy. Follow [Replace the Live Network Policy](configure-policies/replace-live-network-policy) to export the round-trippable base, preserve existing entries, and apply a validated replacement. ## Agent Policy Context -Agents need a redacted view of active presets and policy verification state. -Follow [Explain Network Policy to Agents](explain-network-policy-to-agents) to print or refresh that context and interpret failure classifications. +Agents need a redacted view of active presets and policy verification state. Follow [Explain Network Policy to Agents](explain-network-policy-to-agents) to print or refresh that context and interpret failure classifications. ## Related Topics -- [Common Integration Policy Examples](integration-policy-examples) provides maintained service workflows. + - [Common Integration Policy Examples](integration-policy-examples) provides maintained service + workflows. -- [Network Policies](../reference/network-policies) is the canonical policy reference. -- [OpenShell Policy Schema](https://docs.nvidia.com/openshell/latest/reference/policy-schema.html) provides the complete YAML schema. -- [OpenShell Sandbox Policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.html) explains OpenShell-layer policy iteration. +- [Network Policies](../reference/network-policies) is the canonical policy reference. - [OpenShell +Policy Schema](https://docs.nvidia.com/openshell/latest/reference/policy-schema.html) provides the +complete YAML schema. - [OpenShell Sandbox +Policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.html) explains OpenShell-layer +policy iteration. diff --git a/docs/network-policy/explain-network-policy-to-agents.mdx b/docs/network-policy/explain-network-policy-to-agents.mdx index a225323bae8..7c4020ec832 100644 --- a/docs/network-policy/explain-network-policy-to-agents.mdx +++ b/docs/network-policy/explain-network-policy-to-agents.mdx @@ -12,8 +12,8 @@ skill: priority: 10 agent-variants: ["openclaw", "hermes"] --- -Use `policy explain` to give a sandbox agent a compact, redacted view of its active network policy. -The summary helps the agent distinguish policy denials, missing credentials, unsupported capabilities, and upstream failures. + +Use `policy explain` to give a sandbox agent a compact, redacted view of its active network policy. The summary helps the agent distinguish policy denials, missing credentials, unsupported capabilities, and upstream failures. ## Print the Policy Context @@ -37,6 +37,7 @@ Refresh the file without changing policy: ```bash $$nemoclaw my-assistant policy explain --write ``` + For Hermes, use the printed Markdown or JSON through an operator-controlled prompt or file workflow. @@ -45,54 +46,37 @@ The `--write` target is the OpenClaw workspace and is not a Hermes agent-context ## Understand Redaction -The summary includes the recorded tier, applied presets, allowed host categories, known presets that are not applied, and policy management commands. -It also explains the support boundaries between NemoClaw, OpenShell, and the agent. +The summary includes the inferred tier when available, applied presets, allowed host categories, known presets that are not applied, and policy management commands. It also explains the support boundaries between NemoClaw, OpenShell, and the agent. -The output omits network rule bodies, credential metadata, and binary allowlists. -It includes only host stems and category-level summaries. -NemoClaw drops private, loopback, link-local, metadata, unique-local, reserved, CGNAT, benchmarking, and internal-suffix hosts from `allowedHostCategories`. -It reports their count in `redactedHostCount`. +The output omits network rule bodies, credential metadata, and binary allowlists. It includes only host stems and category-level summaries. NemoClaw drops private, loopback, link-local, metadata, unique-local, reserved, CGNAT, benchmarking, and internal-suffix hosts from `allowedHostCategories`. It reports their count in `redactedHostCount`. ## Interpret Verification Status Each active preset includes a `verification` value: | Status | Meaning | -|--------|---------| -| `verified` | The registry lists the preset, and the gateway confirms enforcement. | -| `registry-only` | The registry lists the preset, but the gateway does not enforce it. Treat the allowed hosts as unverified. | -| `gateway-only` | The gateway enforces a preset that the registry does not list. | -| `agent-base` | The gateway enforces this preset because it belongs to the agent's own base policy (`agents//policy-additions.yaml`), not because the operator applied it. It is active, not drift. `policy add` is unnecessary and would record the preset as operator-applied. | +| --- | --- | +| `verified` | The current OpenShell policy contains the preset content. | | `gateway-unavailable` | NemoClaw could not probe the gateway. Treat the report as advisory until the gateway is reachable. | ## Classify a Failed Request The classifier evaluates conditions in this order: -1. `unsupported` means the active agent does not offer the asserted capability. - Surface the limitation without retrying. -2. `missing-approval` with high confidence means a host on an applied preset returned HTTP 401. - The network path is open, but credentials are missing or invalid. -3. `missing-approval` with low confidence means a host on an applied preset returned HTTP 403. - Confirm credentials, then inspect the effective policy for a method, path, protocol, or binary denial. -4. `blocked-by-policy` means no applied preset allows the host or the request returned a network-block error. - Apply an applicable preset or create a custom preset. -5. `unknown` means no classification matched. - Surface the underlying error. +1. `unsupported` means the active agent does not offer the asserted capability. Surface the limitation without retrying. +2. `missing-approval` with high confidence means a host on an applied preset returned HTTP 401. The network path is open, but credentials are missing or invalid. +3. `missing-approval` with low confidence means a host on an applied preset returned HTTP 403. Confirm credentials, then inspect the effective policy for a method, path, protocol, or binary denial. +4. `blocked-by-policy` means no applied preset allows the host or the request returned a network-block error. Apply an applicable preset or create a custom preset. +5. `unknown` means no classification matched. Surface the underlying error. -Network-block error codes include `EHOSTUNREACH`, `ENETUNREACH`, `ENOTFOUND`, `ECONNREFUSED`, `ETIMEDOUT`, and `EAI_AGAIN`. -A block code on a host from a `registry-only` or `gateway-unavailable` preset produces a low-confidence policy verdict. -A block code on a host from a `verified`, `gateway-only`, or `agent-base` preset stays `unknown` with high confidence because the gateway confirmed enforcement. +Network-block error codes include `EHOSTUNREACH`, `ENETUNREACH`, `ENOTFOUND`, `ECONNREFUSED`, `ETIMEDOUT`, and `EAI_AGAIN`. A block code while the OpenShell policy is unavailable produces a low-confidence policy verdict. A block code for an entry verified in the current OpenShell policy stays `unknown` with high confidence because policy presence alone does not distinguish credential and application failures. -Each verdict includes `confidence` set to `high` or `low`. -Low confidence means the agent must report multiple possibilities instead of treating one next step as authoritative. +Each verdict includes `confidence` set to `high` or `low`. Low confidence means the agent must report multiple possibilities instead of treating one next step as authoritative. -For `blocked-by-policy`, run `$$nemoclaw policy add ` or follow [Custom Preset Files](customize-network-policy#custom-preset-files). -For `missing-approval`, confirm the API token and scopes. -For `unsupported`, surface the limitation without retrying. +For `blocked-by-policy`, run `$$nemoclaw policy add ` or follow [Custom Preset Files](customize-network-policy#custom-preset-files). For `missing-approval`, confirm the API token and scopes. For `unsupported`, surface the limitation without retrying. ## Related Topics -- [Apply Policy Presets](configure-policies/apply-policy-presets) changes the recorded preset set. +- [Apply Policy Presets](configure-policies/apply-policy-presets) changes the current OpenShell policy. - [Create Custom Policy Presets](configure-policies/create-custom-policy-presets) adds a reviewed custom destination. - [Network Policies](../reference/network-policies) explains policy enforcement and tiers. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 62928f9e58c..90c20148885 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -5,33 +5,30 @@ title: "NemoClaw CLI Commands Reference" sidebar-title: "Commands" description: "Full CLI reference for standalone NemoClaw commands and agent-specific in-sandbox commands." description-agent: "Includes the full CLI reference for standalone NemoClaw commands and agent-specific in-sandbox commands. Use when looking up a specific `$$nemoclaw`, `nemohermes`, `nemo-deepagents`, `dcode`, or `/nemoclaw` subcommand, flag, argument, or exit code." -keywords: ["nemoclaw cli commands", "nemoclaw command reference", "nemo-deepagents commands", "dcode commands"] +keywords: + [ + "nemoclaw cli commands", + "nemoclaw command reference", + "nemo-deepagents commands", + "dcode commands", + ] content: type: "reference" --- + -The `$$nemoclaw` CLI is the primary interface for managing NemoClaw sandboxes. -It is installed automatically by the installer (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash`). -For guidance on when to use `$$nemoclaw` versus the underlying `openshell` CLI, refer to [CLI Selection Guide](cli-selection-guide). +The `$$nemoclaw` CLI is the primary interface for managing NemoClaw sandboxes. It is installed automatically by the installer (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash`). For guidance on when to use `$$nemoclaw` versus the underlying `openshell` CLI, refer to [CLI Selection Guide](cli-selection-guide). -The `nemohermes` alias is the primary interface for managing Hermes sandboxes through NemoClaw. -It is installed automatically by the installer (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=hermes bash`). -Most commands in this reference use the same arguments and subcommands across agent variants. -Use `nemohermes` when you want Hermes selected by default. -For guidance on choosing between the agent CLIs and the underlying `openshell` CLI, refer to [CLI Selection Guide](cli-selection-guide). +The `nemohermes` alias is the primary interface for managing Hermes sandboxes through NemoClaw. It is installed automatically by the installer (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=hermes bash`). Most commands in this reference use the same arguments and subcommands across agent variants. Use `nemohermes` when you want Hermes selected by default. For guidance on choosing between the agent CLIs and the underlying `openshell` CLI, refer to [CLI Selection Guide](cli-selection-guide). -The `nemo-deepagents` alias is the primary interface for managing Deep Agents sandboxes through NemoClaw. -It is installed automatically by the installer (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepagents-code bash`). -Most commands in this reference use the same arguments and subcommands across agent variants. -Use `nemo-deepagents` when you want Deep Agents selected by default. -For guidance on choosing between the agent CLIs and the underlying `openshell` CLI, refer to [CLI Selection Guide](cli-selection-guide). +The `nemo-deepagents` alias is the primary interface for managing Deep Agents sandboxes through NemoClaw. It is installed automatically by the installer (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepagents-code bash`). Most commands in this reference use the same arguments and subcommands across agent variants. Use `nemo-deepagents` when you want Deep Agents selected by default. For guidance on choosing between the agent CLIs and the underlying `openshell` CLI, refer to [CLI Selection Guide](cli-selection-guide). @@ -39,18 +36,12 @@ For guidance on choosing between the agent CLIs and the underlying `openshell` C -Use `$$nemoclaw` for the OpenClaw variant. -OpenClaw is the default agent for `$$nemoclaw onboard` unless you select another installed agent with `--agent ` or set `NEMOCLAW_AGENT=`. -Run `$$nemoclaw agents list` to see the installed agent names; for example, use `hermes` for Hermes or `langchain-deepagents-code` for LangChain Deep Agents Code. -OpenClaw-specific sections below describe the `/nemoclaw` slash command, the OpenClaw dashboard URL, the OpenClaw gateway token, and OpenClaw config paths under `/sandbox/.openclaw`. +Use `$$nemoclaw` for the OpenClaw variant. OpenClaw is the default agent for `$$nemoclaw onboard` unless you select another installed agent with `--agent ` or set `NEMOCLAW_AGENT=`. Run `$$nemoclaw agents list` to see the installed agent names; for example, use `hermes` for Hermes or `langchain-deepagents-code` for LangChain Deep Agents Code. OpenClaw-specific sections below describe the `/nemoclaw` slash command, the OpenClaw dashboard URL, the OpenClaw gateway token, and OpenClaw config paths under `/sandbox/.openclaw`. -Use `nemohermes` for the Hermes variant. -It selects Hermes by default during onboarding and for other commands. -Use `--agent hermes` during onboarding or set `NEMOCLAW_AGENT=hermes` when you need the same selection through another entry point. -Hermes-specific sections below describe the built-in Hermes dashboard, the separate OpenAI-compatible API endpoint, Hermes config under `/sandbox/.hermes`, and provider updates that patch `config.yaml`. +Use `nemohermes` for the Hermes variant. It selects Hermes by default during onboarding and for other commands. Use `--agent hermes` during onboarding or set `NEMOCLAW_AGENT=hermes` when you need the same selection through another entry point. Hermes-specific sections below describe the built-in Hermes dashboard, the separate OpenAI-compatible API endpoint, Hermes config under `/sandbox/.hermes`, and provider updates that patch `config.yaml`. ```bash nemohermes onboard # selects Hermes by default @@ -60,10 +51,7 @@ nemohermes my-sandbox connect # connects to a Hermes sandbox -Use `nemo-deepagents` for the Deep Agents variant. -It selects `langchain-deepagents-code` by default during onboarding and for other commands. -Use `--agent langchain-deepagents-code`, `--agent dcode`, or `NEMOCLAW_AGENT=langchain-deepagents-code` when you need the same selection through another entry point. -Deep Agents-specific sections below describe the `dcode` terminal runtime, managed `/sandbox/.deepagents` config, and commands that launch the interactive TUI or headless runner. +Use `nemo-deepagents` for the Deep Agents variant. It selects `langchain-deepagents-code` by default during onboarding and for other commands. Use `--agent langchain-deepagents-code`, `--agent dcode`, or `NEMOCLAW_AGENT=langchain-deepagents-code` when you need the same selection through another entry point. Deep Agents-specific sections below describe the `dcode` terminal runtime, managed `/sandbox/.deepagents` config, and commands that launch the interactive TUI or headless runner. ```bash nemo-deepagents onboard # selects Deep Agents by default @@ -79,7 +67,7 @@ nemo-deepagents my-sandbox connect # connects to a Deep Agents sandbox The `/nemoclaw` slash command is available inside the OpenClaw chat interface for quick actions: | Subcommand | Description | -|---|---| +| --- | --- | | `/nemoclaw` | Show slash-command help and host CLI pointers | | `/nemoclaw status` | Show sandbox and inference state | | `/nemoclaw shields [status]` | Explain that shields status is unavailable inside the sandbox and point to `$$nemoclaw shields status` on the host | @@ -91,17 +79,12 @@ Use host-side `$$nemoclaw shields status|up|down` commands to inspect -Hermes does not use the OpenClaw chat slash command. -Use the host-side `nemohermes` commands for lifecycle, status, policy, and inference operations. -The in-sandbox Hermes integration installs the NemoClaw Hermes plugin, which exposes tools for status, environment information, and skill reload support, plus an `on_session_start` hook. +Hermes does not use the OpenClaw chat slash command. Use the host-side `nemohermes` commands for lifecycle, status, policy, and inference operations. The in-sandbox Hermes integration installs the NemoClaw Hermes plugin, which exposes tools for status, environment information, and skill reload support, plus an `on_session_start` hook. -Deep Agents does not use the OpenClaw chat slash command. -Use the host-side `nemo-deepagents` commands for lifecycle, status, policy, and inference operations. -Inside the sandbox, use `dcode` for the interactive TUI and `dcode -n` for explicit headless automation. -Add `--json` when automation needs the managed, versioned result envelope. +Deep Agents does not use the OpenClaw chat slash command. Use the host-side `nemo-deepagents` commands for lifecycle, status, policy, and inference operations. Inside the sandbox, use `dcode` for the interactive TUI and `dcode -n` for explicit headless automation. Add `--json` when automation needs the managed, versioned result envelope. ```bash dcode @@ -111,22 +94,17 @@ dcode status printf '%s\n' '{"worker":"worker-17"}' | dcode tools call-read-only worker_task_context --json ``` -`dcode tools call-read-only TOOL --json` invokes one coherently read-only managed MCP tool without model participation. -It requires one JSON object on standard input and writes one bounded JSON result envelope. -For the JSON schema, status and exit behavior, output limit, and read-only MCP call requirements, refer to [Run Deep Agents Code](/user-guide/deepagents/manage-sandboxes/operate-sandboxes/run-deep-agents-code). +`dcode tools call-read-only TOOL --json` invokes one exact, coherently read-only managed MCP tool without model participation. It requires one JSON object on standard input and writes one bounded JSON result envelope. For the JSON schema, status and exit behavior, output limit, and read-only MCP call requirements, refer to [Run Deep Agents Code](/user-guide/deepagents/manage-sandboxes/operate-sandboxes/run-deep-agents-code). ## Hosted Installer Options -The hosted installer accepts options after `bash -s --`. -These options control installation and the onboarding run that follows it. +The hosted installer accepts options after `bash -s --`. These options control installation and the onboarding run that follows it. ### `--local-model-runtime=vllm` -Enable the fixed vLLM local model profile. -The flag accepts only `vllm`. -It makes the remaining onboarding non-interactive and disables Express profile selection. +Enable the fixed vLLM local model profile. The flag accepts only `vllm`. It makes the remaining onboarding non-interactive and disables Express profile selection. ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ @@ -144,15 +122,9 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ bash -s -- --local-model-runtime=vllm ``` -The profile selects a fixed catalog model and serving command from the managed-inference catalog. -The hosted installer rejects `NEMOCLAW_PROVIDER` and `NEMOCLAW_MODEL` before onboarding. -The dedicated vLLM onboarder accepts `NEMOCLAW_VLLM_MODEL` only when the catalog resolves it to the matching fixed recipe. -It rejects a model that does not resolve to that recipe and all `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` values before it installs vLLM. -Set `NEMOCLAW_VLLM_PORT` before installation to publish the fixed serving recipe on another host port. +The profile selects a fixed catalog model and serving command from the managed-inference catalog. The hosted installer rejects `NEMOCLAW_PROVIDER` and `NEMOCLAW_MODEL` before onboarding. The dedicated vLLM onboarder accepts `NEMOCLAW_VLLM_MODEL` only when the catalog resolves it to the matching fixed recipe. It rejects a model that does not resolve to that recipe and all `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` values before it installs vLLM. Set `NEMOCLAW_VLLM_PORT` before installation to publish the fixed serving recipe on another host port. -The hosted installer's equivalent environment-variable form requires both `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1` and `NEMOCLAW_LOCAL_MODEL_RUNTIME`. -Use the installer flag unless an automation boundary cannot pass installer arguments. -For prerequisites, effects, verification, and recovery, refer to [Choose a Local Inference Server](../inference/local-inference/choose-local-inference-server#install-a-fixed-vllm-profile). +The hosted installer's equivalent environment-variable form requires both `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1` and `NEMOCLAW_LOCAL_MODEL_RUNTIME`. Use the installer flag unless an automation boundary cannot pass installer arguments. For prerequisites, effects, verification, and recovery, refer to [Choose a Local Inference Server](../inference/local-inference/choose-local-inference-server#install-a-fixed-vllm-profile). ### `--defer-onboarding` @@ -178,12 +150,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ ## Hosted Installer Exit Statuses -The hosted installer reports how a run stopped through its exit status. -When you interrupt it at a prompt, it exits `130`, the same status that `$$nemoclaw onboard` reports for that interrupt. -An interrupted onboarding run still prints `[ERROR] Onboarding did not complete successfully.` before it exits, so read the exit status rather than that line. -The installer preserves no other signal status, and a progress step stopped by `SIGTERM` also exits `130`, so a script that stops the installer itself cannot read `130` as a deliberate interrupt. -DGX Station host preparation exits `10` when it requires a reboot and `11` when it requires a new login session, and prints the command that resumes the install. -Treat every other non-zero status as a failure. +The hosted installer reports how a run stopped through its exit status. When you interrupt it at a prompt, it exits `130`, the same status that `$$nemoclaw onboard` reports for that interrupt. An interrupted onboarding run still prints `[ERROR] Onboarding did not complete successfully.` before it exits, so read the exit status rather than that line. The installer preserves no other signal status, and a progress step stopped by `SIGTERM` also exits `130`, so a script that stops the installer itself cannot read `130` as a deliberate interrupt. DGX Station host preparation exits `10` when it requires a reboot and `11` when it requires a new login session, and prints the command that resumes the install. Treat every other non-zero status as a failure. ## Standalone Host Commands @@ -191,8 +158,7 @@ The CLI handles host-side operations that run outside the selected agent runtime ### `$$nemoclaw help`, `$$nemoclaw --help`, `$$nemoclaw -h` -Show the top-level usage summary and command groups. -Running `$$nemoclaw` with no arguments shows the same help output. +Show the top-level usage summary and command groups. Running `$$nemoclaw` with no arguments shows the same help output. ```bash $$nemoclaw help @@ -208,11 +174,7 @@ $$nemoclaw --version ### `$$nemoclaw completion` -Generate a tab-completion script for Bash, Zsh, or Fish from the commands and flags available in the installed CLI. -The script completes public global commands, the sandbox-first `$$nemoclaw ...` grammar, flags, shell choices, and locally registered sandbox names. -If you omit the shell name, `$$nemoclaw completion` detects the target from `$SHELL` and defaults to Bash when it cannot identify Zsh or Fish. -The generated script is bound to the CLI name that created it, so install a separate script for each CLI alias you use. -It loads sandbox names from the local registry the first time completion runs and caches them for the rest of that shell session. +Generate a tab-completion script for Bash, Zsh, or Fish from the commands and flags available in the installed CLI. The script completes public global commands, the sandbox-first `$$nemoclaw ...` grammar, flags, shell choices, and locally registered sandbox names. If you omit the shell name, `$$nemoclaw completion` detects the target from `$SHELL` and defaults to Bash when it cannot identify Zsh or Fish. The generated script is bound to the CLI name that created it, so install a separate script for each CLI alias you use. It loads sandbox names from the local registry the first time completion runs and caches them for the rest of that shell session. For Bash, source the generated script and add the same line to `~/.bashrc` for future sessions. @@ -237,8 +199,7 @@ Start a new shell session to refresh the cached sandbox names after creating or ### `$$nemoclaw resources` -Display host hardware inventory and configured sandbox resource profiles. -Use `--json` for machine-readable CPU, memory, GPU, Kubernetes allocatable-capacity, and profile data. +Display host hardware inventory and configured sandbox resource profiles. Use `--json` for machine-readable CPU, memory, GPU, Kubernetes allocatable-capacity, and profile data. ```bash $$nemoclaw resources [--json] @@ -248,9 +209,7 @@ If the gateway is not running, Kubernetes allocatable fields are omitted and hos ### `$$nemoclaw host probe` -Inspect host capabilities and gateway authority before onboarding without changing host, Docker, gateway, credential, policy, or sandbox state. -Use `--json` for the schema-versioned report. -The command exits with `0` for `supported`, `2` for `incompatible`, and `3` for `inconclusive`. +Inspect host capabilities and gateway authority before onboarding without changing host, Docker, gateway, credential, policy, or sandbox state. Use `--json` for the schema-versioned report. The command exits with `0` for `supported`, `2` for `incompatible`, and `3` for `inconclusive`. ```bash $$nemoclaw host probe [--json] @@ -260,9 +219,7 @@ For capability IDs, evidence bounds, and compatibility guidance, refer to [Syste ### `$$nemoclaw agents list` -List the installed agent runtimes that can be selected with `$$nemoclaw onboard --agent `. -Use this global command when you need valid runtime names before creating or recreating a sandbox. -It lists runtime names with the descriptions from their installed manifests. +List the installed agent runtimes that can be selected with `$$nemoclaw onboard --agent `. Use this global command when you need valid runtime names before creating or recreating a sandbox. It lists runtime names with the descriptions from their installed manifests. ```bash $$nemoclaw agents list @@ -278,9 +235,7 @@ langchain-deepagents-code Terminal coding agent built on the Deep Agents SDK ### `$$nemoclaw profiles list` -List the serving profiles installed with NemoClaw and evaluate them against the current host. -The command reports each profile's stable ID, display name, inference backend, model, topology, selection mode, support state, estimated downloads, and incompatibility reason. -It reads the serving catalog and host readiness state without downloading a model or changing host, gateway, inference, or sandbox resources. +List the serving profiles installed with NemoClaw and evaluate them against the current host. The command reports each profile's stable ID, display name, inference backend, model, topology, selection mode, support state, estimated downloads, and incompatibility reason. It reads the serving catalog and host readiness state without downloading a model or changing host, gateway, inference, or sandbox resources. ```bash $$nemoclaw profiles list @@ -292,14 +247,11 @@ Use `--json` for machine-readable output with the same profile fields. $$nemoclaw profiles list --json ``` -Use the stable `id` value with `$$nemoclaw onboard --profile `. -Display names are accepted when they identify exactly one profile, but stable IDs are suitable for scripts and automation. +Use the stable `id` value with `$$nemoclaw onboard --profile `. Display names are accepted when they identify exactly one profile, but stable IDs are suitable for scripts and automation. ### `$$nemoclaw onboard` -Run the interactive setup wizard (recommended for new installs). -The wizard creates an OpenShell gateway, registers inference providers, selects the managed image (or builds an explicit custom Dockerfile), and creates the sandbox. -Use this command for new installs and for recreating a sandbox after changes to policy or configuration. +Run the interactive setup wizard (recommended for new installs). The wizard creates an OpenShell gateway, registers inference providers, selects the exact managed image (or builds an explicit custom Dockerfile), and creates the sandbox. Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```bash $$nemoclaw onboard [--profile ] [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--apf-interceptor] [--gpu | --no-gpu] [--from ] [--name ] [--host-mount ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--vllm-gpu-device ] [--agent ] [--agents ] [--tool-disclosure ] [--observability | --no-observability] [--control-ui-port ] [--events=jsonl] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] @@ -314,12 +266,7 @@ nemohermes onboard [options] nemoclaw onboard --agent hermes [options] ``` -The experimental Portable Hermes path records `pending`, `configuring`, and `active` lifecycle phases under the current user's rootless Podman authority. -If onboarding stops in `pending` or `configuring`, correct the reported condition and run `nemohermes onboard --experimental-profile portable --resume`. -Mutation, connection, and recovery commands do not act on an incomplete receipt; `status` and `doctor` report its phase. -After the receipt reaches `active`, the supported runtime actions are `launch`, `connect`, `recover`, `start`, and `stop`; `status` and read-only `doctor` provide diagnostics. -Other sandbox commands fail before their action runs while the Portable Hermes receipt exists. -The supported commands revalidate the receipt-owned Podman container and do not fall back to Docker. +The experimental Portable Hermes path records `pending`, `configuring`, and `active` lifecycle phases under the current user's rootless Podman authority. If onboarding stops in `pending` or `configuring`, correct the reported condition and run `nemohermes onboard --experimental-profile portable --resume`. Mutation, connection, and recovery commands do not act on an incomplete receipt; `status` and `doctor` report its phase. After the receipt reaches `active`, the supported runtime actions are `launch`, `connect`, `recover`, `start`, and `stop`; `status` and read-only `doctor` provide diagnostics. Other sandbox commands fail before their action runs while the Portable Hermes receipt exists. The supported commands revalidate the receipt-owned Podman container and do not fall back to Docker. @@ -333,124 +280,80 @@ nemoclaw onboard --agent langchain-deepagents-code [options] -`--agent` accepts the canonical manifest names from `$$nemoclaw agents list` plus common aliases. -For example, `nemohermes` resolves to `hermes`, while `dcode`, `deepagents`, `deepagents-code`, and `langchain` resolve to `langchain-deepagents-code`. +`--agent` accepts the canonical manifest names from `$$nemoclaw agents list` plus common aliases. For example, `nemohermes` resolves to `hermes`, while `dcode`, `deepagents`, `deepagents-code`, and `langchain` resolve to `langchain-deepagents-code`. #### `--profile ` -Select one serving profile from `$$nemoclaw profiles list` for interactive or non-interactive onboarding. -The flag is generic and does not add a model-specific command or flag. -NemoClaw maps a unique display name to its stable catalog ID and passes that ID to the managed inference path. +Select one serving profile from `$$nemoclaw profiles list` for interactive or non-interactive onboarding. The flag is generic and does not add a model-specific command or flag. NemoClaw maps a unique display name to its stable catalog ID and passes that ID to the managed inference path. ```bash $$nemoclaw onboard --profile ``` -NemoClaw rejects an unknown, ambiguous, disabled, or incompatible profile before image or model downloads begin. -It also rejects `--profile` when you combine it with `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_MANAGED_CLUSTER_PEERS`, or `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` overrides. -If `NEMOCLAW_SERVING_PRESET` is already set, it must select the same stable profile ID; a different ID conflicts with `--profile`. -Run `$$nemoclaw profiles list` to inspect an incompatibility reason before onboarding. +NemoClaw rejects an unknown, ambiguous, disabled, or incompatible profile before image or model downloads begin. It also rejects `--profile` when you combine it with `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_MANAGED_CLUSTER_PEERS`, or `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` overrides. If `NEMOCLAW_SERVING_PRESET` is already set, it must select the same stable profile ID; a different ID conflicts with `--profile`. Run `$$nemoclaw profiles list` to inspect an incompatibility reason before onboarding. -If you omit `--profile`, onboarding uses the same provider and model defaults as an installation without this feature. -The onboarding review screen identifies the resolved profile, recipe, declared model, served model alias, runtime image, support state, and download estimates before confirmation. -When onboarding reuses a running vLLM server, its `/v1/models` response must match the requested profile's served alias or declared model root. -Otherwise, onboarding stops before it records a route that the profile does not declare. -After creation, human status shows the profile, recipe, and catalog digest; JSON status includes the complete secret-free `servingProfileProvenance` record for diagnostics and automation. +If you omit `--profile`, onboarding uses the same provider and model defaults as an installation without this feature. The onboarding review screen identifies the resolved profile, recipe, declared model, served model alias, runtime image, support state, and download estimates before confirmation. When onboarding reuses a running vLLM server, its `/v1/models` response must match the requested profile's served alias or declared model root. Otherwise, onboarding stops before it records a route that the profile does not declare. After creation, human status shows the profile, recipe, and catalog digest; JSON status includes the complete secret-free `servingProfileProvenance` record for diagnostics and automation. #### `--host-mount` -On Linux and Windows Subsystem for Linux 2 (WSL2), repeat `--host-mount ` to expose existing host directories read-only inside the sandbox. -The option requires a NemoClaw-managed Docker-driver gateway and does not provide a read-write mode. -Refer to [Mount a Host Directory for Read-Only Access](../manage-sandboxes/state-and-backups/understand-sandbox-state#mount-a-host-directory-for-read-only-access) for validation rules, security considerations, persistence, and verification. +On Linux and Windows Subsystem for Linux 2 (WSL2), repeat `--host-mount ` to expose existing host directories read-only inside the sandbox. The option requires a NemoClaw-managed Docker-driver gateway and does not provide a read-write mode. Refer to [Mount a Host Directory for Read-Only Access](../manage-sandboxes/state-and-backups/understand-sandbox-state#mount-a-host-directory-for-read-only-access) for validation rules, security considerations, persistence, and verification. #### `--events=jsonl` -Emit a read-only stream of canonical onboarding FSM events as JSON Lines on stdout. -Each line is one JSON object with the version 1 envelope: +Emit a read-only stream of canonical onboarding FSM events as JSON Lines on stdout. Each line is one JSON object with the version 1 envelope: ```json -{"schemaVersion":1,"session":"","type":"state.entered","timestamp":"2026-07-13T12:34:56.789Z","payload":{"state":"inference","step":"inference","context":{"agent":"openclaw","sandboxName":"alpha","provider":"nvidia-prod","model":"nvidia/test-model","endpointOrigin":"https://integrate.api.nvidia.com","credentialEnv":"NVIDIA_API_KEY"},"error":null,"metadata":{}}} +{ + "schemaVersion": 1, + "session": "", + "type": "state.entered", + "timestamp": "2026-07-13T12:34:56.789Z", + "payload": { + "state": "inference", + "step": "inference", + "context": { + "agent": "openclaw", + "sandboxName": "alpha", + "provider": "nvidia-prod", + "model": "nvidia/test-model", + "endpointOrigin": "https://integrate.api.nvidia.com", + "credentialEnv": "NVIDIA_API_KEY" + }, + "error": null, + "metadata": {} + } +} ``` -In this mode, human progress remains available on stderr so stdout stays valid JSONL. -Payloads contain only the existing bounded, redacted machine-event context: credential environment variable names may appear, but credential values and secret-bearing URL components are redacted. -For a `compatible-endpoint` route that uses `openai-completions`, the context includes `reasoningEffort` as `low`, `medium`, `high`, or `endpoint-default`. -Other provider and API-family routes omit this field. -Treat new event `type` values and new payload fields as additive changes. -A breaking envelope or field-semantics change increments `schemaVersion`. +In this mode, human progress remains available on stderr so stdout stays valid JSONL. Payloads contain only the existing bounded, redacted machine-event context: credential environment variable names may appear, but credential values and secret-bearing URL components are redacted. For a `compatible-endpoint` route that uses `openai-completions`, the context includes `reasoningEffort` as `low`, `medium`, `high`, or `endpoint-default`. Other provider and API-family routes omit this field. Treat new event `type` values and new payload fields as additive changes. A breaking envelope or field-semantics change increments `schemaVersion`. -This surface observes the canonical onboarding session and does not accept input, cancel onboarding, or create another state machine. -It does not provide event history, reconnect, or replay; use the existing `--resume` behavior after an interrupted onboarding process. -Closing the output pipe or applying sustained backpressure disables observation without cancelling, rolling back, or otherwise changing onboarding. -Without `--events=jsonl`, terminal output and behavior are unchanged. +This surface observes the canonical onboarding session and does not accept input, cancel onboarding, or create another state machine. It does not provide event history, reconnect, or replay; use the existing `--resume` behavior after an interrupted onboarding process. Closing the output pipe or applying sustained backpressure disables observation without cancelling, rolling back, or otherwise changing onboarding. Without `--events=jsonl`, terminal output and behavior are unchanged. #### `--resume` and `--fresh` -NemoClaw records onboarding progress so interrupted runs can continue. -Use `--resume` to continue a resumable onboarding session with the provider, model, sandbox name, agent, observability choice, custom Dockerfile path, read-only host-mount declarations, and any explicitly selected serving-profile provenance recorded by the original run. -For a profile-backed session, resume requires the same catalog, preset, and recipe digests and exits before effects if the installed definition changed. -Omit `--profile` to reuse that recorded selection, or pass the same profile explicitly; use `--fresh` to adopt a changed catalog definition. -Sessions without a serving-profile provenance record can resume when their checkpoint uses schema 4, but they cannot acquire a new `--profile` selection during resume. - -Checkpoint schema 4 records whether onboarding uses the default profile or the portable experimental profile. -For the portable profile, it also records the current user's canonical home reported by the operating system, that home's `.config` directory, the runtime root, rootless Podman endpoint path, and runtime ownership. -It does not record ambient Docker or Podman runtime selector values. -The runtime authority record contains no credentials. -A plain `--resume` restores the recorded profile. -You can also run `$$nemoclaw onboard --experimental-profile portable --resume` when the recorded profile is portable. -NemoClaw rejects an explicit profile that conflicts with the checkpoint before it changes portable configuration, activates the user-scoped Podman socket, or changes gateway and sandbox resources. - -Portable resume derives `DOCKER_HOST`, `CONTAINERS_CONF`, and `NETAVARK_FW` again while it holds the onboarding lock. -It ignores ambient Docker and Podman runtime selectors during that derivation. -NemoClaw scopes the derived values to onboarding and restores the process environment after success or failure. -It verifies the current user, canonical roots, socket path and ownership, Podman identity and version, and required configuration before a resumed onboarding step changes resources. -Resume stops before writes or activation if an existing socket or configuration path is a symlink, has the wrong owner, or has an unsafe type or mode. -NemoClaw can create missing descendants beneath a validated current-user root and reconcile content drift in its own portable configuration files. -A missing user-scoped socket after a host reboot can be activated and verified at the recorded path. -A new socket inode or a supported Podman upgrade does not invalidate the checkpoint. -Portable onboarding always uses the `.config` directory beneath the canonical home reported by the operating system. -`HOME` and `XDG_CONFIG_HOME` never select or override this authority. -NemoClaw ignores ambient `XDG_CONFIG_HOME` during onboarding and restores its prior presence and value afterward. -Resume rejects a checkpoint that records another configuration root. -It also rejects stored authority or filesystem ownership drift without falling back to Docker. +NemoClaw records onboarding progress so interrupted runs can continue. Use `--resume` to continue a resumable onboarding session with the provider, model, sandbox name, agent, observability choice, custom Dockerfile path, read-only host-mount declarations, and any explicitly selected serving-profile provenance recorded by the original run. For a profile-backed session, resume requires the same catalog, preset, and recipe digests and exits before effects if the installed definition changed. Omit `--profile` to reuse that recorded selection, or pass the same profile explicitly; use `--fresh` to adopt a changed catalog definition. Sessions without a serving-profile provenance record can resume when their checkpoint uses schema 4, but they cannot acquire a new `--profile` selection during resume. + +Checkpoint schema 4 records whether onboarding uses the default profile or the portable experimental profile. For the portable profile, it also records the current user's canonical home reported by the operating system, that home's exact `.config` directory, the runtime root, rootless Podman endpoint path, and runtime ownership. It does not record ambient Docker or Podman runtime selector values. The runtime authority record contains no credentials. A plain `--resume` restores the recorded profile. You can also run `$$nemoclaw onboard --experimental-profile portable --resume` when the recorded profile is portable. NemoClaw rejects an explicit profile that conflicts with the checkpoint before it changes portable configuration, activates the user-scoped Podman socket, or changes gateway and sandbox resources. + +Portable resume derives `DOCKER_HOST`, `CONTAINERS_CONF`, and `NETAVARK_FW` again while it holds the onboarding lock. It ignores ambient Docker and Podman runtime selectors during that derivation. NemoClaw scopes the derived values to onboarding and restores the process environment after success or failure. It verifies the current user, canonical roots, socket path and ownership, Podman identity and version, and required configuration before a resumed onboarding step changes resources. Resume stops before writes or activation if an existing socket or configuration path is a symlink, has the wrong owner, or has an unsafe type or mode. NemoClaw can create missing descendants beneath a validated current-user root and reconcile content drift in its own portable configuration files. A missing user-scoped socket after a host reboot can be activated and verified at the recorded path. A new socket inode or a supported Podman upgrade does not invalidate the checkpoint. Portable onboarding always uses the `.config` directory beneath the canonical home reported by the operating system. `HOME` and `XDG_CONFIG_HOME` never select or override this authority. NemoClaw ignores ambient `XDG_CONFIG_HOME` during onboarding and restores its exact prior presence and value afterward. Resume rejects a checkpoint that records another configuration root. It also rejects stored authority or filesystem ownership drift without falling back to Docker. -OpenClaw onboarding does not enter the `complete` state until NemoClaw proves that the local CLI operator pairing is settled. -Ordinary onboarding first observes the canonical local CLI device. -If the device is pairing-only, the host runs one bounded request producer on the owning gateway. -The in-sandbox watcher is the only component that approves the ordinary onboarding upgrade. -The host observes through any same-device pending state and verifies the final settled state without sending an approval. -Portable onboarding accepts one exact already-pending canonical write upgrade, avoids a duplicate producer, and still requires strict same-device settlement before completion. -The paired device must have exactly the `operator.pairing` and `operator.write` scopes. -Any pairing request considered during bounded repair must request exactly those scopes. -The active token and client authorization must have exactly the `operator.pairing`, `operator.read`, and `operator.write` scopes. -NemoClaw rejects every extra, missing, unknown, malformed, or ambiguous scope or identity shape. -If the policy preset step is incomplete, NemoClaw performs no pairing request or approval writes and publishes no launch-readiness evidence. -Selected Portable onboarding also stops when its lifecycle receipt is missing, invalid, legacy, or incompatible. -A failed check leaves onboarding incomplete and tells you to resume or rerun onboarding. +OpenClaw onboarding does not enter the `complete` state until NemoClaw proves that the local CLI operator pairing is settled. Ordinary onboarding first observes the canonical local CLI device. If the device is pairing-only, the host runs one bounded request producer on the owning gateway. The in-sandbox watcher is the only component that approves the ordinary onboarding upgrade. The host observes through any same-device pending state and verifies the final settled state without sending an approval. Portable onboarding accepts one exact already-pending canonical write upgrade, avoids a duplicate producer, and still requires strict same-device settlement before completion. The paired device must have exactly the `operator.pairing` and `operator.write` scopes. Any pairing request considered during bounded repair must request exactly those scopes. The active token and client authorization must have exactly the `operator.pairing`, `operator.read`, and `operator.write` scopes. NemoClaw rejects every extra, missing, unknown, malformed, or ambiguous scope or identity shape. If the policy preset step is incomplete, NemoClaw performs no pairing request or approval writes and publishes no launch-readiness evidence. Selected Portable onboarding also stops when its lifecycle receipt is missing, invalid, legacy, or incompatible. A failed check leaves onboarding incomplete and tells you to resume or rerun onboarding. -An active onboarding session with checkpoint schema 1, 2, or 3 cannot resume because those schemas did not record the default or portable profile authority. -NemoClaw preserves the older session and exits before portable configuration, socket activation, or resource changes. -Run `$$nemoclaw onboard --fresh` to discard the active session and start fresh onboarding. -If you intend to use the portable experimental profile, run `$$nemoclaw onboard --experimental-profile portable --fresh`. -This compatibility restriction does not prevent NemoClaw from reading a completed older session during status inspection. + An active onboarding session with checkpoint schema 1, 2, or 3 cannot resume because those schemas + did not record the default or portable profile authority. NemoClaw preserves the older session and + exits before portable configuration, socket activation, or resource changes. Run `$$nemoclaw + onboard --fresh` to discard the active session and start fresh onboarding. If you intend to use + the portable experimental profile, run `$$nemoclaw onboard --experimental-profile portable + --fresh`. This compatibility restriction does not prevent NemoClaw from reading a completed older + session during status inspection. -Before the configuration review, NemoClaw records the sandbox name and the selected provider and model as an incomplete choice. -If onboarding stops at the review prompt, an interactive `--resume` run shows the prompt again. -A non-interactive `--resume` run reuses the recorded choice and continues to inference setup. -After you choose **Apply configuration**, NemoClaw records the choice before inference setup starts. -If inference setup fails, `--resume` reuses the accepted provider, model, and sandbox name. -If you choose **Exit onboarding**, onboarding exits with a nonzero status and clears those recorded choices. -Run `$$nemoclaw onboard` to make new choices after exit. -During a resume without terminal input, `--yes` or `NEMOCLAW_YES=1` also selects non-interactive resume behavior. -For a new or fresh session, `--yes` and `NEMOCLAW_YES=1` accept supported confirmations but do not replace `--non-interactive`. -If onboarding returns without reaching the final `complete` state, the command exits with status `1`. -When that result is resumable, NemoClaw keeps the session `in_progress` at its last checkpoint instead of marking it failed, so correct the reported condition and run `$$nemoclaw onboard --resume`. +Before the configuration review, NemoClaw records the sandbox name and the selected provider and model as an incomplete choice. If onboarding stops at the review prompt, an interactive `--resume` run shows the prompt again. A non-interactive `--resume` run reuses the recorded choice and continues to inference setup. After you choose **Apply configuration**, NemoClaw records the choice before inference setup starts. If inference setup fails, `--resume` reuses the accepted provider, model, and sandbox name. If you choose **Exit onboarding**, onboarding exits with a nonzero status and clears those recorded choices. Run `$$nemoclaw onboard` to make new choices after exit. During a resume without terminal input, `--yes` or `NEMOCLAW_YES=1` also selects non-interactive resume behavior. For a new or fresh session, `--yes` and `NEMOCLAW_YES=1` accept supported confirmations but do not replace `--non-interactive`. If onboarding returns without reaching the final `complete` state, the command exits with status `1`. When that result is resumable, NemoClaw keeps the session `in_progress` at its last checkpoint instead of marking it failed, so correct the reported condition and run `$$nemoclaw onboard --resume`. If onboarding cannot complete after sandbox creation, NemoClaw preserves the sandbox and records its create-attempt label. When available, NemoClaw also records a durable identity fingerprint and verified policy evidence for recovery. @@ -466,44 +369,21 @@ $$nemoclaw onboard --fresh --name -OpenClaw sessions also record the web search selection, messaging selection and non-secret settings, and resource profile. -When the saved session includes prompt checkpoints, resume skips each completed group and continues at the first incomplete choice. -Legacy sessions without those checkpoints may repeat choices whose completion cannot be proven. -Raw web search and messaging credentials are never written to the onboarding session. -Resume skips their secret prompts when the same session recorded a successful OpenShell provider registration and OpenShell still reports the recorded name and type and the same credential-key set. -If the session lacks that registration receipt, the provider is missing, or its binding does not match, interactive resume requests the credential again; non-interactive resume preserves the completed choice, reports the required environment variable, and exits so you can export it before retrying. +OpenClaw sessions also record the web search selection, messaging selection and non-secret settings, and resource profile. When the saved session includes prompt checkpoints, resume skips each completed group and continues at the first incomplete choice. Legacy sessions without those checkpoints may repeat choices whose completion cannot be proven. Raw web search and messaging credentials are never written to the onboarding session. Resume skips their secret prompts when the same session recorded a successful OpenShell provider registration and OpenShell still reports the exact expected name, type, and credential keys. If the session lacks that registration receipt, the provider is missing, or its binding does not match, interactive resume requests the credential again; non-interactive resume preserves the completed choice, reports the required environment variable, and exits so you can export it before retrying. -Completed onboarding sessions are not resumable. -Use `--resume` only for resumable interrupted or failed sessions, not to change provider, model, agent, or sandbox recreation settings after onboarding has completed. -During resume, NemoClaw reruns preflight, gateway, provider, and sandbox repair checks even when the saved session has already reached a later nonterminal onboarding phase. -If the recorded session conflicts with flags you pass on the recovery run, NemoClaw exits and tells you to either rerun with the original settings or start over. +Completed onboarding sessions are not resumable. Use `--resume` only for resumable interrupted or failed sessions, not to change provider, model, agent, or sandbox recreation settings after onboarding has completed. During resume, NemoClaw reruns preflight, gateway, provider, and sandbox repair checks even when the saved session has already reached a later nonterminal onboarding phase. If the recorded session conflicts with flags you pass on the recovery run, NemoClaw exits and tells you to either rerun with the original settings or start over. -An active same-name replacement is separate from ordinary onboarding-step resume. -If onboarding printed `Journaled replacement` before it stopped, rerun the original onboarding command with the same target settings. -The replacement can continue without an explicit `--resume` flag. -Refer to [Continue an Interrupted Replacement](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes#continue-an-interrupted-replacement) for the identity checks and failure conditions. +An active same-name replacement is separate from ordinary onboarding-step resume. If onboarding printed `Journaled replacement` before it stopped, rerun the original onboarding command with the same target settings. The replacement can continue without an explicit `--resume` flag. Refer to [Continue an Interrupted Replacement](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes#continue-an-interrupted-replacement) for the identity checks and failure conditions. -Use `--fresh` to discard the saved onboarding session and start the wizard from the beginning. -This clears stale or failed session state before NemoClaw creates a new session record. -It also bypasses locally recorded sandbox base-image resolution metadata and reruns normal candidate resolution. -`--fresh` takes precedence over a base-image hint carried from a rebuild, so NemoClaw does not use that recorded hint. -The installer also accepts `--fresh` and forwards it to `$$nemoclaw onboard`, which skips automatic resume detection. -`--resume` and `--fresh` are mutually exclusive. -For an existing completed sandbox, use `--fresh --name --recreate-sandbox` when you intentionally want onboarding to replace that sandbox with a new provider, model, agent, or startup setting. -Use `$$nemoclaw rebuild` when you want NemoClaw to recreate the sandbox from its recorded registry metadata without changing those selections. +Use `--fresh` to discard the saved onboarding session and start the wizard from the beginning. This clears stale or failed session state before NemoClaw creates a new session record. It also bypasses locally recorded sandbox base-image resolution metadata and reruns normal candidate resolution. `--fresh` takes precedence over a base-image hint carried from a rebuild, so NemoClaw does not use that recorded hint. The installer also accepts `--fresh` and forwards it to `$$nemoclaw onboard`, which skips automatic resume detection. `--resume` and `--fresh` are mutually exclusive. For an existing completed sandbox, use `--fresh --name --recreate-sandbox` when you intentionally want onboarding to replace that sandbox with a new provider, model, agent, or startup setting. Use `$$nemoclaw rebuild` when you want NemoClaw to recreate the sandbox from its recorded registry metadata without changing those selections. #### `--apf-interceptor` -Use this option to request a policyless sandbox creation for an APF-interceptor flow. -This option currently supports providerless sandbox creation only. -APF onboarding with an inference provider and model is not yet supported. -APF onboarding with an OpenShell provider for a Model Context Protocol (MCP) server is also not yet supported. -OpenShell cannot bind provider attachment to the new sandbox's verified immutable ID. -If the prepared plan contains any provider, NemoClaw exits before it: +Use this option to request a policyless sandbox creation for an APF-interceptor flow. This option currently supports providerless sandbox creation only. APF onboarding with an inference provider and model is not yet supported. APF onboarding with an OpenShell provider for a Model Context Protocol (MCP) server is also not yet supported. OpenShell cannot bind provider attachment to the new sandbox's verified immutable ID. If the prepared plan contains any provider, NemoClaw exits before it: - Creates the sandbox. - Registers or changes credentials. @@ -511,104 +391,72 @@ If the prepared plan contains any provider, NemoClaw exits before it: The option requires these conditions: -- Start a new onboarding session. - Use `--fresh` when a saved onboarding session exists. +- Start a new onboarding session. Use `--fresh` when a saved onboarding session exists. - Select a sandbox name that has no registry entry or live OpenShell sandbox. - Keep the active global policy absent. - Do not combine the option with `--resume` or `--recreate-sandbox`. - Do not select the Portable experimental profile. -NemoClaw omits a caller policy from every sandbox creation attempt. -After creation, NemoClaw binds the returned durable sandbox identity and verifies its effective policy. -The policy must be sandbox-scoped and contain every policy entry required by the prepared configuration. -NemoClaw then records the policy as externally managed and read-only. -This verification does not establish that APF injected the policy. +NemoClaw omits a caller policy from every sandbox creation attempt. After creation, NemoClaw binds the returned durable sandbox identity and verifies its effective policy. The policy must be sandbox-scoped and contain every policy entry required by the prepared configuration. NemoClaw stores no policy owner, receipt, or desired state; later policy commands read OpenShell directly. This verification does not establish that APF injected the policy. ```bash $$nemoclaw onboard --fresh --apf-interceptor --name my-apf-sandbox ``` -If post-create verification or native GPU fallback fails after OpenShell may have created the sandbox, NemoClaw preserves the incomplete sandbox because automatic deletion would use its mutable name. -Do not destroy that sandbox by name. -Retain the reported sandbox name, create-attempt label, and durable identity fingerprint for comparison only. -If OpenShell did not return the fingerprint, recovery remains blocked until an administrator resolves the create-attempt label to one exact sandbox. -Ask an OpenShell administrator to obtain the exact live durable ID, verify it against the fingerprint, and use an identity-bound removal procedure. -This onboarding mode does not support `--resume` or `--recreate-sandbox`, regardless of whether sandbox creation began. -After the administrator confirms identity-bound removal, repeat the original command with `--fresh` and a new name. + If post-create verification or native GPU fallback fails after OpenShell may have created the + sandbox, NemoClaw preserves the incomplete sandbox because automatic deletion would use its + mutable name. Do not destroy that sandbox by name. Retain the reported sandbox name, + create-attempt label, and durable identity fingerprint for comparison only. If OpenShell did not + return the fingerprint, recovery remains blocked until an administrator resolves the + create-attempt label to one exact sandbox. Ask an OpenShell administrator to obtain the exact live + durable ID, verify it against the fingerprint, and use an identity-bound removal procedure. This + onboarding mode does not support `--resume` or `--recreate-sandbox`, regardless of whether sandbox + creation began. After the administrator confirms identity-bound removal, repeat the original + command with `--fresh` and a new name. #### `--tool-disclosure ` -Choose how the selected agent presents its session-authorized tools to the model. -Outside the Portable experimental profile, `progressive` is the default: OpenClaw and Hermes use their native Tool Search implementations, while Deep Agents Code initially shows its core tools plus `search_tools` after at least one MCP tool loads successfully. -`direct` restores the previous behavior and presents all registered tools directly. -This setting changes model context only; it does not bypass OpenShell policy, credentials, approvals, hooks, or sandbox controls. +Choose how the selected agent presents its session-authorized tools to the model. Outside the Portable experimental profile, `progressive` is the default: OpenClaw and Hermes use their native Tool Search implementations, while Deep Agents Code initially shows its core tools plus `search_tools` after at least one MCP tool loads successfully. `direct` restores the previous behavior and presents all registered tools directly. This setting changes model context only; it does not bypass OpenShell policy, credentials, approvals, hooks, or sandbox controls. -The flag takes precedence over `NEMOCLAW_TOOL_DISCLOSURE`. -A new non-Portable sandbox defaults to `progressive` when neither is set. -Fresh Portable onboarding defaults to `direct` when the flag is absent, even when `NEMOCLAW_TOOL_DISCLOSURE` is set. -Pass `--tool-disclosure progressive` explicitly to select progressive disclosure for a fresh Portable sandbox. -NemoClaw records the selected value with the onboarding session and sandbox so rebuilds preserve it and ambient shell variables cannot silently change an internal rebuild. -Model-specific compatibility safeguards may downgrade a selected `progressive` mode to direct exposure for that model without changing the recorded preference. -To change an existing sandbox, recreate it explicitly: +The flag takes precedence over `NEMOCLAW_TOOL_DISCLOSURE`. A new non-Portable sandbox defaults to `progressive` when neither is set. Fresh Portable onboarding defaults to `direct` when the flag is absent, even when `NEMOCLAW_TOOL_DISCLOSURE` is set. Pass `--tool-disclosure progressive` explicitly to select progressive disclosure for a fresh Portable sandbox. NemoClaw records the selected value with the onboarding session and sandbox so rebuilds preserve it and ambient shell variables cannot silently change an internal rebuild. Model-specific compatibility safeguards may downgrade a selected `progressive` mode to direct exposure for that model without changing the recorded preference. To change an existing sandbox, recreate it explicitly: ```bash $$nemoclaw onboard --name my-assistant --recreate-sandbox --tool-disclosure direct ``` -Outside the Portable experimental profile, recreation without an explicit flag or environment value preserves the recorded setting and only falls back to `progressive` for legacy state. -A Portable resume without the flag preserves the mode recorded by the interrupted session. -Resuming an interrupted session with a different explicit setting fails with a conflict instead of changing behavior mid-session. +Outside the Portable experimental profile, recreation without an explicit flag or environment value preserves the recorded setting and only falls back to `progressive` for legacy state. A Portable resume without the flag preserves the mode recorded by the interrupted session. Resuming an interrupted session with a different explicit setting fails with a conflict instead of changing behavior mid-session. #### `--observability` and `--no-observability` -Enable backend-neutral trace export for a LangChain Deep Agents Code sandbox. -During initial onboarding, pass `--observability` with the Deep Agents alias. -When you use the generic `nemoclaw` entry point, combine it with `--agent langchain-deepagents-code`. -NemoClaw rejects the positive flag for OpenClaw and Hermes sandboxes. -Use `--no-observability` when you need to clear a recorded Deep Agents Code choice before switching the resumed session to another agent. +Enable backend-neutral trace export for a LangChain Deep Agents Code sandbox. During initial onboarding, pass `--observability` with the Deep Agents alias. When you use the generic `nemoclaw` entry point, combine it with `--agent langchain-deepagents-code`. NemoClaw rejects the positive flag for OpenClaw and Hermes sandboxes. Use `--no-observability` when you need to clear a recorded Deep Agents Code choice before switching the resumed session to another agent. ```bash $$nemoclaw onboard --observability nemoclaw onboard --agent langchain-deepagents-code --observability ``` -The flag is off by default. -When enabled, NemoClaw records the choice with the onboarding session and sandbox, adds the `observability-otlp-local` policy preset on supported policy tiers, and preserves the choice across resume and rebuild operations. -An explicit `--observability` or `--no-observability` choice updates a resumed onboarding session. -The Restricted tier suppresses automatic application of the preset. -An operator can add it manually after reviewing the additional egress, but the next Restricted onboarding or rebuild reconciliation removes it. +The flag is off by default. When enabled, NemoClaw records the choice with the onboarding session and sandbox, adds the `observability-otlp-local` policy preset on supported policy tiers, and preserves the choice across resume and rebuild operations. An explicit `--observability` or `--no-observability` choice updates a resumed onboarding session. The Restricted tier suppresses automatic application of the preset. An operator can add it manually after reviewing the additional egress, but the next Restricted onboarding or rebuild reconciliation removes it. -The explicit opt-in can export bounded prompts, responses, tool arguments, tool results, and operational metadata. -Treat trace payloads as sensitive application data. -The managed capture applies size, depth, item-count, recognized-key, and exception-text safeguards, but it does not detect secrets embedded in ordinary content values. -Deep Agents Code sends OTLP/HTTP protobuf traces to the fixed local endpoint `http://host.openshell.internal:4318/v1/traces`. -The OTLP library adds standard transport headers, but the sandbox cannot configure operator-supplied custom or authentication headers, a remote endpoint, backend credentials, or a backend. +The explicit opt-in can export bounded prompts, responses, tool arguments, tool results, and operational metadata. Treat trace payloads as sensitive application data. The managed capture applies size, depth, item-count, recognized-key, and exception-text safeguards, but it does not detect secrets embedded in ordinary content values. Deep Agents Code sends OTLP/HTTP protobuf traces to the fixed local endpoint `http://host.openshell.internal:4318/v1/traces`. The OTLP library adds standard transport headers, but the sandbox cannot configure operator-supplied custom or authentication headers, a remote endpoint, backend credentials, or a backend. -Changing this setting on an existing sandbox requires a new sandbox process so the startup environment matches the recorded choice. -Use the transactional rebuild flags so NemoClaw backs up declared agent state, preserves managed MCP providers and adapter state, recreates the sandbox, and restores the backup. +Changing this setting on an existing sandbox requires a new sandbox process so the startup environment matches the recorded choice. Use the transactional rebuild flags so NemoClaw backs up declared agent state, preserves managed MCP providers and adapter state, recreates the sandbox, and restores the backup. ```bash $$nemoclaw my-dcode rebuild --observability --yes $$nemoclaw my-dcode rebuild --no-observability --yes ``` -Removing the `observability-otlp-local` policy stops delivery immediately but does not clear the recorded opt-in. -A later rebuild restores the preset on Balanced and Open tiers, while Restricted continues to suppress it. -For policy recovery and the host-side LangSmith exporter example, refer to [Set Up Deep Agents Trace Export](/user-guide/deepagents/monitoring/set-up-deepagents-trace-export). -Review [Understand Deep Agents Trace Export](/user-guide/deepagents/monitoring/understand-deepagents-trace-export) for the privacy boundary, [Verify Deep Agents Trace Export](/user-guide/deepagents/monitoring/verify-deepagents-trace-export) for delivery checks, and [Manage Deep Agents Trace Export](/user-guide/deepagents/monitoring/manage-deepagents-trace-export) for lifecycle operations. +Removing the `observability-otlp-local` policy stops delivery immediately but does not clear the recorded opt-in. A later rebuild restores the preset on Balanced and Open tiers, while Restricted continues to suppress it. For policy recovery and the host-side LangSmith exporter example, refer to [Set Up Deep Agents Trace Export](/user-guide/deepagents/monitoring/set-up-deepagents-trace-export). Review [Understand Deep Agents Trace Export](/user-guide/deepagents/monitoring/understand-deepagents-trace-export) for the privacy boundary, [Verify Deep Agents Trace Export](/user-guide/deepagents/monitoring/verify-deepagents-trace-export) for delivery checks, and [Manage Deep Agents Trace Export](/user-guide/deepagents/monitoring/manage-deepagents-trace-export) for lifecycle operations. -When Docker exposes the required identity metadata, NemoClaw records the base-image resolution on managed sandbox images. -During a warm recreate or rebuild, it validates the local image identity and platform, plus the repository digest for a published image and any active OpenShell ABI requirement, before reusing it. -A valid match avoids candidate discovery and a network pull. -Set `NEMOCLAW_SANDBOX_BASE_IMAGE_REFRESH=1` to bypass the recorded hint without changing onboarding session handling: +When Docker exposes the required identity metadata, NemoClaw records the base-image resolution on managed sandbox images. During a warm recreate or rebuild, it validates the local image identity and platform, plus the exact repository digest for a published image and any active OpenShell ABI requirement, before reusing it. A valid match avoids candidate discovery and a network pull. Set `NEMOCLAW_SANDBOX_BASE_IMAGE_REFRESH=1` to bypass the recorded hint without changing onboarding session handling: ```bash NEMOCLAW_SANDBOX_BASE_IMAGE_REFRESH=1 $$nemoclaw onboard --recreate-sandbox @@ -621,100 +469,51 @@ Base-image selection follows this precedence: 2. Without a bypass, NemoClaw validates and reuses the recorded hint when possible. 3. When the hint is absent or no longer valid, NemoClaw performs normal resolution. -After a cache miss, source checkouts require a fresh local build before candidate selection when base-image inputs have dirty or staged changes, or Git cannot inspect the worktree safely. -For a clean release checkout or versioned install, NemoClaw first accepts the release-version image. -If that tag exists locally but fails compatibility validation, NemoClaw refreshes the same tag from the registry once and validates it again. -If the release-version image is missing or still incompatible, NemoClaw builds a compatible local base instead of falling back to mutable `:latest`. -For clean unversioned development checkouts, NemoClaw first tries the image tagged with the source commit. -If that image is unavailable and committed base-image inputs differ from `main`, NemoClaw requires a compatible local build. -When committed base-image inputs match `main`, NemoClaw tries the image tagged with the newest reachable release version from `origin` and only uses `:latest` when no version tag is discoverable. -When a stable tag and a prerelease tag share the same version, NemoClaw prefers the stable tag. -If `origin` tag lookup is unavailable, NemoClaw uses the newest reachable local release tag as a fallback. -If that nearest release-version image is missing or incompatible, NemoClaw builds a compatible local base instead of falling back to mutable `:latest`. -The required-build path does not reuse an older local tag. -If local builds are disabled or the build fails, resolution stops instead of selecting a stale image. -When the OpenShell sandbox ABI is required, NemoClaw also rejects a built image that does not report a compatible glibc version. - -For explicit base-image overrides, NemoClaw validates the requested ref and fails closed when it cannot be pulled or does not satisfy required ABI, agent runtime, or dependency checks. -Otherwise, normal resolution checks compatible images in Docker's local image store before attempting to pull a missing published candidate. -For warm-hint reuse and unversioned development resolution, NemoClaw can reuse another validated local fallback when published candidates are unavailable or incompatible. -When the OpenShell sandbox ABI is required, that local fallback must be ABI-compatible. -An offline warm recreate or rebuild can therefore continue when the recorded image or another compatible candidate is available locally. -When source inputs require a fresh local build, NemoClaw fails the operation if that build cannot be produced and validated instead of substituting an older local tag. -When the OpenShell sandbox ABI is required, resolution also fails if no ABI-compatible image can be resolved instead of falling back to an unvalidated cached `:latest` image. +After a cache miss, source checkouts require a fresh local build before candidate selection when base-image inputs have dirty or staged changes, or Git cannot inspect the worktree safely. For a clean release checkout or versioned install, NemoClaw first accepts the exact release-version image. If that tag exists locally but fails compatibility validation, NemoClaw refreshes the same tag from the registry once and validates it again. If the release-version image is missing or still incompatible, NemoClaw builds a compatible local base instead of falling back to mutable `:latest`. For clean unversioned development checkouts, NemoClaw first tries the image tagged with the exact source commit. If that image is unavailable and committed base-image inputs differ from `main`, NemoClaw requires a compatible local build. When committed base-image inputs match `main`, NemoClaw tries the image tagged with the newest reachable release version from `origin` and only uses `:latest` when no version tag is discoverable. When a stable tag and a prerelease tag share the same version, NemoClaw prefers the stable tag. If `origin` tag lookup is unavailable, NemoClaw uses the newest reachable local release tag as a fallback. If that nearest release-version image is missing or incompatible, NemoClaw builds a compatible local base instead of falling back to mutable `:latest`. The required-build path does not reuse an older local tag. If local builds are disabled or the build fails, resolution stops instead of selecting a stale image. When the OpenShell sandbox ABI is required, NemoClaw also rejects a built image that does not report a compatible glibc version. + +Explicit base-image overrides are exact: NemoClaw validates the requested ref and fails closed when it cannot be pulled or does not satisfy required ABI, agent runtime, or dependency checks. Otherwise, normal resolution checks compatible images in Docker's local image store before attempting to pull a missing published candidate. For warm-hint reuse and unversioned development resolution, NemoClaw can reuse another validated local fallback when published candidates are unavailable or incompatible. When the OpenShell sandbox ABI is required, that local fallback must be ABI-compatible. An offline warm recreate or rebuild can therefore continue when the recorded image or another compatible candidate is available locally. When source inputs require a fresh local build, NemoClaw fails the operation if that build cannot be produced and validated instead of substituting an older local tag. When the OpenShell sandbox ABI is required, resolution also fails if no ABI-compatible image can be resolved instead of falling back to an unvalidated cached `:latest` image. -For Hermes, warm-hint and candidate validation reruns a container probe for the MCP SDK and native Streamable HTTP integration. -During normal resolution, NemoClaw tries the published digest declared by the final Hermes Dockerfile before release-version and source-commit candidates. -The digest must also pass any active OpenShell ABI requirement, and a validated result can be recorded for warm-hint reuse. -The final Hermes image accepts only the official published digest tracked by its Dockerfile or a repository-built local base, so an otherwise reachable or ABI-compatible image is not sufficient. +For Hermes, warm-hint and candidate validation reruns a container probe for the MCP SDK and native Streamable HTTP integration. During normal resolution, NemoClaw tries the exact published digest declared by the final Hermes Dockerfile before release-version and source-commit candidates. The digest must also pass any active OpenShell ABI requirement, and a validated result can be recorded for warm-hint reuse. The final Hermes image accepts only the official published digest tracked by its Dockerfile or a repository-built local base, so an otherwise reachable or ABI-compatible image is not sufficient. -Bypassing the recorded hint does not clear Docker's local image store or require a network pull. -Only `--fresh` also discards the saved onboarding session; the refresh environment variable affects base-image selection only. +Bypassing the recorded hint does not clear Docker's local image store or require a network pull. Only `--fresh` also discards the saved onboarding session; the refresh environment variable affects base-image selection only. -For NemoClaw-managed environments, use `$$nemoclaw onboard` when you need to create or recreate the OpenShell gateway or sandbox. -Avoid `openshell self-update`, `npm update -g openshell`, or `openshell sandbox create` directly unless you intend to manage OpenShell separately and then rerun `$$nemoclaw onboard`. + For NemoClaw-managed environments, use `$$nemoclaw onboard` when you need to create or recreate + the OpenShell gateway or sandbox. Avoid `openshell self-update`, `npm update -g openshell`, or + `openshell sandbox create` directly unless you intend to manage OpenShell separately and then + rerun `$$nemoclaw onboard`. Use `--fresh` to ignore any saved onboarding session and restart the wizard from scratch. This is useful after an interrupted `$$nemoclaw onboard` run when you want to discard saved state instead of continuing it with `--resume`. -The installer detects existing sandbox sessions before onboarding and prints a warning if any are found. -To make the installer abort instead of continuing, set `NEMOCLAW_SINGLE_SESSION=1`: +The installer detects existing sandbox sessions before onboarding and prints a warning if any are found. To make the installer abort instead of continuing, set `NEMOCLAW_SINGLE_SESSION=1`: ```bash NEMOCLAW_SINGLE_SESSION=1 curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` -When existing sandboxes were created with OpenShell earlier than `0.0.37`, the installer prompts before running the automatic gateway upgrade path. -For scripted installs, set `NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE=1` to allow the automatic path to prepare the current CLI without replacing OpenShell, back up every registered sandbox with the current state manifest, retire an installed gateway whose OpenShell version is outside the current release's supported range, install the supported OpenShell release, and recover the existing sandboxes. -The installer reads that supported range from the prepared current source and stops without retiring the gateway if the installed version is unknown or the range is missing or invalid. -When the installed OpenShell version is already supported, the installer keeps the running gateway through the host update. -On Linux, if installed OpenShell lifecycle commands cannot retire the gateway, the installer checks a verified NemoClaw-managed gateway PID file for any configured gateway port. -For the default gateway on port `8080`, the installer first checks a verified active `nemoclaw-openshell-gateway.service`, then checks the PID file. -After either fallback confirms the gateway process is stopped, the installer tries to remove the selected OpenShell registration and warns if onboarding must replace a stale registration. -If neither fallback can verify and stop the process, the installer stops after backup with every sandbox backup preserved. -If any registered sandbox cannot be backed up, the installer aborts before it changes the gateway. -After the automatic path retires an out-of-range gateway, it forces installation of the OpenShell version pinned by the prepared source before recovery. -This mandatory installation applies to source and managed install modes and cannot remain deferred after gateway retirement. -If the forced installation fails, the installer does not stage a gateway service or start recovery, preserves the backups, and tells you to rerun with `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1`. -When the registry contains a pre-fingerprint OpenClaw or Hermes entry with no recorded custom-image evidence, an interactive install asks you to confirm that the listed sandbox used a NemoClaw-managed image. -For a non-interactive install, set `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` to the JSON array of names printed by the installer, such as `["my-assistant","preserve-hermes"]`, only after verifying every named sandbox used a managed image. -The confirmation permits those legacy entries to recover onto the current managed image, but it does not override recorded custom-image evidence. -After successful recovery, the installer skips generic onboarding. -For any registered-sandbox upgrade that you already prepared manually, set `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1` only after backing up every registered sandbox and retiring the old gateway. -This environment variable asserts that those steps are complete, so the installer skips the repeated backup and gateway-retirement phase before it checks whether OpenShell is installed or whether its version is in range. -For a non-default gateway, preserve the selected port on the `bash` side of the install pipeline. +When existing sandboxes were created with OpenShell earlier than `0.0.37`, the installer prompts before running the automatic gateway upgrade path. For scripted installs, set `NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE=1` to allow the automatic path to prepare the current CLI without replacing OpenShell, back up every registered sandbox with the current state manifest, retire an installed gateway whose OpenShell version is outside the current release's supported range, install the supported OpenShell release, and recover the existing sandboxes. The installer reads that supported range from the prepared current source and stops without retiring the gateway if the installed version is unknown or the range is missing or invalid. When the installed OpenShell version is already supported, the installer keeps the running gateway through the host update. On Linux, if installed OpenShell lifecycle commands cannot retire the gateway, the installer checks a verified NemoClaw-managed gateway PID file for any configured gateway port. For the default gateway on port `8080`, the installer first checks a verified active `nemoclaw-openshell-gateway.service`, then checks the PID file. After either fallback confirms the gateway process is stopped, the installer tries to remove the selected OpenShell registration and warns if onboarding must replace a stale registration. If neither fallback can verify and stop the process, the installer stops after backup with every sandbox backup preserved. If any registered sandbox cannot be backed up, the installer aborts before it changes the gateway. After the automatic path retires an out-of-range gateway, it forces installation of the OpenShell version pinned by the prepared source before recovery. This mandatory installation applies to source and managed install modes and cannot remain deferred after gateway retirement. If the forced installation fails, the installer does not stage a gateway service or start recovery, preserves the backups, and tells you to rerun with `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1`. When the registry contains a pre-fingerprint OpenClaw or Hermes entry with no recorded custom-image evidence, an interactive install asks you to confirm that the listed sandbox used a NemoClaw-managed image. For a non-interactive install, set `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` to the exact JSON array of names printed by the installer, such as `["my-assistant","preserve-hermes"]`, only after verifying every named sandbox used a managed image. The confirmation permits those legacy entries to recover onto the current managed image, but it does not override recorded custom-image evidence. After successful recovery, the installer skips generic onboarding. For any registered-sandbox upgrade that you already prepared manually, set `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1` only after backing up every registered sandbox and retiring the old gateway. This environment variable asserts that those steps are complete, so the installer skips the repeated backup and gateway-retirement phase before it checks whether OpenShell is installed or whether its version is in range. For a non-default gateway, preserve the selected port on the `bash` side of the install pipeline. ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_GATEWAY_PORT= NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1 bash ``` -It reuses the latest backups, forces the pinned OpenShell installation, and starts recovery only after that installation succeeds. -If the installation fails, rerun the same install-pipeline command to preserve `NEMOCLAW_GATEWAY_PORT` and `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED`. +It reuses the latest backups, forces the pinned OpenShell installation, and starts recovery only after that installation succeeds. If the installation fails, rerun the same install-pipeline command to preserve `NEMOCLAW_GATEWAY_PORT` and `NEMOCLAW_OPENSHELL_UPGRADE_PREPARED`. -Prepared backup recovery for a legacy sandbox restores only the managed state directory recorded in its validated manifest, such as `/sandbox/.openclaw` or `/sandbox/.hermes`. -Files outside that recorded path, including `/sandbox/user-data`, are not preserved when the installer recreates the sandbox. -Back up those paths outside the sandbox before you continue. + Prepared backup recovery for a legacy sandbox restores only the managed state directory recorded + in its validated manifest, such as `/sandbox/.openclaw` or `/sandbox/.hermes`. Files outside that + recorded path, including `/sandbox/user-data`, are not preserved when the installer recreates the + sandbox. Back up those paths outside the sandbox before you continue. -The wizard prompts for a provider first, then collects the provider credential if needed. -Supported non-experimental choices include NVIDIA Endpoints, OpenRouter, OpenAI, Anthropic, Google Gemini, and compatible OpenAI or Anthropic endpoints. -Credentials are registered with the OpenShell gateway and never persisted to host disk. -Refer to [Credential Storage](../security/credential-storage) for details on inspection, rotation, and migration from earlier releases. -The legacy `$$nemoclaw setup` command is deprecated; use `$$nemoclaw onboard` instead. +The wizard prompts for a provider first, then collects the provider credential if needed. Supported non-experimental choices include NVIDIA Endpoints, OpenRouter, OpenAI, Anthropic, Google Gemini, and compatible OpenAI or Anthropic endpoints. Credentials are registered with the OpenShell gateway and never persisted to host disk. Refer to [Credential Storage](../security/credential-storage) for details on inspection, rotation, and migration from earlier releases. The legacy `$$nemoclaw setup` command is deprecated; use `$$nemoclaw onboard` instead. -On a qualified DGX Spark Arm64 or Linux x86_64 NVIDIA GPU host, the provider menu lists compatible experimental managed llama.cpp profiles in descending YAML priority order. -During interactive onboarding without an explicit provider request, the menu ignores `NEMOCLAW_LLAMACPP_RECIPE` and marks the unique highest-priority compatible profile as `(recommended)`. -On DGX Spark, the recommended profile appears as **Managed llama.cpp: NVIDIA Nemotron 3 Nano 30B-A3B on one DGX Spark (recommended)**. -On Linux x86_64, the recommended profile identifies one NVIDIA GPU. -Meta Muse Glimmer remains available on DGX Spark without the recommendation marker. -The selected menu entry determines the exact recipe even when `NEMOCLAW_LLAMACPP_RECIPE` names another recipe. -Select the same path non-interactively with the repository-owned recipe: +On a qualified DGX Spark, the provider menu lists compatible experimental managed llama.cpp profiles in descending YAML priority order. During interactive onboarding without an explicit provider request, the menu ignores `NEMOCLAW_LLAMACPP_RECIPE` and marks the unique highest-priority compatible profile as `(recommended)`. The recommended profile appears as **Managed llama.cpp: Meta Muse Glimmer 30B on one DGX Spark (recommended)**. The NVIDIA Nemotron profile appears next without the recommendation marker. The selected menu entry determines the exact recipe even when `NEMOCLAW_LLAMACPP_RECIPE` names another recipe. Select the same path non-interactively with the repository-owned recipe: ```bash NEMOCLAW_PROVIDER=install-llama-cpp \ @@ -725,45 +524,36 @@ NEMOCLAW_SANDBOX_NAME=my-assistant \ Use `llama-cpp.muse-glimmer-30b.spark-single.v1` to select the Meta Muse Glimmer recipe explicitly. -Do not set `NEMOCLAW_MODEL` for the managed llama.cpp path. -For prerequisites, external traffic, verification, and recovery, refer to [Install Managed llama.cpp on an NVIDIA GPU Host](../inference/local-inference/set-up-llama-cpp#install-managed-llamacpp-on-an-nvidia-gpu-host). +Do not set `NEMOCLAW_MODEL` for the managed llama.cpp path. For prerequisites, external traffic, verification, and recovery, refer to [Install Managed llama.cpp on DGX Spark](../inference/local-inference/choose-local-inference-server#install-managed-llamacpp-on-dgx-spark). -After provider selection, the wizard reviews the provider, model, credential state, and sandbox name before registering inference. -The interactive review offers these actions: +After provider selection, the wizard reviews the provider, model, credential state, and sandbox name before registering inference. The interactive review offers these actions: - **Apply configuration** continues to provider registration. - **Edit inference provider or model** returns to provider and model selection. - **Edit sandbox name** prompts for the sandbox name again. - **Exit onboarding** stops onboarding before provider registration. -When you edit inference, NemoClaw clears the credential staged for the discarded selection. -NemoClaw preserves the sandbox name. -When you edit the sandbox name, NemoClaw preserves the inference selection. -The sandbox prompt shows the prior name as its default. -After you apply the configuration, routine editing ends. -If inference setup fails and offers a `back` recovery action, you can return to provider and model selection and then review the updated configuration again. +When you edit inference, NemoClaw clears the credential staged for the discarded selection. NemoClaw preserves the sandbox name. When you edit the sandbox name, NemoClaw preserves the inference selection. The sandbox prompt shows the prior name as its default. After you apply the configuration, routine editing ends. If inference setup fails and offers a `back` recovery action, you can return to provider and model selection and then review the updated configuration again. + -It then prompts for optional web search and messaging channels, builds and starts the sandbox, and asks for a **policy tier** that controls the default set of network policy presets applied to the sandbox. + It then prompts for optional web search and messaging channels, builds and starts the sandbox, and + asks for a **policy tier** that controls the default set of network policy presets applied to the + sandbox. -It then prompts for optional web search, builds and starts the sandbox, and asks for a **policy tier** that controls the default set of network policy presets applied to the sandbox. + It then prompts for optional web search, builds and starts the sandbox, and asks for a **policy + tier** that controls the default set of network policy presets applied to the sandbox. Four tiers are available: | Tier | Description | -|------|-------------| +| --- | --- | | Restricted | No tier defaults. Web search or other integrations selected earlier can still add their required presets; deselect them during policy review for baseline-only access. | | Balanced (default) | Full dev tooling and a selected, supported web search provider. Package installs, model downloads, and inference. No messaging platform access by default. | | Open | Broad access across third-party services including supported messaging and productivity presets. Agent-specific unsupported presets are filtered out. | | Personal | Requires one broad web authority that lets every sandbox binary open TCP connections to public and private address ranges on destination ports 80 and 443. It replaces overlapping web endpoints while preserving non-web policy. Unspecified, loopback, and link-local ranges remain blocked. Intended only for trusted personal-use workloads. | -After selecting a tier, the wizard shows a combined preset and access-mode screen where you can include or exclude individual presets and toggle each between read and read-write access. -When Personal is selected or carried forward, `personal-open-internet` is mandatory for every agent and every onboarding entry point, including Portable. -The picker and policy modes control only additional presets; they cannot deselect, skip, or replace Personal's required web authority. -For details on tiers and the presets each includes, refer to [Network Policies](network-policies#policy-tiers). -When you finish the policy step, NemoClaw records the finalized built-in preset selection for that sandbox. -When onboarding creates or recreates a sandbox with presets, NemoClaw prints the finalized create-time policy scope before registering providers or creating the sandbox. -Later re-onboard runs seed from that finalized selection, so presets you intentionally removed stay removed unless you select them again or override the policy mode. +After selecting a tier, the wizard shows a combined preset and access-mode screen where you can include or exclude individual presets and toggle each between read and read-write access. When Personal is selected or carried forward, `personal-open-internet` is mandatory for every agent and every onboarding entry point, including Portable. The picker and policy modes control only additional presets; they cannot deselect, skip, or replace Personal's required web authority. For details on tiers and the presets each includes, refer to [Network Policies](network-policies#policy-tiers). When you finish the policy step, NemoClaw records the finalized built-in preset selection for that sandbox. When onboarding creates or recreates a sandbox with presets, NemoClaw prints the exact finalized create-time policy scope before registering providers or creating the sandbox. Later re-onboard runs seed from that finalized selection, so presets you intentionally removed stay removed unless you select them again or override the policy mode. In non-interactive mode, set the tier with `NEMOCLAW_POLICY_TIER` (default: `balanced`): @@ -771,53 +561,36 @@ In non-interactive mode, set the tier with `NEMOCLAW_POLICY_TIER` (default: `bal NEMOCLAW_POLICY_TIER=restricted $$nemoclaw onboard --non-interactive --yes-i-accept-third-party-software ``` -Unset, blank, or whitespace-only `NEMOCLAW_POLICY_TIER` values use the `balanced` default. -In non-interactive mode, any non-blank value must be one of `restricted`, `balanced`, `open`, or `personal`; otherwise onboarding exits before preflight, gateway, or inference side effects with an error listing the valid options. -Interactive onboarding ignores an invalid environment value and shows the normal tier prompt. - -`NEMOCLAW_POLICY_MODE` controls how non-interactive onboarding reconciles the tier-derived suggestions against the sandbox's currently-applied presets. -The default is `suggested`, which is *additive*. -Onboarding applies tier defaults and preserves any presets you previously added with [`$$nemoclaw policy add`](#$$nemoclaw-name-policy-add) across re-onboards. -Use `custom` with `NEMOCLAW_POLICY_PRESETS` when you want the explicit list to be authoritative for optional presets. -Onboarding removes any optional preset that is not in the list. -`skip` does not add optional tier defaults and retains eligible optional presets already applied. -It still applies the required preset for each messaging channel enabled during the same onboarding run so the configured channel can reach its service. -For Personal, all modes still apply or retain the mandatory `personal-open-internet` preset. -NemoClaw filters tier suggestions and resume selections by active agent support and the selected web search provider. -During automatic suggestion and resume reconciliation, it removes stale web-search selections when they conflict with the active agent or selected provider. -The Personal tier instead uses `personal-open-internet` for web transport and does not select Brave Search or Tavily Search merely to enable ordinary web fetches. -This makes keyless fetches available to any sandbox binary, but it does not add a provider-free `web_search` implementation. +Unset, blank, or whitespace-only `NEMOCLAW_POLICY_TIER` values use the `balanced` default. In non-interactive mode, any non-blank value must be one of `restricted`, `balanced`, `open`, or `personal`; otherwise onboarding exits before preflight, gateway, or inference side effects with an error listing the valid options. Interactive onboarding ignores an invalid environment value and shows the normal tier prompt. + +`NEMOCLAW_POLICY_MODE` controls how non-interactive onboarding reconciles the tier-derived suggestions against the sandbox's currently-applied presets. The default is `suggested`, which is _additive_. Onboarding applies tier defaults and preserves any presets you previously added with [`$$nemoclaw policy add`](#$$nemoclaw-name-policy-add) across re-onboards. Use `custom` with `NEMOCLAW_POLICY_PRESETS` when you want the explicit list to be authoritative for optional presets. Onboarding removes any optional preset that is not in the list. `skip` does not add optional tier defaults and retains eligible optional presets already applied. It still applies the required preset for each messaging channel enabled during the same onboarding run so the configured channel can reach its service. For Personal, all modes still apply or retain the mandatory `personal-open-internet` preset. NemoClaw filters tier suggestions and resume selections by active agent support and the selected web search provider. During automatic suggestion and resume reconciliation, it removes stale web-search selections when they conflict with the active agent or selected provider. The Personal tier instead uses `personal-open-internet` for web transport and does not select Brave Search or Tavily Search merely to enable ordinary web fetches. This makes keyless fetches available to any sandbox binary, but it does not add a provider-free `web_search` implementation. + -For Hermes, this includes replacing stale `nous-web` when Tavily is selected. + For Hermes, this includes replacing stale `nous-web` when Tavily is selected. -An explicit `custom` preset list or interactive manual selection remains operator-controlled for additional presets. +An explicit `custom` preset list or interactive manual selection remains operator-controlled for +additional presets. -Hermes managed-tool gateway selections add matching Hermes-specific policy presets, such as `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, and `nous-code`, without applying unsupported OpenClaw-only presets. -When Tavily Search is selected, it replaces `nous-web` as the Hermes web search and extract backend while the other selected Nous tools remain enabled. +Hermes managed-tool gateway selections add matching Hermes-specific policy presets, such as `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, and `nous-code`, without applying unsupported OpenClaw-only presets. When Tavily Search is selected, it replaces `nous-web` as the Hermes web search and extract backend while the other selected Nous tools remain enabled. | Value | Behaviour | -|-------|-----------| +| --- | --- | | `suggested` (default) | Apply tier defaults and preserve any extra presets already applied. Aliases: `default`, `auto`. | | `custom` | Apply exactly the optional presets in `NEMOCLAW_POLICY_PRESETS`. Previously-applied optional presets not in the list are removed. Personal still requires `personal-open-internet`. Alias: `list`. | | `skip` | Do not add optional tier defaults; retain eligible optional presets already applied. Personal still applies or retains `personal-open-internet`. Aliases: `none`, `no`. | -OpenClaw onboarding supports Brave Search and Tavily Search. -NemoClaw registers a sandbox-scoped OpenShell provider and keeps `openclaw.json` on an OpenShell credential placeholder. -At egress, OpenShell rewrites Brave's `X-Subscription-Token` header with `BRAVE_API_KEY` or Tavily's `Authorization` header with `TAVILY_API_KEY`. -Treat web search as an explicit opt-in and use a dedicated low-privilege key. +OpenClaw onboarding supports Brave Search and Tavily Search. NemoClaw registers a sandbox-scoped OpenShell provider and keeps `openclaw.json` on an OpenShell credential placeholder. At egress, OpenShell rewrites Brave's `X-Subscription-Token` header with `BRAVE_API_KEY` or Tavily's `Authorization` header with `TAVILY_API_KEY`. Treat web search as an explicit opt-in and use a dedicated low-privilege key. -Deep Agents onboarding supports the maintained Tavily Search path. -NemoClaw registers the Tavily credential with the OpenShell gateway, applies the `tavily` policy preset when you opt in, and rebuilds the sandbox so the provider attaches to the managed Python runtime. -Do not place `TAVILY_API_KEY` in `/sandbox/.deepagents/.env`, `.state/auth.json`, or other Deep Agents Code state. +Deep Agents onboarding supports the maintained Tavily Search path. NemoClaw registers the Tavily credential with the OpenShell gateway, applies the `tavily` policy preset when you opt in, and rebuilds the sandbox so the provider attaches to the managed Python runtime. Do not place `TAVILY_API_KEY` in `/sandbox/.deepagents/.env`, `.state/auth.json`, or other Deep Agents Code state. For non-interactive onboarding, export the Tavily key only in the host shell that runs onboarding: @@ -858,20 +631,12 @@ TAVILY_API_KEY=... \ $$nemoclaw onboard --non-interactive ``` -Use `NEMOCLAW_WEB_SEARCH_PROVIDER=brave` with `BRAVE_API_KEY` for Brave Search, or set the provider to `none` to disable web search explicitly. -When the provider selector is unset, NemoClaw chooses Brave Search when `BRAVE_API_KEY` is available, then Tavily Search when only `TAVILY_API_KEY` is available. -Brave Search wins when both keys are available to preserve the historical non-interactive behavior. -An explicit provider with no matching key exits before sandbox creation. -A provider key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. -After fixing the key, rerun onboarding so NemoClaw can validate it, register the selected provider, and apply the matching policy preset. -Changing or disabling the selected provider recreates the sandbox because the plugin configuration and credential attachment are part of the image. -Accept the recreate prompt or pass `--recreate-sandbox`. +Use `NEMOCLAW_WEB_SEARCH_PROVIDER=brave` with `BRAVE_API_KEY` for Brave Search, or set the provider to `none` to disable web search explicitly. When the provider selector is unset, NemoClaw chooses Brave Search when `BRAVE_API_KEY` is available, then Tavily Search when only `TAVILY_API_KEY` is available. Brave Search wins when both keys are available to preserve the historical non-interactive behavior. An explicit provider with no matching key exits before sandbox creation. A provider key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. After fixing the key, rerun onboarding so NemoClaw can validate it, register the selected provider, and apply the matching policy preset. Changing or disabling the selected provider recreates the sandbox because the plugin configuration and credential attachment are part of the image. Accept the recreate prompt or pass `--recreate-sandbox`. -Hermes supports Tavily Search through NemoClaw onboarding and does not support Brave Search. -To enable Tavily in non-interactive mode, set the provider and matching key. +Hermes supports Tavily Search through NemoClaw onboarding and does not support Brave Search. To enable Tavily in non-interactive mode, set the provider and matching key. ```bash NEMOCLAW_WEB_SEARCH_PROVIDER=tavily \ @@ -879,99 +644,39 @@ TAVILY_API_KEY=... \ $$nemoclaw onboard --non-interactive ``` -Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` to disable web search explicitly. -When the selector is unset, NemoClaw enables Tavily when `TAVILY_API_KEY` is available and ignores `BRAVE_API_KEY` for Hermes. -An explicit Tavily selection with no key exits before sandbox creation. -A Tavily key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. -Changing or disabling Tavily recreates the sandbox because the Hermes backend, environment placeholder, and credential attachment are part of the image. -If you also select the Nous-managed web gateway through Nous Portal OAuth, Tavily replaces `nous-web` while other selected Nous tools remain enabled. -API-key mode is inference-only and does not enable managed Nous tool gateways. +Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` to disable web search explicitly. When the selector is unset, NemoClaw enables Tavily when `TAVILY_API_KEY` is available and ignores `BRAVE_API_KEY` for Hermes. An explicit Tavily selection with no key exits before sandbox creation. A Tavily key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. Changing or disabling Tavily recreates the sandbox because the Hermes backend, environment placeholder, and credential attachment are part of the image. If you also select the Nous-managed web gateway through Nous Portal OAuth, Tavily replaces `nous-web` while other selected Nous tools remain enabled. API-key mode is inference-only and does not enable managed Nous tool gateways. -The wizard prompts for a sandbox name. -Names must contain 1 to 19 characters. -They must be lowercase, start with a letter, contain only letters, numbers, and single internal hyphens, and end with a letter or number. -Consecutive hyphens (`--`) are not allowed. -The CLI rejects names that do not match these rules. -It also prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input, so passing `--name MyAssistant` reports `Try: myassistant`. -Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. -Use `--agent ` to target a specific installed agent profile during onboarding. -The `$$nemoclaw onboard --help` output lists installed runtime names inline, and `$$nemoclaw agents list` shows the same runtimes with manifest descriptions. +The wizard prompts for a sandbox name. Names must contain 1 to 19 characters. They must be lowercase, start with a letter, contain only letters, numbers, and single internal hyphens, and end with a letter or number. Consecutive hyphens (`--`) are not allowed. The CLI rejects names that do not match these rules. It also prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input, so passing `--name MyAssistant` reports `Try: myassistant`. Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. The `$$nemoclaw onboard --help` output lists installed runtime names inline, and `$$nemoclaw agents list` shows the same runtimes with manifest descriptions. -Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw includes in the generated sandbox configuration. -Refer to [Declarative Multi-Agent Manifest](../configure-agents/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. +Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw includes in the generated sandbox configuration. Refer to [Declarative Multi-Agent Manifest](../configure-agents/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. -Use `--control-ui-port ` to choose the host dashboard port for a sandbox. -The value must be an integer from `1024` through `65535`. -This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. - -Do not use a port from `8642` through `8652` for any agent. -NemoClaw allocates each Hermes sandbox's OpenAI-compatible API port from that range, so it rejects every port in the range as a dashboard port before sandbox creation. - -If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`). -Socket Mode requires both tokens. -The app-level token is stored in a dedicated `slack-app` OpenShell provider and forwarded to the sandbox alongside the bot token. -The wizard also accepts optional `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` values so you can restrict Slack DMs, channel `@mention` users, and channel IDs before the sandbox is created. - -If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. -NemoClaw includes those values in the generated Discord guild workspace configuration so the bot can respond in the selected server, not just in DMs. -If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. -Guild responses remain mention-gated by default unless you opt into all-message replies. -If `DISCORD_SERVER_ID` is set and `DISCORD_REQUIRE_MENTION` is unset, NemoClaw records the existing mention-only default (`DISCORD_REQUIRE_MENTION=1`). - -If you enable Telegram during onboarding, the wizard can also prompt for whether group chats should reply only to `@mentions` or to all group messages. -Mention-only group replies are the default. -Set `TELEGRAM_REQUIRE_MENTION=0` for non-interactive onboarding when you want all group messages to trigger replies. -For OpenClaw, Telegram group access defaults to `TELEGRAM_GROUP_POLICY=open`; set `TELEGRAM_GROUP_POLICY=allowlist` or `TELEGRAM_GROUP_POLICY=disabled` before non-interactive onboarding when you want stricter group access. -Hermes does not have an equivalent disable-groups policy; `TELEGRAM_ALLOWED_IDS` maps to Hermes `TELEGRAM_ALLOWED_USERS`, which authorizes those users across DMs, groups, and forums. -Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. - - - -If you cancel a brand-new onboarding run at the policy-tier selector or either policy-preset selector after sandbox creation, NemoClaw preserves the incomplete sandbox, registry entry, and onboarding session for identity-bound recovery. -NemoClaw reports the durable sandbox identity fingerprint when it is available. -It does not run OpenShell's mutable-name deletion command because the name may now identify a replacement sandbox. -Do not delete the sandbox by mutable name. -Shared inference providers are gateway configuration, not sandbox cleanup targets. -Sandbox-scoped provider registrations or gateway-bound credentials may remain when the durable recovery record lists them. -Ask an OpenShell administrator to inspect the exact sandbox identity and remove only sandbox-scoped resources whose ownership is confirmed for the retained sandbox. -A credential environment name in the recovery record does not prove that its value was exposed. -Rotate a credential only when identity-bound inspection proves that it was exposed or attached to a retained sandbox-scoped resource. -NemoClaw stores the recovery record independently from the active onboarding session. -A fresh run with a different name can proceed without clearing that record, but automatic resume, explicit `--resume`, reuse, recreation, and fresh onboarding with the retained name remain blocked. -NemoClaw has no supported operation in this release to clear the recovery record, so the retained name remains unavailable even after external recovery or removal. -Preserve the record as evidence. -Start fresh onboarding with `$$nemoclaw onboard --fresh --name `. -Select the required provider, model, agent, policy, and environment inputs again because `--fresh` does not retain them. - -If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection. -In interactive mode, the wizard asks for confirmation before delete and recreate. -In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs. -For managed Deep Agents Code sandboxes, NemoClaw also recreates when the live `dcode identity` selection is unreadable; other agent paths continue to reuse by default when their stored selection cannot be read. -Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected. - -Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live. -This applies whether the existing sandbox is ready or marked not-ready, so cross-version upgrades that pass `NEMOCLAW_RECREATE_SANDBOX=1` no longer drop user files from the selected agent workspace. -The behaviour matches `$$nemoclaw rebuild --force`. -NemoClaw aborts the recreate when the backup cannot complete in full, including when individual state directories or files fail mid-backup, so failed entries are not silently dropped on delete. -If restoration into the replacement does not complete, NemoClaw reports the failed directories, files, and restore reason that are available, preserves the snapshot, leaves the replacement unregistered, and exits nonzero. -Run the owner-scoped `openshell sandbox delete -g '' ''` command that onboarding prints, then rerun the original onboarding command. -If NemoClaw cannot identify the owning gateway, do not delete a same-name sandbox. -Preserve the snapshot for manual recovery. -Set `NEMOCLAW_RECREATE_WITHOUT_BACKUP=1` to skip the pre-recreate backup. -The destination sandbox starts with a fresh workspace. - -Before deletion, onboarding prints a `Journaled replacement` diagnostic with the replacement identifier, recorded OpenShell gateway, and current phase. -If the process stops after this point, a later same-target onboarding run continues the active replacement without requiring `--resume`. -It accepts a ready same-name replacement only when the live identity and sandbox registry generation match the journal. -It fails closed if the gateway, source, target, durable source registry fields, or replacement settings changed. +Use `--control-ui-port ` to choose the host dashboard port for a sandbox. The value must be an integer from `1024` through `65535`. This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the previous registry value, and the default port. + +Do not use a port from `8642` through `8652` for any agent. NemoClaw allocates each Hermes sandbox's OpenAI-compatible API port from that range, so it rejects every port in the range as a dashboard port before sandbox creation. + +If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`). Socket Mode requires both tokens. The app-level token is stored in a dedicated `slack-app` OpenShell provider and forwarded to the sandbox alongside the bot token. The wizard also accepts optional `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` values so you can restrict Slack DMs, channel `@mention` users, and channel IDs before the sandbox is created. + +If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. NemoClaw includes those values in the generated Discord guild workspace configuration so the bot can respond in the selected server, not just in DMs. If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. Guild responses remain mention-gated by default unless you opt into all-message replies. If `DISCORD_SERVER_ID` is set and `DISCORD_REQUIRE_MENTION` is unset, NemoClaw records the existing mention-only default (`DISCORD_REQUIRE_MENTION=1`). + +If you enable Telegram during onboarding, the wizard can also prompt for whether group chats should reply only to `@mentions` or to all group messages. Mention-only group replies are the default. Set `TELEGRAM_REQUIRE_MENTION=0` for non-interactive onboarding when you want all group messages to trigger replies. For OpenClaw, Telegram group access defaults to `TELEGRAM_GROUP_POLICY=open`; set `TELEGRAM_GROUP_POLICY=allowlist` or `TELEGRAM_GROUP_POLICY=disabled` before non-interactive onboarding when you want stricter group access. Hermes does not have an equivalent disable-groups policy; `TELEGRAM_ALLOWED_IDS` maps to Hermes `TELEGRAM_ALLOWED_USERS`, which authorizes those users across DMs, groups, and forums. Pairing and `TELEGRAM_ALLOWED_IDS` still govern direct messages. + + + +If you cancel a brand-new onboarding run at the policy-tier selector or either policy-preset selector after sandbox creation, NemoClaw preserves the incomplete sandbox, registry entry, and onboarding session for identity-bound recovery. NemoClaw reports the durable sandbox identity fingerprint when it is available. It does not run OpenShell's mutable-name deletion command because the name may now identify a replacement sandbox. Do not delete the sandbox by mutable name. Shared inference providers are gateway configuration, not sandbox cleanup targets. Sandbox-scoped provider registrations or gateway-bound credentials may remain when the durable recovery record lists them. Ask an OpenShell administrator to inspect the exact sandbox identity and remove only sandbox-scoped resources whose ownership is confirmed for the retained sandbox. A credential environment name in the recovery record does not prove that its value was exposed. Rotate a credential only when identity-bound inspection proves that it was exposed or attached to a retained sandbox-scoped resource. NemoClaw stores the recovery record independently from the active onboarding session. A fresh run with a different name can proceed without clearing that record, but automatic resume, explicit `--resume`, reuse, recreation, and fresh onboarding with the retained name remain blocked. NemoClaw has no supported operation in this release to clear the recovery record, so the retained name remains unavailable even after external recovery or removal. Preserve the record as evidence. Start fresh onboarding with `--fresh` and another available sandbox name. Select the required provider, model, agent, policy, and environment inputs again because `--fresh` does not retain them. + +If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection. In interactive mode, the wizard asks for confirmation before delete and recreate. In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs. For managed Deep Agents Code sandboxes, NemoClaw also recreates when the live `dcode identity` selection is unreadable; other agent paths continue to reuse by default when their stored selection cannot be read. Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected. + +Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live. This applies whether the existing sandbox is ready or marked not-ready, so cross-version upgrades that pass `NEMOCLAW_RECREATE_SANDBOX=1` no longer drop user files from the selected agent workspace. The behaviour matches `$$nemoclaw rebuild --force`. NemoClaw aborts the recreate when the backup cannot complete in full, including when individual state directories or files fail mid-backup, so failed entries are not silently dropped on delete. If restoration into the replacement does not complete, NemoClaw reports the failed directories, files, and restore reason that are available, preserves the snapshot, leaves the replacement unregistered, and exits nonzero. Run the owner-scoped `openshell sandbox delete -g '' ''` command that onboarding prints, then rerun the original onboarding command. If NemoClaw cannot identify the owning gateway, do not delete a same-name sandbox. Preserve the snapshot for manual recovery. Set `NEMOCLAW_RECREATE_WITHOUT_BACKUP=1` to skip the pre-recreate backup. The destination sandbox starts with a fresh workspace. + +Before deletion, onboarding prints a `Journaled replacement` diagnostic with the replacement identifier, recorded OpenShell gateway, and current phase. If the process stops after this point, a later same-target onboarding run continues the active replacement without requiring `--resume`. It accepts a ready same-name replacement only when the live identity and sandbox registry generation match the journal. It fails closed if the gateway, source, target, durable source registry fields, or replacement settings changed. @@ -980,107 +685,46 @@ For OpenClaw, the backed-up paths include agents, extensions, workspace, skills, -For Hermes, the backed-up paths come from `agents/hermes/manifest.yaml`, including `/sandbox/.hermes` state such as memories, sessions, skills, plugins, cron, scripts, logs, plans, workspace, messaging platform state, `runtime/state.db`, and the default kanban board in `kanban.db`. -Kanban backup does not include named boards, attachments, worker logs, scratch workspaces under `kanban/`, or external directory or worktree targets. - - - -Before creating the gateway, the wizard runs preflight checks. -It verifies that Docker is reachable and prints host remediation guidance when prerequisites are missing. -Standard onboarding rejects unsupported runtimes such as Podman. -The explicit portable experimental profile has one installer-preflight admission exception for the Podman unsupported-runtime finding. -It does not waive any other readiness blocker or make Podman generally supported. -The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`). -If the installed OpenShell version falls outside this range, onboarding exits with an actionable error and a link to compatible releases. -For fresh OpenShell installs, NemoClaw queries published OpenShell releases and asks the installer to use a release that fits the blueprint range. -If release metadata is unavailable, the installer uses its bundled fallback pin and the post-install version gate still enforces the range. - -When NemoClaw finds an existing gateway to reuse, it probes the host gateway HTTP endpoint before declaring the gateway reusable. -If the container is running but the upstream is still warming up (for example, immediately after a Docker daemon restart), NemoClaw rebuilds the gateway instead of trusting stale metadata. -On the Docker-driver gateway path, preflight stays read-only when it detects a stale gateway (for example, a Docker-driver runtime env hash drift). -It prints a `⚠ Gateway will be recreated when sandbox creation starts` notice and defers the actual teardown to step `[2/8] Starting OpenShell gateway`. -This means pressing `Ctrl+C` between preflight and step `[2/8]` leaves the running gateway and existing sandbox containers untouched, so `$$nemoclaw onboard` is safe to run just to check preflight output. -An interrupted run prints the resume command and exits with status `130` for `Ctrl+C` or `143` for `SIGTERM`. -For Linux Docker-driver gateways, onboarding also checks that a helper container on the OpenShell Docker network can reach `host.openshell.internal:`. -If a host firewall blocks that sandbox path, onboarding exits with a `sudo ufw allow from to port proto tcp` command before it reports the gateway healthy. -Set `NEMOCLAW_AUTO_FIX_FIREWALL=1` to opt in to automatic UFW remediation for this specific failure: NemoClaw uses `sudo -n` only, validates the Docker bridge subnet/gateway/port, applies the narrow UFW rule only after a proven TCP reachability failure, and re-probes before continuing. -If passwordless sudo, UFW, or active UFW is unavailable, NemoClaw falls back to the manual guidance path without prompting for a password. - -For the portable experimental profile, the helper maps `host.openshell.internal` to the OpenShell Podman host gateway instead of the inspected network gateway. -This path does not use Docker bridge UFW remediation. -After all portable TCP probe attempts fail, onboarding prints commands for the user-scoped Podman service and socket. - -Onboarding prints the same commands when the portable probe cannot reach the user-scoped Podman service. -The printed rerun command keeps the portable experimental profile selected. -Portable commands reconstruct the current user's rootless Podman socket authority from NemoClaw state before they use the Docker-compatible API. -They do not select an endpoint from ambient Docker or Podman runtime variables or named connections. -When `podman.service` reports inactive and the recorded socket exists, NemoClaw first makes one 10-second API request through the guarded recorded authority. -A valid server version classifies the endpoint as warm and avoids starting another socket service. -A missing socket or a response without a valid server version enters bounded cold activation. -Any socket authority change during this precheck fails at the socket authority stage. -When the user-scoped socket-backed service needs activation, NemoClaw activates it and waits through a bounded startup period for a real Podman API response. -During cold activation, the first API probe can cause systemd to replace the socket inode. -NemoClaw requalifies one such replacement and repeats the probe only when the socket path, device, mode, owner, and complete directory authority remain unchanged. -Any other authority change or a second inode replacement fails the readiness check. -After cold activation succeeds, later API health checks use the fixed 10-second steady-state deadline. -Onboarding and portable sandbox lifecycle commands use this same readiness contract. -Failures identify socket authority, service activation, startup API health, or steady-state API health without reporting credentials. -NemoClaw does not fall back to Docker or report an absent or unreachable endpoint as healthy. -A successful cold path uses the `cold` timing label and reports activation, API, and total time in milliseconds. -A successful warm path uses the `warm` timing label and reports steady-state API and total time in milliseconds. -To tune the existing-gateway HTTP health poll, use `NEMOCLAW_REUSE_HEALTH_POLL_COUNT` (default `6`) and `NEMOCLAW_REUSE_HEALTH_POLL_INTERVAL` (default `5` seconds). -The poll count is clamped to a minimum of `1` so the health probe always runs at least once, and the interval is clamped to a minimum of `0` (no sleep between attempts). - -The Docker-driver gateway and the portable experimental profile's Podman-driver gateway resolve to the same default state directory when they use the same gateway port, because NemoClaw scopes that directory by port, not by driver. -Selecting the portable experimental profile on a host that already has a Docker-driver gateway therefore refuses to rewrite that gateway's config rather than silently repurposing it for Podman. -The error names the driver the existing config already uses and the driver this run selected. -Docker and Podman gateways cannot reuse one state directory. For NemoClaw-managed state, switch drivers with the applicable `$$nemoclaw uninstall` path, then retry onboarding. Uninstall preserves externally managed or supervised state; resolve that state through its lifecycle authority instead. To run both drivers concurrently, select an unused gateway port with `NEMOCLAW_GATEWAY_PORT=` and a separate state directory with `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR=`. +For Hermes, the backed-up paths come from `agents/hermes/manifest.yaml`, including `/sandbox/.hermes` state such as memories, sessions, skills, plugins, cron, scripts, logs, plans, workspace, messaging platform state, `runtime/state.db`, and the default kanban board in `kanban.db`. Kanban backup does not include named boards, attachments, worker logs, scratch workspaces under `kanban/`, or external directory or worktree targets. + + + +Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable and prints host remediation guidance when prerequisites are missing. Standard onboarding rejects unsupported runtimes such as Podman. The explicit portable experimental profile has one installer-preflight admission exception for the Podman unsupported-runtime finding. It does not waive any other readiness blocker or make Podman generally supported. The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`). If the installed OpenShell version falls outside this range, onboarding exits with an actionable error and a link to compatible releases. For fresh OpenShell installs, NemoClaw queries published OpenShell releases and asks the installer to use a release that fits the blueprint range. If release metadata is unavailable, the installer uses its bundled fallback pin and the post-install version gate still enforces the range. + +When NemoClaw finds an existing gateway to reuse, it probes the host gateway HTTP endpoint before declaring the gateway reusable. If the container is running but the upstream is still warming up (for example, immediately after a Docker daemon restart), NemoClaw rebuilds the gateway instead of trusting stale metadata. On the Docker-driver gateway path, preflight stays read-only when it detects a stale gateway (for example, a Docker-driver runtime env hash drift). It prints a `⚠ Gateway will be recreated when sandbox creation starts` notice and defers the actual teardown to step `[2/8] Starting OpenShell gateway`. This means pressing `Ctrl+C` between preflight and step `[2/8]` leaves the running gateway and existing sandbox containers untouched, so `$$nemoclaw onboard` is safe to run just to check preflight output. An interrupted run prints the resume command and exits with status `130` for `Ctrl+C` or `143` for `SIGTERM`. For Linux Docker-driver gateways, onboarding also checks that a helper container on the OpenShell Docker network can reach `host.openshell.internal:`. If a host firewall blocks that sandbox path, onboarding exits with a `sudo ufw allow from to port proto tcp` command before it reports the gateway healthy. Set `NEMOCLAW_AUTO_FIX_FIREWALL=1` to opt in to automatic UFW remediation for this specific failure: NemoClaw uses `sudo -n` only, validates the Docker bridge subnet/gateway/port, applies the narrow UFW rule only after a proven TCP reachability failure, and re-probes before continuing. If passwordless sudo, UFW, or active UFW is unavailable, NemoClaw falls back to the manual guidance path without prompting for a password. + +For the portable experimental profile, the helper maps `host.openshell.internal` to the OpenShell Podman host gateway instead of the inspected network gateway. This path does not use Docker bridge UFW remediation. After all portable TCP probe attempts fail, onboarding prints commands for the user-scoped Podman service and socket. + +Onboarding prints the same commands when the portable probe cannot reach the user-scoped Podman service. The printed rerun command keeps the portable experimental profile selected. Portable commands reconstruct the current user's rootless Podman socket authority from NemoClaw state before they use the Docker-compatible API. They do not select an endpoint from ambient Docker or Podman runtime variables or named connections. When `podman.service` reports inactive and the recorded socket exists, NemoClaw first makes one 10-second API request through the guarded recorded authority. A valid server version classifies the endpoint as warm and avoids starting another socket service. A missing socket or a response without a valid server version enters bounded cold activation. Any socket authority change during this precheck fails at the socket authority stage. When the user-scoped socket-backed service needs activation, NemoClaw activates it and waits through a bounded startup period for a real Podman API response. During cold activation, the first API probe can cause systemd to replace the socket inode. NemoClaw requalifies one such replacement and repeats the probe only when the socket path, device, mode, owner, and complete directory authority remain unchanged. Any other authority change or a second inode replacement fails the readiness check. After cold activation succeeds, later API health checks use the fixed 10-second steady-state deadline. Onboarding and portable sandbox lifecycle commands use this same readiness contract. Failures identify socket authority, service activation, startup API health, or steady-state API health without reporting credentials. NemoClaw does not fall back to Docker or report an absent or unreachable endpoint as healthy. A successful cold path uses the `cold` timing label and reports activation, API, and total time in milliseconds. A successful warm path uses the `warm` timing label and reports steady-state API and total time in milliseconds. To tune the existing-gateway HTTP health poll, use `NEMOCLAW_REUSE_HEALTH_POLL_COUNT` (default `6`) and `NEMOCLAW_REUSE_HEALTH_POLL_INTERVAL` (default `5` seconds). The poll count is clamped to a minimum of `1` so the health probe always runs at least once, and the interval is clamped to a minimum of `0` (no sleep between attempts). + +The Docker-driver gateway and the portable experimental profile's Podman-driver gateway resolve to the same default state directory when they use the same gateway port, because NemoClaw scopes that directory by port, not by driver. Selecting the portable experimental profile on a host that already has a Docker-driver gateway therefore refuses to rewrite that gateway's config rather than silently repurposing it for Podman. The error names the driver the existing config already uses and the driver this run selected. Docker and Podman gateways cannot reuse one state directory. For NemoClaw-managed state, switch drivers with the applicable `$$nemoclaw uninstall` path, then retry onboarding. Uninstall preserves externally managed or supervised state; resolve that state through its lifecycle authority instead. To run both drivers concurrently, select an unused gateway port with `NEMOCLAW_GATEWAY_PORT=` and a separate state directory with `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR=`. #### `--from ` -Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. -NemoClaw validates the complete three-agent publication cohort before selecting any member. -If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. -Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. -The portable experimental profile and native Podman are not part of this activation. - -Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. -The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. -The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. -When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths. -This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`. -For this managed exception, onboarding applies the `.dockerignore` from the repository root. -For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker. -NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them. -Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context. -Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them. -If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts. -Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. -If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. +Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. + +Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths. This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`. For this managed exception, onboarding applies the `.dockerignore` from the repository root. For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker. NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them. Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context. Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them. If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts. Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. -NemoClaw builds user-supplied `--from` contexts with the OpenShell gateway builder. -The host-side local BuildKit prebuild is limited to build contexts generated entirely by NemoClaw. -On a local Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and onboarding continues with the custom image. + NemoClaw builds user-supplied `--from` contexts with the OpenShell gateway builder. The host-side + local BuildKit prebuild is limited to build contexts generated entirely by NemoClaw. On a local + Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and onboarding + continues with the custom image. ```bash $$nemoclaw onboard --from path/to/Dockerfile ``` -The Dockerfile path must exist. -Missing paths fail during command parsing before preflight, gateway setup, inference setup, or sandbox creation starts. +The Dockerfile path must exist. Missing paths fail during command parsing before preflight, gateway setup, inference setup, or sandbox creation starts. -If deployment verification cannot reach the gateway for a custom OpenClaw image, NemoClaw checks for `/tmp/gateway.log`, `/usr/local/bin/nemoclaw-start`, and `/sandbox/.openclaw/openclaw.json`. -When all three paths are absent, onboarding reports that the custom image lacks the managed runtime instead of treating repeated port-forward retries as the recovery path. -For the version-matched full-runtime plugin workflow, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). +If deployment verification cannot reach the gateway for a custom OpenClaw image, NemoClaw checks for `/tmp/gateway.log`, `/usr/local/bin/nemoclaw-start`, and `/sandbox/.openclaw/openclaw.json`. When all three paths are absent, onboarding reports that the custom image lacks the managed runtime instead of treating repeated port-forward retries as the recovery path. For the version-matched full-runtime plugin workflow, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). -The file can have any name; if it is not already named `Dockerfile`, onboard copies it to `Dockerfile` inside the staged build context automatically. -To create an isolated build context, create a dedicated directory that contains only the Dockerfile and the files it needs: +The file can have any name; if it is not already named `Dockerfile`, onboard copies it to `Dockerfile` inside the staged build context automatically. To create an isolated build context, create a dedicated directory that contains only the Dockerfile and the files it needs: ```text build-dir/ @@ -1090,27 +734,18 @@ build-dir/ For faster custom builds, plan for Docker cache behavior: -- Treat the first build on a fresh host as a cold build. - Cold builds download the base image and package indexes, so they take longer than later warm rebuilds even when NemoClaw is healthy. +- Treat the first build on a fresh host as a cold build. Cold builds download the base image and package indexes, so they take longer than later warm rebuilds even when NemoClaw is healthy. - A warm rebuild reuses cached layers when the base image and earlier layers are unchanged, so it is much faster than the first build. -- Order Dockerfile instructions from least-changing to most-changing: base image, system packages, dependency manifests, dependency install, then application source. - This lets warm rebuilds reuse cached dependency layers instead of reinstalling on every source change. +- Order Dockerfile instructions from least-changing to most-changing: base image, system packages, dependency manifests, dependency install, then application source. This lets warm rebuilds reuse cached dependency layers instead of reinstalling on every source change. - Pin the base image to an explicit tag or digest so warm rebuilds resolve the same cached base instead of pulling a new one. -To diagnose where a slow build spends time, set `NEMOCLAW_TRACE=1` and read the phase timings in [Onboard Profiling Traces](#onboard-profiling-traces). -NemoClaw does not guarantee build timings. +To diagnose where a slow build spends time, set `NEMOCLAW_TRACE=1` and read the phase timings in [Onboard Profiling Traces](#onboard-profiling-traces). NemoClaw does not guarantee exact build timings. All NemoClaw build arguments (`NEMOCLAW_MODEL`, `NEMOCLAW_INFERENCE_PROVIDER_ID`, `NEMOCLAW_INFERENCE_BASE_URL`, etc.) are injected as `ARG` overrides at build time, so declare them in your Dockerfile if you need to reference them. -`NEMOCLAW_INFERENCE_PROVIDER_ID` is a non-secret inference route identifier (for example `inference` for proxied providers, or a provider family such as `openai`), never a credential; provider credentials stay in OpenShell provider storage. -It replaces the former `NEMOCLAW_PROVIDER_KEY` image argument, whose secret-shaped name triggered a BuildKit `SecretsUsedInArgOrEnv` warning. -The host-side `NEMOCLAW_PROVIDER_KEY` credential alias is unchanged; this migration only renames the managed image route selector. -Custom Dockerfiles that declare either `ARG NEMOCLAW_INFERENCE_PROVIDER_ID` or the legacy `ARG NEMOCLAW_PROVIDER_KEY` continue working in v0.0.91. -NemoClaw updates whichever supported declaration is present, and runtime consumers read the legacy name as a fallback. -Rename the legacy `ARG`/`ENV` declaration to `NEMOCLAW_INFERENCE_PROVIDER_ID`; the legacy fallback is retained for compatibility in this release and may be removed in a future release. +`NEMOCLAW_INFERENCE_PROVIDER_ID` is a non-secret inference route identifier (for example `inference` for proxied providers, or a provider family such as `openai`), never a credential; provider credentials stay in OpenShell provider storage. It replaces the former `NEMOCLAW_PROVIDER_KEY` image argument, whose secret-shaped name triggered a BuildKit `SecretsUsedInArgOrEnv` warning. The host-side `NEMOCLAW_PROVIDER_KEY` credential alias is unchanged; this migration only renames the managed image route selector. Custom Dockerfiles that declare either `ARG NEMOCLAW_INFERENCE_PROVIDER_ID` or the legacy `ARG NEMOCLAW_PROVIDER_KEY` continue working in v0.0.91. NemoClaw updates whichever supported declaration is present, and runtime consumers read the legacy name as a fallback. Rename the legacy `ARG`/`ENV` declaration to `NEMOCLAW_INFERENCE_PROVIDER_ID`; the legacy fallback is retained for compatibility in this release and may be removed in a future release. -Custom Dockerfiles must declare `ARG NEMOCLAW_TOOL_DISCLOSURE=progressive` exactly once in the final build stage and promote it into that stage's runtime environment. -The usual runtime contract is: +Custom Dockerfiles must declare `ARG NEMOCLAW_TOOL_DISCLOSURE=progressive` exactly once in the final build stage and promote it into that stage's runtime environment. The usual runtime contract is: ```dockerfile ARG NEMOCLAW_TOOL_DISCLOSURE=progressive @@ -1119,8 +754,7 @@ ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} Onboarding and rebuild preflight reject a missing, duplicate, or unconsumed declaration before replacing an existing sandbox. -In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable. -You must also supply a sandbox name via `--name ` or `NEMOCLAW_SANDBOX_NAME` so a `--from` build cannot silently clobber the default `my-assistant` sandbox. +In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable. You must also supply a sandbox name via `--name ` or `NEMOCLAW_SANDBOX_NAME` so a `--from` build cannot silently clobber the default `my-assistant` sandbox. ```bash NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_FROM_DOCKERFILE=path/to/Dockerfile NEMOCLAW_SANDBOX_NAME=my-build $$nemoclaw onboard @@ -1130,27 +764,17 @@ If a `--resume` is attempted with a different `--from` path than the original se #### `--name ` -Set the sandbox name without going through the interactive prompt. -The same name format and reserved-name rules that the wizard enforces apply here too. -Names must contain 1 to 19 characters. -They must be lowercase, start with a letter, contain only letters, numbers, and single internal hyphens, and end with a letter or number. -Consecutive hyphens (`--`) are not allowed. -Names that match a NemoClaw CLI command (`status`, `list`, `debug`, etc.) are rejected up front. +Set the sandbox name without going through the interactive prompt. The same name format and reserved-name rules that the wizard enforces apply here too. Names must contain 1 to 19 characters. They must be lowercase, start with a letter, contain only letters, numbers, and single internal hyphens, and end with a letter or number. Consecutive hyphens (`--`) are not allowed. Names that match a NemoClaw CLI command (`status`, `list`, `debug`, etc.) are rejected up front. ```bash $$nemoclaw onboard --non-interactive --name my-build --from path/to/Dockerfile ``` -The flag wins over `NEMOCLAW_SANDBOX_NAME`. -When prompting is possible, `NEMOCLAW_SANDBOX_NAME` fills the interactive default so you can press Enter to accept it. -When prompting is impossible (no TTY or `--non-interactive`), the env var is also honoured so existing CI scripts keep working. -Combining `--from ` with non-interactive onboarding requires one of `--name` or `NEMOCLAW_SANDBOX_NAME`; otherwise onboarding exits rather than silently defaulting to `my-assistant` and clobbering the default sandbox. +The flag wins over `NEMOCLAW_SANDBOX_NAME`. When prompting is possible, `NEMOCLAW_SANDBOX_NAME` fills the interactive default so you can press Enter to accept it. When prompting is impossible (no TTY or `--non-interactive`), the env var is also honoured so existing CI scripts keep working. Combining `--from ` with non-interactive onboarding requires one of `--name` or `NEMOCLAW_SANDBOX_NAME`; otherwise onboarding exits rather than silently defaulting to `my-assistant` and clobbering the default sandbox. ### `$$nemoclaw onboard --from` -Use a custom Dockerfile for the sandbox image. -This variant of `$$nemoclaw onboard` accepts a `--from ` argument to build the sandbox from a user-supplied Dockerfile instead of the default NemoClaw image. -The user-supplied context uses the OpenShell gateway builder instead of NemoClaw's host-side local BuildKit prebuild. +Use a custom Dockerfile for the sandbox image. This variant of `$$nemoclaw onboard` accepts a `--from ` argument to build the sandbox from a user-supplied Dockerfile instead of the default NemoClaw image. The user-supplied context uses the OpenShell gateway builder instead of NemoClaw's host-side local BuildKit prebuild. ```bash $$nemoclaw onboard --from ./Dockerfile.custom @@ -1158,51 +782,11 @@ $$nemoclaw onboard --from ./Dockerfile.custom ### GPU Passthrough -When `$$nemoclaw onboard` detects an NVIDIA GPU on the host, it enables OpenShell GPU passthrough at both the gateway and sandbox level by default. -The `nvidia-smi` probes require a successful result and reject placeholder `JMJWOA-Generic-*` GPU names unless NemoClaw can prove a supported NVIDIA platform or GPU execution. -NemoClaw treats a recognized NVIDIA product model from `/sys/class/dmi/id/product_name` or `/sys/firmware/devicetree/base/model`, or a known Tegra device node, as authoritative platform identity. -On eligible native or Docker Desktop-backed WSL ARM64 Linux hosts without that firmware evidence, one bounded Docker CUDA workload can prove GPU execution. -On those hosts, a single plausible, non-placeholder NVIDIA GPU name also requires that proof when the NVIDIA kernel-driver interface (`/proc/driver/nvidia`) is absent. -For Windows-on-Arm, this proof is a technical detection check and does not change the Unsupported product status or establish platform qualification. -Refer to [Platform Support and Launch Claims](platform-support#out-of-scope-and-not-supported) for the current support boundary. -For the proof command, timeout control, and failure recovery, refer to [GPU Setup Fails with a Placeholder GPU Name](troubleshooting#gpu-setup-fails-with-a-placeholder-gpu-name). -The names-only unified-memory fallback does not run this workload and rejects denylisted names. -Other non-firmware-vouched hosts also reject denylisted names. -Jetson/Tegra hosts that ship without `nvidia-smi` continue to be detected via the devicetree firmware fallback (`/sys/firmware/devicetree/base/model`) or the Tegra device-node fallback (`/dev/nvhost-gpu`, `/dev/nvhost-ctrl-gpu`, `/dev/nvhost-ctrl`, or `/dev/nvmap`); both bypass the trust-tier gate above. -Use `--no-gpu` to opt out when you want host-side inference providers only and do not need direct GPU access inside the sandbox. -Use `--gpu` to require GPU passthrough and fail fast if an NVIDIA GPU is not detected. -Use `--sandbox-gpu` or `--no-sandbox-gpu` to control only direct NVIDIA GPU access inside the sandbox. -Use `--sandbox-gpu --sandbox-gpu-device ` to select an NVIDIA GPU by index (`0`), GPU UUID (`GPU-...`), or full CDI device name (`nvidia.com/gpu=0`). -NemoClaw preserves the selection on resume. -Use `--vllm-gpu-device ` to select the host GPU for the vLLM container that NemoClaw installs and manages. -This selection is separate from sandbox GPU access, and NemoClaw also preserves it on resume. -The selected GPU must satisfy the model's memory and compute-capability requirements. -For native Docker and Podman creation, NemoClaw passes the normalized CDI name through OpenShell driver config; compatibility routes use the equivalent container-runtime selector. -Device selection requires explicit sandbox GPU enablement. -On ordinary native Linux Docker-driver hosts, NemoClaw uses native OpenShell GPU injection by default and never broadens confinement automatically. - -Portable onboarding requires native OpenShell GPU injection for every agent. -It does not use `NEMOCLAW_DOCKER_GPU_PATCH` compatibility routing, so do not set `fallback`, `1`, or another legacy nonzero value for that profile. - -Set `NEMOCLAW_DOCKER_GPU_PATCH=fallback` to explicitly authorize one native attempt followed by one compatibility retry. -NemoClaw permits the retry only after it confirms either a trusted host-side GPU routing failure or an explicit driver proof plus container host configuration showing that no GPU was attached. -It then saves redacted diagnostics and removes the incomplete sandbox before retrying. -Sandbox-reported CUDA output alone never authorizes the broader compatibility envelope, even when the operator enabled fallback. -That case fails closed and points to the explicit `NEMOCLAW_DOCKER_GPU_PATCH=1` compatibility-only control. -NemoClaw retries only after it verifies that no OpenShell-managed Docker container labeled for that sandbox remains; if cleanup cannot be proven safe, onboarding stops and prints cleanup guidance instead. -On Docker Desktop WSL and Jetson/Tegra, automatic GPU onboarding uses the compatibility path directly. -On ordinary native Linux, the compatibility path uses an available NVIDIA CDI spec before falling back to Docker `--gpus all` or the NVIDIA runtime. -On Docker Desktop WSL, the compatibility path skips CDI and tries Docker `--gpus all` before the NVIDIA runtime. -On Jetson/Tegra hosts, the compatibility path uses the NVIDIA runtime and adds eligible host group IDs for the supported GPU device nodes. -These include selected `/dev/nvmap`, `/dev/nvhost-*`, and `/dev/nvgpu/igpu0/*` nodes plus real `/dev/dri/renderD*` character devices. -After compatibility recreation starts, onboarding keeps the pre-patch container as a rollback backup until the replacement passes the Ready, GPU, and applicable local-inference checks. -If one of those checks fails before backup removal, onboarding prints failure diagnostics and attempts to restore the pre-patch container. -To commit the replacement, NemoClaw first asks OpenShell to stop the sandbox so its durable lifecycle row reaches `Stopped` before any irreversible Docker mutation. -It then stops the exact transaction-owned replacement, removes the rollback backup, and asks OpenShell to start the sandbox so OpenShell owns the `Starting` lifecycle fence. -NemoClaw verifies a `Ready` row, a working sandbox exec, and that the exact replacement is the sole running labeled container within the final handoff deadline. -If that final handoff cannot be confirmed, onboarding exits with the container diagnostics and cleanup guidance instead of reporting success. -If rollback fails, onboarding reports that the pre-patch container was not restored and prints container-cleanup guidance. -GPU-proof diagnostics are captured before rollback and can print that guidance before the final container state is known, so inspect the sandbox and its labeled Docker containers before running a deletion command. +When `$$nemoclaw onboard` detects an NVIDIA GPU on the host, it enables OpenShell GPU passthrough at both the gateway and sandbox level by default. The `nvidia-smi` probes require a successful result and reject placeholder `JMJWOA-Generic-*` GPU names unless NemoClaw can prove a supported NVIDIA platform or GPU execution. NemoClaw treats a recognized NVIDIA product model from `/sys/class/dmi/id/product_name` or `/sys/firmware/devicetree/base/model`, or a known Tegra device node, as authoritative platform identity. On eligible native or Docker Desktop-backed WSL ARM64 Linux hosts without that firmware evidence, one bounded Docker CUDA workload can prove GPU execution. On those hosts, a single plausible, non-placeholder NVIDIA GPU name also requires that proof when the NVIDIA kernel-driver interface (`/proc/driver/nvidia`) is absent. For Windows-on-Arm, this proof is a technical detection check and does not change the Unsupported product status or establish platform qualification. Refer to [Platform Support and Launch Claims](platform-support#out-of-scope-and-not-supported) for the current support boundary. For the proof command, timeout control, and failure recovery, refer to [GPU Setup Fails with a Placeholder GPU Name](troubleshooting#gpu-setup-fails-with-a-placeholder-gpu-name). The names-only unified-memory fallback does not run this workload and rejects denylisted names. Other non-firmware-vouched hosts also reject denylisted names. Jetson/Tegra hosts that ship without `nvidia-smi` continue to be detected via the devicetree firmware fallback (`/sys/firmware/devicetree/base/model`) or the Tegra device-node fallback (`/dev/nvhost-gpu`, `/dev/nvhost-ctrl-gpu`, `/dev/nvhost-ctrl`, or `/dev/nvmap`); both bypass the trust-tier gate above. Use `--no-gpu` to opt out when you want host-side inference providers only and do not need direct GPU access inside the sandbox. Use `--gpu` to require GPU passthrough and fail fast if an NVIDIA GPU is not detected. Use `--sandbox-gpu` or `--no-sandbox-gpu` to control only direct NVIDIA GPU access inside the sandbox. Use `--sandbox-gpu --sandbox-gpu-device ` to select an NVIDIA GPU by index (`0`), GPU UUID (`GPU-...`), or full CDI device name (`nvidia.com/gpu=0`). NemoClaw preserves the selection on resume. Use `--vllm-gpu-device ` to select the host GPU for the vLLM container that NemoClaw installs and manages. This selection is separate from sandbox GPU access, and NemoClaw also preserves it on resume. The selected GPU must satisfy the model's memory and compute-capability requirements. For native Docker and Podman creation, NemoClaw passes the normalized CDI name through OpenShell driver config; compatibility routes use the equivalent container-runtime selector. Device selection requires explicit sandbox GPU enablement. On ordinary native Linux Docker-driver hosts, NemoClaw uses native OpenShell GPU injection by default and never broadens confinement automatically. + +Portable onboarding requires native OpenShell GPU injection for every agent. It does not use `NEMOCLAW_DOCKER_GPU_PATCH` compatibility routing, so do not set `fallback`, `1`, or another legacy nonzero value for that profile. + +Set `NEMOCLAW_DOCKER_GPU_PATCH=fallback` to explicitly authorize one native attempt followed by one compatibility retry. NemoClaw permits the retry only after it confirms either a trusted host-side GPU routing failure or an explicit driver proof plus exact-container host configuration showing that no GPU was attached. It then saves redacted diagnostics and removes the incomplete sandbox before retrying. Sandbox-reported CUDA output alone never authorizes the broader compatibility envelope, even when the operator enabled fallback. That case fails closed and points to the explicit `NEMOCLAW_DOCKER_GPU_PATCH=1` compatibility-only control. NemoClaw retries only after it verifies that no OpenShell-managed Docker container labeled for that sandbox remains; if cleanup cannot be proven safe, onboarding stops and prints cleanup guidance instead. On Docker Desktop WSL and Jetson/Tegra, automatic GPU onboarding uses the compatibility path directly. On ordinary native Linux, the compatibility path uses an available NVIDIA CDI spec before falling back to Docker `--gpus all` or the NVIDIA runtime. On Docker Desktop WSL, the compatibility path skips CDI and tries Docker `--gpus all` before the NVIDIA runtime. On Jetson/Tegra hosts, the compatibility path uses the NVIDIA runtime and adds eligible host group IDs for the supported GPU device nodes. These include selected `/dev/nvmap`, `/dev/nvhost-*`, and `/dev/nvgpu/igpu0/*` nodes plus real `/dev/dri/renderD*` character devices. After compatibility recreation starts, onboarding keeps the pre-patch container as a rollback backup until the replacement passes the Ready, GPU, and applicable local-inference checks. If one of those checks fails before backup removal, onboarding prints failure diagnostics and attempts to restore the pre-patch container. To commit the replacement, NemoClaw first asks OpenShell to stop the sandbox so its durable lifecycle row reaches `Stopped` before any irreversible Docker mutation. It then stops the exact transaction-owned replacement, removes the rollback backup, and asks OpenShell to start the sandbox so OpenShell owns the `Starting` lifecycle fence. NemoClaw verifies a `Ready` row, a working sandbox exec, and that the exact replacement is the sole running labeled container within the final handoff deadline. If that final handoff cannot be confirmed, onboarding exits with the container diagnostics and cleanup guidance instead of reporting success. If rollback fails, onboarding reports that the pre-patch container was not restored and prints container-cleanup guidance. GPU-proof diagnostics are captured before rollback and can print that guidance before the final container state is known, so inspect the sandbox and its labeled Docker containers before running a deletion command. Prerequisites: @@ -1211,38 +795,18 @@ Prerequisites: - On Jetson/Tegra hosts shipping without `nvidia-smi`, the devicetree firmware fallback substitutes. - NVIDIA Container Toolkit configured for Docker. -When GPU passthrough is enabled and a gateway already exists without it, onboarding first checks whether replacing the CPU-only gateway is safe. -If no other registered sandbox depends on that gateway, or if `--recreate-sandbox` is recreating the only registered sandbox with the same name, onboarding cleans up the stale gateway and continues. -If other sandboxes depend on the gateway or Docker state is unclear, onboarding exits without cleanup and prints targeted destroy or gateway-removal guidance. -To add GPU to an existing sandbox, rerun with `--recreate-sandbox`. -Leave `NEMOCLAW_DOCKER_GPU_PATCH` unset or set it to `auto` for native-only GPU onboarding on ordinary native Linux. -Set `NEMOCLAW_DOCKER_GPU_PATCH=fallback` to explicitly opt into one bounded native-to-compatibility retry on ordinary native Linux. -Set `NEMOCLAW_DOCKER_GPU_PATCH=0` to require native OpenShell GPU injection on ordinary native Linux or Jetson/Tegra. -Set `NEMOCLAW_DOCKER_GPU_PATCH=1` to use only the compatibility path on ordinary native Linux. -Other legacy nonzero values keep that behavior through the `v0.0.x` release line and will be removed in `v0.1.0`. -Use `NEMOCLAW_DOCKER_GPU_PATCH=0` on Jetson/Tegra only for troubleshooting because it bypasses Tegra device-group propagation and CUDA may not initialize. -Docker Desktop WSL ignores `NEMOCLAW_DOCKER_GPU_PATCH=0` because GPU passthrough on that runtime requires the compatibility patch. -Use `--no-sandbox-gpu`, `--no-gpu`, or `NEMOCLAW_SANDBOX_GPU=0` when you want to disable sandbox GPU passthrough on Docker Desktop WSL. +When GPU passthrough is enabled and a gateway already exists without it, onboarding first checks whether replacing the CPU-only gateway is safe. If no other registered sandbox depends on that gateway, or if `--recreate-sandbox` is recreating the only registered sandbox with the same name, onboarding cleans up the stale gateway and continues. If other sandboxes depend on the gateway or Docker state is unclear, onboarding exits without cleanup and prints targeted destroy or gateway-removal guidance. To add GPU to an existing sandbox, rerun with `--recreate-sandbox`. Leave `NEMOCLAW_DOCKER_GPU_PATCH` unset or set it to `auto` for native-only GPU onboarding on ordinary native Linux. Set `NEMOCLAW_DOCKER_GPU_PATCH=fallback` to explicitly opt into one bounded native-to-compatibility retry on ordinary native Linux. Set `NEMOCLAW_DOCKER_GPU_PATCH=0` to require native OpenShell GPU injection on ordinary native Linux or Jetson/Tegra. Set `NEMOCLAW_DOCKER_GPU_PATCH=1` to use only the compatibility path on ordinary native Linux. Other legacy nonzero values keep that behavior through the `v0.0.x` release line and will be removed in `v0.1.0`. Use `NEMOCLAW_DOCKER_GPU_PATCH=0` on Jetson/Tegra only for troubleshooting because it bypasses Tegra device-group propagation and CUDA may not initialize. Docker Desktop WSL ignores `NEMOCLAW_DOCKER_GPU_PATCH=0` because GPU passthrough on that runtime requires the compatibility patch. Use `--no-sandbox-gpu`, `--no-gpu`, or `NEMOCLAW_SANDBOX_GPU=0` when you want to disable sandbox GPU passthrough on Docker Desktop WSL. ### `$$nemoclaw list` -List all registered sandboxes with their model, provider, and policy presets. -Pass `--json` for machine-readable output that includes a `schemaVersion`, the default sandbox, recovery metadata, and the sandbox inventory. -When the latest resumable onboarding session owns the matching inference-route reservation but has not created a sandbox, text output shows it under `Incomplete onboarding` with the recorded step and resume command. -JSON output reports the same state in `incompleteOnboarding`; it remains separate from `sandboxes` and never affects the default sandbox. -When present, `incompleteOnboarding` contains `name`, `status` (`failed` or `in_progress`), `step` (a string or `null`), `interrupted` (a boolean), and `resumable: true`; otherwise it is `null`. -NemoClaw does not expose stale reservations that belong to another onboarding session. -Each sandbox row reports `activeSessionCount` as a nonnegative integer when the SSH-session probe is available and `null` when it is unavailable. -Each sandbox row reports `agent` as a string in both text and JSON output, never `null`. -The row reports `openclaw` when the registry records no agent for the sandbox. -The row reports `unknown` for a sandbox that `$$nemoclaw list` recovers from the live OpenShell gateway. -The gateway sandbox list does not expose the agent. -The row does not include the former derived `connected` boolean. -Sandboxes with an active SSH session are marked with a `●` indicator so you can tell at a glance which sandbox you are already connected to in another terminal. +List all registered sandboxes with their model, provider, and policy presets. Pass `--json` for machine-readable output that includes a `schemaVersion`, the default sandbox, recovery metadata, and the sandbox inventory. When the latest resumable onboarding session owns the matching inference-route reservation but has not created a sandbox, text output shows it under `Incomplete onboarding` with the recorded step and resume command. JSON output reports the same state in `incompleteOnboarding`; it remains separate from `sandboxes` and never affects the default sandbox. When present, `incompleteOnboarding` contains `name`, `status` (`failed` or `in_progress`), `step` (a string or `null`), `interrupted` (a boolean), and `resumable: true`; otherwise it is `null`. NemoClaw does not expose stale reservations that belong to another onboarding session. Each sandbox row reports `activeSessionCount` as a nonnegative integer when the SSH-session probe is available and `null` when it is unavailable. Each sandbox row reports `agent` as a string in both text and JSON output, never `null`. The row reports `openclaw` when the registry records no agent for the sandbox. The row reports `unknown` for a sandbox that `$$nemoclaw list` recovers from the live OpenShell gateway. The gateway sandbox list does not expose the agent. The row does not include the former derived `connected` boolean. Sandboxes with an active SSH session are marked with a `●` indicator so you can tell at a glance which sandbox you are already connected to in another terminal. + -When a sandbox has a recorded dashboard port, the output includes its local dashboard URL. + When a sandbox has a recorded dashboard port, the output includes its local dashboard URL. -The default sandbox in text and JSON output honors the same environment override order as host-level status and tunnel commands: `NEMOCLAW_SANDBOX_NAME`, then `NEMOCLAW_SANDBOX`, then `SANDBOX_NAME`, then the registry default. +The default sandbox in text and JSON output honors the same environment override order as host-level +status and tunnel commands: `NEMOCLAW_SANDBOX_NAME`, then `NEMOCLAW_SANDBOX`, then `SANDBOX_NAME`, +then the registry default. ```bash $$nemoclaw list [--json] @@ -1251,13 +815,9 @@ $$nemoclaw list --json ### `$$nemoclaw use ` -Promote a registered sandbox to the default. -This is the first-class replacement for hand-editing `~/.nemoclaw/sandboxes.json`; it updates the registry through the same atomic, lock-guarded path that `$$nemoclaw onboard` uses for the initial default. -Subsequent commands and the `NEMOCLAW_SANDBOX_NAME` resolution order then pick up the new default automatically. -Pass `--json` to receive a machine-readable result indicating whether the registry was updated, the sandbox was already the default, or the name is unknown. +Promote a registered sandbox to the default. This is the first-class replacement for hand-editing `~/.nemoclaw/sandboxes.json`; it updates the registry through the same atomic, lock-guarded path that `$$nemoclaw onboard` uses for the initial default. Subsequent commands and the `NEMOCLAW_SANDBOX_NAME` resolution order then pick up the new default automatically. Pass `--json` to receive a machine-readable result indicating whether the registry was updated, the sandbox was already the default, or the name is unknown. -`$$nemoclaw use` is a thin selector and never mutates the sandbox itself. -It fails with a non-zero exit and a known-sandbox list when the requested name is not registered, so scripts can branch safely on the outcome. +`$$nemoclaw use` is a thin selector and never mutates the sandbox itself. It fails with a non-zero exit and a known-sandbox list when the requested name is not registered, so scripts can branch safely on the outcome. ```bash $$nemoclaw use @@ -1266,96 +826,60 @@ $$nemoclaw use --json ### `$$nemoclaw launch ` -Connect to a sandbox and start its agent in one host-side command. -Use it instead of running `$$nemoclaw connect` and then typing the agent command inside the sandbox. +Connect to a sandbox and start its agent in one host-side command. Use it instead of running `$$nemoclaw connect` and then typing the agent command inside the sandbox. -`launch` runs the complete preflight from [`$$nemoclaw connect`](#$$nemoclaw-name-connect) when no launch-readiness lease is usable. -That path includes the readiness wait, in-sandbox agent process recovery, and inference-route reconciliation. -A successful complete preflight can publish a credential-free launch-readiness lease with a fixed 24-hour lifetime on Linux. -Lease acceptance and publication are currently Linux-only and require a secure, independently writable OS per-user runtime authority under `/run/user/`. -It never uses caller-provided environment variables to select this authority. -On macOS, `launch` runs the complete preflight every time and does not publish a launch-readiness lease. +`launch` runs the complete preflight from [`$$nemoclaw connect`](#$$nemoclaw-name-connect) when no launch-readiness lease is usable. That path includes the readiness wait, in-sandbox agent process recovery, and inference-route reconciliation. A successful complete preflight can publish a credential-free launch-readiness lease with a fixed 24-hour lifetime on Linux. Lease acceptance and publication are currently Linux-only and require a secure, independently writable OS per-user runtime authority under `/run/user/`. It never uses caller-provided environment variables to select this authority. On macOS, `launch` runs the complete preflight every time and does not publish a launch-readiness lease. During that lease, another `launch` still verifies these conditions: -- The owning OpenShell gateway reports the sandbox identity in the `Ready` or `Running` state. -- The sandbox registry, agent manifest, interactive command, policy intent, and effective parsed OpenShell network policy match the recorded identity. +- The owning OpenShell gateway reports the exact sandbox identity in the `Ready` or `Running` state. +- The sandbox registry, agent manifest, and interactive command match the recorded identity, and the current OpenShell policy is readable and valid. The lease stores no policy hash, so trusted host-side policy changes do not invalidate launch readiness. + -- The recorded inference selection matches the live route, and `inference.local` returns HTTP 2xx from its semantic probe when inference is configured. - This is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. +- The recorded inference selection matches the live route, and `inference.local` returns HTTP 2xx from its semantic probe when inference is configured. This is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. -- The recorded inference selection matches the live route, and `inference.local` returns HTTP 2xx from its semantic probe for every configured provider except `openrouter-api`. - OpenRouter's HTTP 404 response for `GET /v1/models` passes only after a bounded inference request for the recorded model succeeds. - This is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. +- The recorded inference selection matches the live route, and `inference.local` returns HTTP 2xx from its semantic probe for every configured provider except `openrouter-api`. OpenRouter's HTTP 404 response for `GET /v1/models` passes only after a bounded inference request for the recorded model succeeds. This is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. - The agent runtime and its required host-side forwards pass their semantic health checks. -For OpenClaw, `connect --probe-only` also settles the existing allowlisted pairing flow before it publishes the lease. -The readiness evidence binds the OpenClaw version and trusted registry and agent manifest configuration. -Its credential-free pairing qualification binds the canonical CLI client, paired device identity, required operator role and scopes, owning OpenShell gateway, sandbox lifecycle identity, and fixed lease epoch. -Before accepting that evidence, `launch` makes a bounded, read-only observation of the current OpenClaw-owned pairing state through the owning OpenShell gateway. -It skips the complete pairing approval pass only when the evidence still matches exactly and no relevant allowlisted request is pending. -Missing, unreadable, malformed, ambiguous, or changed pairing evidence runs the complete pairing approval pass. -A relevant allowlisted pending request also runs that complete path, so late scope requests remain eligible for approval. +For OpenClaw, `connect --probe-only` also settles the existing allowlisted pairing flow before it publishes the lease. The readiness evidence binds the OpenClaw version and trusted registry and agent manifest configuration. Its credential-free pairing qualification binds the canonical CLI client, exact paired device identity, required operator role and scopes, owning OpenShell gateway, sandbox lifecycle identity, and fixed lease epoch. Before accepting that evidence, `launch` makes a bounded, read-only observation of the current OpenClaw-owned pairing state through the owning OpenShell gateway. It skips the complete pairing approval pass only when the evidence still matches exactly and no relevant allowlisted request is pending. Missing, unreadable, malformed, ambiguous, or changed pairing evidence runs the complete pairing approval pass. A relevant allowlisted pending request also runs that complete path, so late scope requests remain eligible for approval. Hermes and LangChain Deep Agents Code retain their existing session setup on the lease-accepted path. -After these checks pass, `launch` can skip duplicate recovery, readiness polling, and inference-route repair. -The lease does not replace a health check or authorize repair. -For missing, expired, malformed, inaccessible, mismatched, or unhealthy evidence, NemoClaw fences any prior acceptable evidence before it runs the complete preflight. -Ordinary launch continues only when NemoClaw proves that no old authority or evidence can exist, or durably rotates the runtime epoch. -If an old epoch might exist and cannot be durably rotated, `launch` stops before complete preflight or recovery. -Its redacted guidance asks you to repair the current user's secure OS runtime authority and NemoClaw state permissions, then retry. -A failed live check never becomes a successful launch because a lease exists. - -Immediately before the first mutation in the complete preflight, the producer revalidates its sandbox-global runtime epoch while holding the sandbox lifecycle lock followed by the owning gateway lock. -It holds both locks through all mutations in the complete preflight, final state capture, and publication. -If another producer has replaced the epoch, the stale producer makes no changes and re-inspects the newer lease. - -The 24-hour lifetime does not extend when you launch repeatedly. -Exiting the agent with `/exit` does not revoke the lease. -If state changes before expiry, NemoClaw fences the old evidence and runs the complete preflight. -A successful preflight in that interval keeps the original start and expiry time. -After expiry, a successful complete preflight starts a new 24-hour lease only when publication succeeds. - -If unsafe or malformed authority history makes the prior lease timeline untrustworthy, NemoClaw durably invalidates the old epoch and starts one conservative 24-hour quarantine. -Both wall time and monotonic uptime must span the full quarantine, and publication remains disabled during it. -Repeated attempts do not extend the quarantine. -After it elapses, the next successful complete preflight can publish a new fixed 24-hour lease. -You do not create or refresh this lease manually, and `launch` has no lease-control flags. -After lease validation or the automatic fallback that runs the complete preflight, `launch` starts the sandbox's agent in your terminal instead of opening a sandbox shell. - -The agent command comes from the sandbox's agent manifest. -If the sandbox registry names a non-OpenClaw agent without a readable local agent manifest, `launch` exits before starting an in-sandbox command. - -| Agent | Command | -|---|---| -| OpenClaw | `openclaw tui` | -| Hermes | `hermes` | -| LangChain Deep Agents Code | `dcode` | +After these checks pass, `launch` can skip duplicate recovery, readiness polling, and inference-route repair. The lease does not replace a health check or authorize repair. For missing, expired, malformed, inaccessible, mismatched, or unhealthy evidence, NemoClaw fences any prior acceptable evidence before it runs the complete preflight. Ordinary launch continues only when NemoClaw proves that no old authority or evidence can exist, or durably rotates the runtime epoch. If an old epoch might exist and cannot be durably rotated, `launch` stops before complete preflight or recovery. Its redacted guidance asks you to repair the current user's secure OS runtime authority and NemoClaw state permissions, then retry. A failed live check never becomes a successful launch because a lease exists. + +Immediately before the first mutation in the complete preflight, the producer revalidates its sandbox-global runtime epoch while holding the sandbox lifecycle lock followed by the owning gateway lock. It holds both locks through all mutations in the complete preflight, final state capture, and publication. If another producer has replaced the epoch, the stale producer makes no changes and re-inspects the newer lease. + +The 24-hour lifetime does not extend when you launch repeatedly. Exiting the agent with `/exit` does not revoke the lease. If state changes before expiry, NemoClaw fences the old evidence and runs the complete preflight. A successful preflight in that interval keeps the original start and expiry time. After expiry, a successful complete preflight starts a new 24-hour lease only when publication succeeds. + +If unsafe or malformed authority history makes the prior lease timeline untrustworthy, NemoClaw durably invalidates the old epoch and starts one conservative 24-hour quarantine. Both wall time and monotonic uptime must span the full quarantine, and publication remains disabled during it. Repeated attempts do not extend the quarantine. After it elapses, the next successful complete preflight can publish a new fixed 24-hour lease. You do not create or refresh this lease manually, and `launch` has no lease-control flags. After lease validation or the automatic fallback that runs the complete preflight, `launch` starts the sandbox's agent in your terminal instead of opening a sandbox shell. + +The agent command comes from the sandbox's agent manifest. If the sandbox registry names a non-OpenClaw agent without a readable local agent manifest, `launch` exits before starting an in-sandbox command. + +| Agent | Command | +| -------------------------- | -------------- | +| OpenClaw | `openclaw tui` | +| Hermes | `hermes` | +| LangChain Deep Agents Code | `dcode` | ```bash $$nemoclaw launch ``` -The sandbox name is required, and the command takes no flags. -The sandbox must already exist in the local NemoClaw state. -If it is not registered locally, `launch` exits before it runs an OpenShell command or readiness recovery and reports that the sandbox is not registered in the local NemoClaw state. -When the agent exits, you return to the host shell. +The sandbox name is required, and the command takes no flags. The sandbox must already exist in the local NemoClaw state. If it is not registered locally, `launch` exits before it runs an OpenShell command or readiness recovery and reports that the sandbox is not registered in the local NemoClaw state. When the agent exits, you return to the host shell. -`launch` returns the agent's exit code when the post-command OpenClaw permission cleanup succeeds. -If that cleanup cannot inspect, restore, or verify the mutable config permission contract, `launch` fails closed with exit `1` and prints `OpenClaw permission cleanup failed (...)` to `stderr`. +`launch` returns the agent's exit code when the post-command OpenClaw permission cleanup succeeds. If that cleanup cannot inspect, restore, or verify the mutable config permission contract, `launch` fails closed with exit `1` and prints `OpenClaw permission cleanup failed (...)` to `stderr`. @@ -1369,14 +893,11 @@ When you want a shell inside the sandbox rather than an agent session, use `$$ne ### `$$nemoclaw deploy` -The `$$nemoclaw deploy` command is deprecated. -Prefer provisioning the remote host separately, then running the standard NemoClaw installer and `$$nemoclaw onboard` on that host. + The `$$nemoclaw deploy` command is deprecated. Prefer provisioning the remote host separately, + then running the standard NemoClaw installer and `$$nemoclaw onboard` on that host. -Deploy NemoClaw to a remote GPU instance through [Brev](https://brev.nvidia.com). -This command remains as a compatibility wrapper for the older Brev-specific bootstrap flow. -The Brev instance name is the positional argument. -The sandbox name comes from `NEMOCLAW_SANDBOX_NAME` and defaults to `my-assistant`; invalid sandbox names fail before Brev provisioning starts. +Deploy NemoClaw to a remote GPU instance through [Brev](https://brev.nvidia.com). This command remains as a compatibility wrapper for the older Brev-specific bootstrap flow. The Brev instance name is the positional argument. The sandbox name comes from `NEMOCLAW_SANDBOX_NAME` and defaults to `my-assistant`; invalid sandbox names fail before Brev provisioning starts. ```bash $$nemoclaw deploy @@ -1384,30 +905,13 @@ $$nemoclaw deploy ### `$$nemoclaw connect` -Connect to a sandbox by name. -Bare `$$nemoclaw connect` (no sandbox name) connects to the registry default. -NemoClaw uses the stored default when it names a non-pending registered sandbox, then falls back to the first non-pending registration. -If only pending registrations remain, the command exits non-zero and tells you to wait for onboarding or remove the incomplete sandbox. -If the registry remains empty after recovery, it tells you to run `$$nemoclaw onboard`. -A registered sandbox literally named `connect` keeps the name-first reading. -If the sandbox is not yet in the `Ready` phase, `connect` polls `openshell sandbox list` every few seconds and prints the current phase. This gives you progress output right after onboarding, when the 2.4 GB image is still pulling, instead of a silent hang. -Control the wait budget with `NEMOCLAW_CONNECT_TIMEOUT` in integer seconds. An interactive connection defaults to `120` seconds, while `--probe-only` and [`$$nemoclaw start`](#$$nemoclaw-name-start) default to `300` seconds so a scripted health check can wait through a cold sandbox start. When the deadline expires, `connect` exits non-zero with the last-seen phase. - -On a TTY, a one-shot hint prints before dropping into the sandbox shell. -The hint is agent-aware. It names the correct TUI command for the sandbox's agent and reminds you to use `/exit` to leave the chat before `exit` returns you to the host shell. -Set `NEMOCLAW_NO_CONNECT_HINT=1` to suppress the hint in scripted workflows. -If the sandbox is running an outdated agent version, a non-blocking warning prints before connecting with a `$$nemoclaw rebuild` hint. -If another terminal is already connected to the sandbox, `connect` prints a note with the number of existing sessions before proceeding. Multiple concurrent sessions are allowed. - -While a session opened by `connect` remains active, NemoClaw watches for a Shields auto-relock that occurs after the connection begins. -When one occurs, the host terminal prints one warning for that event on stderr, explains that restricted operations can now fail, and shows the `$$nemoclaw shields down --timeout ...` command to run on the host. -The connected session remains open. -If the Shields audit cannot be read, NemoClaw keeps the session open and might not print the advisory warning. - -Without `--probe-only`, `connect` does not pull a model itself, but it does inspect managed-vLLM install variables such as `NEMOCLAW_VLLM_MODEL` and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` if you exported them in the same shell. -An unknown model slug, malformed extra-args JSON, or a gated model (for example `deepseek-r1-distill-70b`) with no `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN` exits non-zero with the same error the installer would emit, before any sandbox readiness probe or SSH attach. -Unset the managed-vLLM variable, or fix the value, before retrying a regular connection. -`connect --probe-only` skips this install preflight so stale managed-vLLM variables cannot block recovery. +Connect to a sandbox by name. Bare `$$nemoclaw connect` (no sandbox name) connects to the registry default. NemoClaw uses the stored default when it names a non-pending registered sandbox, then falls back to the first non-pending registration. If only pending registrations remain, the command exits non-zero and tells you to wait for onboarding or remove the incomplete sandbox. If the registry remains empty after recovery, it tells you to run `$$nemoclaw onboard`. A registered sandbox literally named `connect` keeps the name-first reading. If the sandbox is not yet in the `Ready` phase, `connect` polls `openshell sandbox list` every few seconds and prints the current phase. This gives you progress output right after onboarding, when the 2.4 GB image is still pulling, instead of a silent hang. Control the wait budget with `NEMOCLAW_CONNECT_TIMEOUT` in integer seconds. An interactive connection defaults to `120` seconds, while `--probe-only` and [`$$nemoclaw start`](#$$nemoclaw-name-start) default to `300` seconds so a scripted health check can wait through a cold sandbox start. When the deadline expires, `connect` exits non-zero with the last-seen phase. + +On a TTY, a one-shot hint prints before dropping into the sandbox shell. The hint is agent-aware. It names the correct TUI command for the sandbox's agent and reminds you to use `/exit` to leave the chat before `exit` returns you to the host shell. Set `NEMOCLAW_NO_CONNECT_HINT=1` to suppress the hint in scripted workflows. If the sandbox is running an outdated agent version, a non-blocking warning prints before connecting with a `$$nemoclaw rebuild` hint. If another terminal is already connected to the sandbox, `connect` prints a note with the number of existing sessions before proceeding. Multiple concurrent sessions are allowed. + +While a session opened by `connect` remains active, NemoClaw watches for a Shields auto-relock that occurs after the connection begins. When one occurs, the host terminal prints one warning for that event on stderr, explains that restricted operations can now fail, and shows the exact `$$nemoclaw shields down --timeout ...` command to run on the host. The connected session remains open. If the Shields audit cannot be read, NemoClaw keeps the session open and might not print the advisory warning. + +Without `--probe-only`, `connect` does not pull a model itself, but it does inspect managed-vLLM install variables such as `NEMOCLAW_VLLM_MODEL` and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` if you exported them in the same shell. An unknown model slug, malformed extra-args JSON, or a gated model (for example `deepseek-r1-distill-70b`) with no `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN` exits non-zero with the same error the installer would emit, before any sandbox readiness probe or SSH attach. Unset the managed-vLLM variable, or fix the value, before retrying a regular connection. `connect --probe-only` skips this install preflight so stale managed-vLLM variables cannot block recovery. For a portable experimental-profile sandbox with the recorded `ollama-local` provider, `connect --probe-only` probes `http://127.0.0.1:11434/api/tags` before it decides whether to start Ollama. @@ -1422,46 +926,20 @@ If Ollama does not become healthy within 30 seconds, the command identifies the The command does not take over a system service or an unrelated user-managed Ollama daemon. -Before reading or changing the live OpenShell gateway inference route, `connect` verifies the shared provider and sandbox metadata. -When the live route differs and the metadata is compatible, `connect` warns and re-points the route to the target sandbox's recorded provider and model. -Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for provider-global identity, route drift, and hard-error recovery. -Use `$$nemoclaw inference set --provider --model ` to make an intentional compatible route change outside the connect flow. -Before it opens SSH, `connect` probes `https://inference.local/v1/models` from inside the sandbox with the selected agent's trusted CA and proxy context. -HTTP `200` through `499` confirms that the route is reachable. -When the probe returns a recognized broken result, `connect` attempts DNS or route repair and verifies the route again. -When the initial probe cannot return a trusted result, `connect` fails closed before health-driven repair and before opening SSH. -It prints a bounded, redacted last-probe detail and points you to `$$nemoclaw doctor`. -If the sandbox is registered locally but missing from a healthy gateway, `connect` preserves the registry entry and points you to `rebuild --yes`, `onboard`, or `destroy` instead of deleting the metadata needed for recovery. +Before reading or changing the live OpenShell gateway inference route, `connect` verifies the shared provider and sandbox metadata. When the live route differs and the metadata is compatible, `connect` warns and re-points the route to the target sandbox's recorded provider and model. Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for provider-global identity, route drift, and hard-error recovery. Use `$$nemoclaw inference set --provider --model ` to make an intentional compatible route change outside the connect flow. Before it opens SSH, `connect` probes `https://inference.local/v1/models` from inside the sandbox with the selected agent's trusted CA and proxy context. HTTP `200` through `499` confirms that the route is reachable. When the probe returns a recognized broken result, `connect` attempts DNS or route repair and verifies the route again. When the initial probe cannot return a trusted result, `connect` fails closed before health-driven repair and before opening SSH. It prints a bounded, redacted last-probe detail and points you to `$$nemoclaw doctor`. If the sandbox is registered locally but missing from a healthy gateway, `connect` preserves the registry entry and points you to `rebuild --yes`, `onboard`, or `destroy` instead of deleting the metadata needed for recovery. -After a host reboot, the OpenShell gateway rotates its SSH host keys. -`connect` detects the resulting identity drift, prunes stale `openshell-*` entries from `~/.ssh/known_hosts`, and retries automatically. -You no longer need to re-run `$$nemoclaw onboard` after a reboot in this case. +After a host reboot, the OpenShell gateway rotates its SSH host keys. `connect` detects the resulting identity drift, prunes stale `openshell-*` entries from `~/.ssh/known_hosts`, and retries automatically. You no longer need to re-run `$$nemoclaw onboard` after a reboot in this case. ```bash $$nemoclaw my-assistant connect [--probe-only] $$nemoclaw connect ``` -On Linux, the `--probe-only` flag is the infrastructure producer for launch-readiness evidence. -It validates a usable lease and exits without duplicate recovery. -Otherwise, it fences prior evidence, waits for the sandbox, verifies or repairs its in-sandbox agent process and host-side forwards, and publishes evidence only after every probe succeeds. -It rechecks the sandbox on its recorded OpenShell gateway after the readiness wait and never restarts the shared host gateway. -If an old runtime epoch might exist and cannot be durably rotated, the command exits nonzero before complete preflight or recovery and gives redacted repair guidance. -A securely absent runtime authority and receipt let ordinary `launch` run the complete preflight without optimization if new authority creation fails, but on Linux `connect --probe-only` still exits nonzero because it could not publish launch-readiness evidence. -A runtime failure and, on Linux, a failure to publish evidence for an otherwise healthy runtime also exit nonzero with different diagnostics. +On Linux, the `--probe-only` flag is the infrastructure producer for launch-readiness evidence. It validates a usable lease and exits without duplicate recovery. Otherwise, it fences prior evidence, waits for the sandbox, verifies or repairs its in-sandbox agent process and host-side forwards, and publishes evidence only after every probe succeeds. It rechecks the sandbox on its recorded OpenShell gateway after the readiness wait and never restarts the shared host gateway. If an old runtime epoch might exist and cannot be durably rotated, the command exits nonzero before complete preflight or recovery and gives redacted repair guidance. A securely absent runtime authority and receipt let ordinary `launch` run the complete preflight without optimization if new authority creation fails, but on Linux `connect --probe-only` still exits nonzero because it could not publish launch-readiness evidence. A runtime failure and, on Linux, a failure to publish evidence for an otherwise healthy runtime also exit nonzero with different diagnostics. -Infrastructure must run the command as the same final numeric user that later runs `launch`. -Run it only after the final durable home and state volume is mounted and after policy and network provisioning is complete. -On Linux, that user also needs a secure, independently writable OS per-user runtime authority under `/run/user/`. -Do not redirect this authority with caller environment variables. -Do not use a graphical or login-session identifier as the deployment ordering boundary. -On macOS, `connect --probe-only` runs the complete preflight, including recovery and probes. -After a successful probe and recovery, it prints a note that launch-readiness evidence is unavailable on this platform and exits zero. -The next `launch` runs the complete preflight. -On Linux, the publication-failure diagnostic is redacted and does not print filesystem paths or environment values. +Infrastructure must run the command as the same final numeric user that later runs `launch`. Run it only after the final durable home and state volume is mounted and after policy and network provisioning is complete. On Linux, that user also needs a secure, independently writable OS per-user runtime authority under `/run/user/`. Do not redirect this authority with caller environment variables. Do not use a graphical or login-session identifier as the deployment ordering boundary. On macOS, `connect --probe-only` runs the complete preflight, including recovery and probes. After a successful probe and recovery, it prints a note that launch-readiness evidence is unavailable on this platform and exits zero. The next `launch` runs the complete preflight. On Linux, the publication-failure diagnostic is redacted and does not print filesystem paths or environment values. -Every `connect --probe-only` completion prints at most one credential-free `Probe timing:` line. -The line always reports these stages in this order, with cumulative whole-millisecond durations: +Every `connect --probe-only` completion prints at most one credential-free `Probe timing:` line. The line always reports these stages in this order, with cumulative whole-millisecond durations: - `readiness` waits for the sandbox state. - `authority` validates launch-readiness authority and evidence. @@ -1473,28 +951,20 @@ The line always reports these stages in this order, with cumulative whole-millis - `pairing` settles OpenClaw operator pairing when applicable. - `publication` publishes launch-readiness evidence on Linux. -The line also reports `total`, `lifecycleAction=skipped|reused|recovered|failed`, `forwardAction=skipped|verified|restored|failed`, and `result=ready|failed`. -A failed probe adds `failedStage=` or `failedStage=unknown`. -Stages that do not apply or do not run report `0ms`. -Timing collection and output are fail-open: clock or writer failures do not change the readiness work, command diagnostics, or exit status. -Use the command exit status, not a duration or action field, as the readiness decision. +The line also reports `total`, `lifecycleAction=skipped|reused|recovered|failed`, `forwardAction=skipped|verified|restored|failed`, and `result=ready|failed`. A failed probe adds `failedStage=` or `failedStage=unknown`. Stages that do not apply or do not run report `0ms`. Timing collection and output are fail-open: clock or writer failures do not change the readiness work, command diagnostics, or exit status. Use the command exit status, not a duration or action field, as the readiness decision. -Portable lifecycle recovery also emits one credential-free `Portable lifecycle timing:` line. -It reports fixed authority, container, exec-readiness, Ollama, startup, and gateway stages with their durations, selected actions, attempt counts, result, and failed stage when available. -This diagnostic does not change recovery behavior or the command exit status. + Portable lifecycle recovery also emits one credential-free `Portable lifecycle timing:` line. It + reports fixed authority, container, exec-readiness, Ollama, startup, and gateway stages with their + durations, selected actions, attempt counts, result, and failed stage when available. This + diagnostic does not change recovery behavior or the command exit status. Run it for health checks and scripted readiness probes; users continue to run only `$$nemoclaw launch `. -For a current Portable OpenClaw sandbox, `connect`, `connect --probe-only`, `recover`, and `launch` require the same strict local CLI operator pairing as onboarding. -If NemoClaw finds only the paired device and no pending request, it runs the canonical OpenClaw request producer once. -It then runs at most one canonical approval and observes the final pairing state. -An ambiguous approval result receives one final observation and no approval retry. -Pairing with missing, extra, unknown, malformed, or ambiguous scope or identity data exits nonzero with an incomplete-onboarding diagnostic instead of opening a session or publishing launch-readiness evidence. -Follow the diagnostic to resume or rerun onboarding. +For a current Portable OpenClaw sandbox, `connect`, `connect --probe-only`, `recover`, and `launch` require the same strict local CLI operator pairing as onboarding. If NemoClaw finds only the paired device and no pending request, it runs the canonical OpenClaw request producer once. It then runs at most one canonical approval and observes the final pairing state. An ambiguous approval result receives one final observation and no approval retry. Pairing with missing, extra, unknown, malformed, or ambiguous scope or identity data exits nonzero with an incomplete-onboarding diagnostic instead of opening a session or publishing launch-readiness evidence. Follow the diagnostic to resume or rerun onboarding. @@ -1502,18 +972,13 @@ Use [`$$nemoclaw launch `](#$$nemoclaw-launch-name) when you want launch-r ### `$$nemoclaw exec` -Run a single command non-interactively in a running sandbox via the OpenShell exec endpoint. -The command runs as the sandbox user with `HOME=/sandbox`, so in-sandbox tooling resolves NemoClaw-provisioned config the same way it does for `connect` and `openshell sandbox connect`. -This is the supported substitute for `docker exec` on the sandbox container; raw `docker exec` runs as root and lands on `HOME=/root`, where the selected agent config is not present. -For a registered sandbox, NemoClaw selects its recorded owning OpenShell gateway before the workdir probe and command dispatch. -If gateway selection fails, `exec` stops without running the sandbox command. +Run a single command non-interactively in a running sandbox via the OpenShell exec endpoint. The command runs as the sandbox user with `HOME=/sandbox`, so in-sandbox tooling resolves NemoClaw-provisioned config the same way it does for `connect` and `openshell sandbox connect`. This is the supported substitute for `docker exec` on the sandbox container; raw `docker exec` runs as root and lands on `HOME=/root`, where the selected agent config is not present. For a registered sandbox, NemoClaw selects its recorded owning OpenShell gateway before the workdir probe and command dispatch. If gateway selection fails, `exec` stops without running the sandbox command. OpenClaw config resolves under `/sandbox/.openclaw`. -Run one OpenClaw turn with `$$nemoclaw my-assistant exec -- openclaw agent --agent main -m "What is 2+2?"`. -List the default OpenClaw workspace with `$$nemoclaw my-assistant exec --workdir /sandbox/.openclaw/workspace -- ls -la`. +Run one OpenClaw turn with `$$nemoclaw my-assistant exec -- openclaw agent --agent main -m "What is 2+2?"`. List the default OpenClaw workspace with `$$nemoclaw my-assistant exec --workdir /sandbox/.openclaw/workspace -- ls -la`. @@ -1531,15 +996,9 @@ Everything after `--` is forwarded verbatim to the sandbox command, including fl -After an OpenClaw one-shot command exits, NemoClaw verifies and, when needed, restores the mutable config permission contract. -When cleanup succeeds, `exec` returns the remote command's exit code. -If cleanup cannot inspect, restore, or verify that contract, it fails closed and prints `OpenClaw permission cleanup failed (...)` to `stderr`. -In that case, `exec` returns the cleanup failure instead of the remote command's status. +After an OpenClaw one-shot command exits, NemoClaw verifies and, when needed, restores the mutable config permission contract. When cleanup succeeds, `exec` returns the remote command's exit code. If cleanup cannot inspect, restore, or verify that contract, it fails closed and prints `OpenClaw permission cleanup failed (...)` to `stderr`. In that case, `exec` returns the cleanup failure instead of the remote command's status. -For a registered OpenClaw sandbox with a selected owning managed gateway, a successful direct `openclaw pairing approve googlechat ` command also restarts that gateway after cleanup so the new sender allowlist applies to the next message. -If cleanup or restart fails after the approval commits, `exec` exits with status `1` and reports that the approval was not rolled back. -When an owning gateway was selected, correct any reported cleanup problem, then run `$$nemoclaw gateway restart` before testing the next message. -Without an owning managed gateway, NemoClaw does not attempt activation or print a managed restart command; unregistered and non-OpenClaw sandboxes also do not receive the automatic restart. +For a registered OpenClaw sandbox with a selected owning managed gateway, a successful direct `openclaw pairing approve googlechat ` command also restarts that gateway after cleanup so the new sender allowlist applies to the next message. If cleanup or restart fails after the approval commits, `exec` exits with status `1` and reports that the approval was not rolled back. When an owning gateway was selected, correct any reported cleanup problem, then run `$$nemoclaw gateway restart` before testing the next message. Without an owning managed gateway, NemoClaw does not attempt activation or print a managed restart command; unregistered and non-OpenClaw sandboxes also do not receive the automatic restart. @@ -1548,28 +1007,24 @@ The exit code is the remote command's exit code. -By default, NemoClaw inherits caller stdin only when it is a terminal. -Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. -Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. +By default, NemoClaw inherits caller stdin only when it is a terminal. Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. ```bash printf 'hello\n' | $$nemoclaw my-assistant exec --stdin -- cat ssh dgx-spark '$$nemoclaw my-assistant exec --no-stdin -- pwd' ``` -OpenShell preserves line endings and quote characters inside each command argument, so inline scripts and heredocs can be passed as one argument after `--`. -For example, a shell variable keeps the multi-line script in one argv element: +OpenShell preserves line endings and quote characters inside each command argument, so inline scripts and heredocs can be passed as one argument after `--`. For example, a shell variable keeps the multi-line script in one argv element: ```bash script=$'cat <<\'EOF\'\nline one\nline two\nEOF' $$nemoclaw exec -- bash -lc "$script" ``` -NUL bytes are still rejected in command arguments. -Line breaks are accepted only in command argv: `--workdir` remains single-line, and NemoClaw does not expose OpenShell request-environment injection on this command. +NUL bytes are still rejected in command arguments. Line breaks are accepted only in command argv: `--workdir` remains single-line, and NemoClaw does not expose OpenShell request-environment injection on this command. | Flag | Description | -|------|-------------| +| --- | --- | | `--workdir ` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: does not exist inside the sandbox` and exits with status `1` without invoking the inner command. | | `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) | | `--timeout ` | Timeout in seconds (`0` means no timeout) | @@ -1579,16 +1034,11 @@ Line breaks are accepted only in command argv: `--workdir` remains single-line, -Run one agent turn non-interactively in a running sandbox. -For OpenClaw sandboxes, this command forwards arguments to `openclaw agent ...` inside the sandbox via `openshell sandbox exec`, with `HOME=/sandbox` so the addressed agent profile resolves the same way as `connect`. -For terminal-runtime sandboxes, NemoClaw forwards arguments to the manifest-declared interactive command; LangChain Deep Agents Code sandboxes run `dcode ...`. -Use this when driving the sandbox programmatically from another process (CI job, multi-agent platform, evaluation harness) rather than from an interactive terminal. +Run one agent turn non-interactively in a running sandbox. For OpenClaw sandboxes, this command forwards arguments to `openclaw agent ...` inside the sandbox via `openshell sandbox exec`, with `HOME=/sandbox` so the addressed agent profile resolves the same way as `connect`. For terminal-runtime sandboxes, NemoClaw forwards arguments to the manifest-declared interactive command; LangChain Deep Agents Code sandboxes run `dcode ...`. Use this when driving the sandbox programmatically from another process (CI job, multi-agent platform, evaluation harness) rather than from an interactive terminal. All flags accepted by the selected in-sandbox agent CLI are forwarded verbatim, so the upstream surface stays the single source of truth. -OpenClaw invocations must include at least one target selector: `--agent`, `--session-id`, `--session-key`, or `--to`. -This keeps the wrapper from falling back to the unspecified default-session behaviour. -Conflict resolution between multiple selectors is delegated to the in-sandbox `openclaw agent` argv contract; the host-side guard only checks presence. +OpenClaw invocations must include at least one target selector: `--agent`, `--session-id`, `--session-key`, or `--to`. This keeps the wrapper from falling back to the unspecified default-session behaviour. Conflict resolution between multiple selectors is delegated to the in-sandbox `openclaw agent` argv contract; the host-side guard only checks presence. ```bash $$nemoclaw my-assistant agent --agent main -m "Summarise README.md" @@ -1600,17 +1050,7 @@ $$nemoclaw dcode-sandbox agent -n "Summarize this repository" $$nemoclaw dcode-sandbox agent -n "Summarize this repository" --json ``` -For non-JSON OpenClaw turns, the wrapper captures `stdout` and `stderr` and replays them only after the in-sandbox command exits. -The combined capture limit is `64 MiB`; exceeding it reports an OpenShell invocation error and exits with status `1`. -If the captured output contains an embedded-fallback marker, the wrapper suppresses both streams, prints `recover`, `rebuild --yes`, and `onboard --resume` guidance to `stderr`, and exits with status `1`. -Otherwise, it writes the captured output to the corresponding host streams and returns the OpenShell command's exit status. -The in-sandbox NemoClaw plugin writes its registration banner to `stderr`, so the banner does not prefix the agent reply on `stdout` in non-JSON mode. -Because a delivered turn always writes to one of the two streams, the wrapper reports a dispatch with status `0` and no output as a failure. -The wrapper prints recovery guidance to `stderr` and exits with status `1`. -Pressing `Ctrl+C` interrupts the OpenShell child, and sending `SIGTERM` to the host wrapper forwards `SIGTERM` to that child. -NemoClaw waits for OpenShell to stop the in-sandbox turn, replays captured output, and returns status `130` for `SIGINT` or `143` for `SIGTERM`. -When the forwarded argv sets `openclaw agent --timeout `, both captured paths bound the OpenShell command at that value plus 30 seconds. -The extra seconds let the in-sandbox turn report its own timeout first, so the host bound catches only a turn that stops answering. +For non-JSON OpenClaw turns, the wrapper captures `stdout` and `stderr` and replays them only after the in-sandbox command exits. The combined capture limit is `64 MiB`; exceeding it reports an OpenShell invocation error and exits with status `1`. If the captured output contains an embedded-fallback marker, the wrapper suppresses both streams, prints `recover`, `rebuild --yes`, and `onboard --resume` guidance to `stderr`, and exits with status `1`. Otherwise, it writes the captured output to the corresponding host streams and returns the OpenShell command's exit status. The in-sandbox NemoClaw plugin writes its registration banner to `stderr`, so the banner does not prefix the agent reply on `stdout` in non-JSON mode. Because a delivered turn always writes to one of the two streams, the wrapper reports a dispatch with status `0` and no output as a failure. The wrapper prints recovery guidance to `stderr` and exits with status `1`. Pressing `Ctrl+C` interrupts the OpenShell child, and sending `SIGTERM` to the host wrapper forwards `SIGTERM` to that child. NemoClaw waits for OpenShell to stop the in-sandbox turn, replays captured output, and returns status `130` for `SIGINT` or `143` for `SIGTERM`. When the forwarded argv sets `openclaw agent --timeout `, both captured paths bound the OpenShell command at that value plus 30 seconds. The extra seconds let the in-sandbox turn report its own timeout first, so the host bound catches only a turn that stops answering. These leave the OpenShell wait unbounded: @@ -1620,33 +1060,9 @@ These leave the OpenShell wait unbounded: - An unrecognized option before `--timeout`, because NemoClaw does not infer a host deadline outside the documented OpenClaw option grammar. - A `--timeout` after the `--` argv terminator, which OpenClaw reads as payload rather than as its own flag. -When the captured output reports that the turn's deadline fired, the wrapper replays the partial output and writes deadline guidance to `stderr`. -It exits with status `1` instead of the upstream status `0`. -The diagnostic shell-quotes the sandbox name and forwarded arguments, then redacts detected credential values before writing the recovery command to `stderr`. -If redaction changes the recovery command, the diagnostic tells you not to replay it; otherwise, it labels the command as runnable inside the sandbox. -For a registered sandbox, both captured paths pin the sandbox's recorded gateway with an explicit `-g`. -Neither path forwards an interactive terminal on `stdin`; a genuine pipe or redirect is still passed through, so `printf 'ping' | $$nemoclaw my-assistant agent --agent main` keeps working. -When the top-level OpenClaw `--json` output flag is present, the wrapper uses a captured no-TTY path with a `64 MiB` buffer so `stdout` stays parseable JSON. -Raw `stderr`, including structured JSON diagnostics, is forwarded unchanged. -NemoClaw appends failed-tool or untrusted-child provenance only from the `stdout` JSON. -The wrapper reads completion markers only from the final matching OpenClaw response envelope: a local `{ payloads, meta }` response or a gateway `{ status, result: { payloads, meta } }` response. -It ignores earlier JSON progress or log records. -It exits with status `1` when that metadata contains `error.kind: "incomplete_turn"`, `livenessState: "abandoned"`, `replayInvalid: true`, or a `timeoutPhase` value, even when the envelope reports success. -Marker-shaped values inside tool results, tool-call arguments, or other descendants do not change the exit status. -A turn can run every tool successfully and still become abandoned before it produces a reply. -The wrapper writes the unchanged JSON trace to `stdout` before it reports the incomplete turn, so the partial tool trace remains available. -The wrapper writes the verdict, the detected markers, and verify-before-retry guidance to `stderr`. -A `timeoutPhase` value names the phase the deadline fired in, so the wrapper writes deadline guidance in place of the generic incomplete-turn text. -Tool calls in a partial trace may have already applied side effects, so verify what the turn changed before you retry it. -The wrapper passes through an upstream non-zero exit status unchanged. -Literal `--json` values consumed by flags such as `-m` or `--reply-channel`, or arguments after `--`, stay on the normal passthrough path. -Documented value flags written as `--flag=value`, such as `--session-id=s1`, are recognized the same way as separated value flags. -If an unrecognized OpenClaw option appears before `--json`, NemoClaw also keeps the command on the normal passthrough path so OpenClaw remains the argv source of truth. - -Common OpenClaw flags include `-m `, `--session-id `, `--agent `, `--model `, `--thinking `, `--json`, `--deliver`, `--reply-channel `, and `--timeout `. -For OpenClaw sandboxes and registry fallbacks, `$$nemoclaw agent --help` prints the wrapper-level summary locally. -Invoke `$$nemoclaw exec -- openclaw agent --help` to view the upstream OpenClaw help text directly. -For registered terminal-runtime sandboxes, bare invocations and `--help` are forwarded to the terminal command, so a LangChain Deep Agents Code sandbox receives `dcode` for `$$nemoclaw agent` and `dcode --help` for `$$nemoclaw agent --help`. +When the captured output reports that the turn's deadline fired, the wrapper replays the partial output and writes deadline guidance to `stderr`. It exits with status `1` instead of the upstream status `0`. The diagnostic shell-quotes the sandbox name and forwarded arguments, then redacts detected credential values before writing the recovery command to `stderr`. If redaction changes the recovery command, the diagnostic tells you not to replay it; otherwise, it labels the command as runnable inside the sandbox. For a registered sandbox, both captured paths pin the sandbox's recorded gateway with an explicit `-g`. Neither path forwards an interactive terminal on `stdin`; a genuine pipe or redirect is still passed through, so `printf 'ping' | $$nemoclaw my-assistant agent --agent main` keeps working. When the top-level OpenClaw `--json` output flag is present, the wrapper uses a captured no-TTY path with a `64 MiB` buffer so `stdout` stays parseable JSON. Raw `stderr`, including structured JSON diagnostics, is forwarded unchanged. NemoClaw appends failed-tool or untrusted-child provenance only from the `stdout` JSON. The wrapper reads completion markers only from the final matching OpenClaw response envelope: a local `{ payloads, meta }` response or a gateway `{ status, result: { payloads, meta } }` response. It ignores earlier JSON progress or log records. It exits with status `1` when that metadata contains `error.kind: "incomplete_turn"`, `livenessState: "abandoned"`, `replayInvalid: true`, or a `timeoutPhase` value, even when the envelope reports success. Marker-shaped values inside tool results, tool-call arguments, or other descendants do not change the exit status. A turn can run every tool successfully and still become abandoned before it produces a reply. The wrapper writes the unchanged JSON trace to `stdout` before it reports the incomplete turn, so the partial tool trace remains available. The wrapper writes the verdict, the detected markers, and verify-before-retry guidance to `stderr`. A `timeoutPhase` value names the phase the deadline fired in, so the wrapper writes deadline guidance in place of the generic incomplete-turn text. Tool calls in a partial trace may have already applied side effects, so verify what the turn changed before you retry it. The wrapper passes through an upstream non-zero exit status unchanged. Literal `--json` values consumed by flags such as `-m` or `--reply-channel`, or arguments after `--`, stay on the normal passthrough path. Documented value flags written as `--flag=value`, such as `--session-id=s1`, are recognized the same way as separated value flags. If an unrecognized OpenClaw option appears before `--json`, NemoClaw also keeps the command on the normal passthrough path so OpenClaw remains the argv source of truth. + +Common OpenClaw flags include `-m `, `--session-id `, `--agent `, `--model `, `--thinking `, `--json`, `--deliver`, `--reply-channel `, and `--timeout `. For OpenClaw sandboxes and registry fallbacks, `$$nemoclaw agent --help` prints the wrapper-level summary locally. Invoke `$$nemoclaw exec -- openclaw agent --help` to view the upstream OpenClaw help text directly. For registered terminal-runtime sandboxes, bare invocations and `--help` are forwarded to the terminal command, so a LangChain Deep Agents Code sandbox receives `dcode` for `$$nemoclaw agent` and `dcode --help` for `$$nemoclaw agent --help`. Host-side validation runs before the sandbox dispatch: @@ -1657,11 +1073,7 @@ Host-side validation runs before the sandbox dispatch: -The `agent` wrapper rejects Hermes sandboxes with guidance for the Hermes HTTP API. -Each Hermes sandbox exposes an OpenAI-compatible API inside the sandbox on its own port, which defaults to `8642`, so non-interactive use does not need a wrapper command. -When another sandbox or a host listener already holds `8642`, the sandbox receives the next free port from `8642` through `8652`. -The rejection message names that port and the `openshell forward start` command for it. -Run `openshell forward list` to read the host bind for each of that sandbox's forwards. +The `agent` wrapper rejects Hermes sandboxes with guidance for the Hermes HTTP API. Each Hermes sandbox exposes an OpenAI-compatible API inside the sandbox on its own port, which defaults to `8642`, so non-interactive use does not need a wrapper command. When another sandbox or a host listener already holds `8642`, the sandbox receives the next free port from `8642` through `8652`. The rejection message names that port and the `openshell forward start` command for it. Run `openshell forward list` to read the host bind for each of that sandbox's forwards. Forward the port and POST chat completions directly: @@ -1675,15 +1087,7 @@ curl -sN http://127.0.0.1:8642/v1/chat/completions \ -For Deep Agents sandboxes, `agent` forwards to the manifest-declared terminal command. -Bare invocations run `dcode`, and `--help` runs `dcode --help`. -Use `dcode -n` for explicit headless automation when you are already connected to the sandbox, or use `$$nemoclaw agent -n ""` from the host. -Add `--json` to either form for one managed, versioned JSON envelope on stdout. -The host wrapper forwards the flag to `dcode`. -For the schema, status and exit behavior, and 1 MiB output limit, refer to [Run Deep Agents Code](/user-guide/deepagents/manage-sandboxes/operate-sandboxes/run-deep-agents-code). -The host wrapper keeps `HOME=/sandbox`, the managed proxy environment, and the manifest-declared Deep Agents config path aligned with `connect`. -Interactive `$$nemoclaw agent` launches the same terminal TUI as `dcode`. -Headless `$$nemoclaw agent -n ""` uses the managed headless boundary, where non-shell tools can auto-run without the interactive approval UI. +For Deep Agents sandboxes, `agent` forwards to the manifest-declared terminal command. Bare invocations run `dcode`, and `--help` runs `dcode --help`. Use `dcode -n` for explicit headless automation when you are already connected to the sandbox, or use `$$nemoclaw agent -n ""` from the host. Add `--json` to either form for one managed, versioned JSON envelope on stdout. The host wrapper forwards the flag to `dcode`. For the schema, status and exit behavior, and 1 MiB output limit, refer to [Run Deep Agents Code](/user-guide/deepagents/manage-sandboxes/operate-sandboxes/run-deep-agents-code). The host wrapper keeps `HOME=/sandbox`, the managed proxy environment, and the manifest-declared Deep Agents config path aligned with `connect`. Interactive `$$nemoclaw agent` launches the same terminal TUI as `dcode`. Headless `$$nemoclaw agent -n ""` uses the managed headless boundary, where non-shell tools can auto-run without the interactive approval UI. @@ -1693,9 +1097,7 @@ The following commands are available for targeted host-side maintenance, but the #### `$$nemoclaw config get` -Read the sanitized agent configuration from a sandbox. -The output removes credential-bearing sections before printing. -Use `--key` to read one dotpath and `--format` to choose JSON or YAML output. +Read the sanitized agent configuration from a sandbox. The output removes credential-bearing sections before printing. Use `--key` to read one dotpath and `--format` to choose JSON or YAML output. @@ -1722,19 +1124,16 @@ $$nemoclaw my-assistant config get --key models.default --format yaml -| Flag | Description | -|------|-------------| -| `--key ` | Print one value from the sanitized config | -| `--format json\|yaml` | Output format. Defaults to JSON | +| Flag | Description | +| --------------------- | ----------------------------------------- | +| `--key ` | Print one value from the sanitized config | +| `--format json\|yaml` | Output format. Defaults to JSON | #### `$$nemoclaw config set` -Write one value into the agent configuration in a sandbox. -The command validates every HTTP and HTTPS URL in the value, including URLs nested inside JSON objects or arrays. -It pins an HTTP host to the validated IP address. -Configuration changes are unavailable while shields are up, so lower shields with `$$nemoclaw shields down` first. +Write one value into the agent configuration in a sandbox. The command validates every HTTP and HTTPS URL in the value, including URLs nested inside JSON objects or arrays. It pins an HTTP host to the validated IP address. Configuration changes are unavailable while shields are up, so lower shields with `$$nemoclaw shields down` first. ```bash $$nemoclaw my-assistant config set --key agents.defaults.model.primary --value nvidia/nemotron @@ -1742,43 +1141,31 @@ $$nemoclaw my-assistant config set --key agents.defaults.timeoutSeconds --value ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--key ` | Dotpath to update in the config. Required | | `--value ` | Value to write. The command parses a JSON value when it can, and otherwise writes the text as a string. Required | | `--restart` | Restart a supported OpenClaw or Hermes gateway after writing | | `--config-accept-new-path` | Write a dotpath that does not already exist in the config | -The command treats a dotpath that does not already exist in the config as a possible typo. -An interactive run asks for confirmation before writing the new dotpath. -A run without a TTY, or a run with `NEMOCLAW_NON_INTERACTIVE=1`, refuses the write. -Pass `--config-accept-new-path`, or set `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH=1`, to write the new dotpath without the confirmation. -If the confirmation reaches the end of input, for example when you press `Ctrl-D` or run the command from a harness that closes stdin, the command exits non-zero without writing and repeats the same guidance. +The command treats a dotpath that does not already exist in the config as a possible typo. An interactive run asks for confirmation before writing the new dotpath. A run without a TTY, or a run with `NEMOCLAW_NON_INTERACTIVE=1`, refuses the write. Pass `--config-accept-new-path`, or set `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH=1`, to write the new dotpath without the confirmation. If the confirmation reaches the end of input, for example when you press `Ctrl-D` or run the command from a harness that closes stdin, the command exits non-zero without writing and repeats the same guidance. The command refuses to write `gateway` or any dotpath under `gateway.`, which holds credentials. -For Hermes, the command validates the complete candidate configuration with the bundled Hermes schema before it writes configuration or integrity metadata. -An incomplete structural object is rejected without changing the configuration or hashes. -Private URLs remain rejected unless the existing Hermes configuration explicitly sets `security.allow_private_urls: true`. -That opt-in allows private URLs for any Hermes configuration value. Public hostnames still receive DNS validation and pinning. -Hermes can restart its gateway when it applies a configuration change. Use `--restart` when the command must request and verify that restart. -`config set` changes `config.yaml` only. Hermes startup and rebuild can update managed `.env` keys and their integrity hashes without printing their values. +For Hermes, the command validates the complete candidate configuration with the bundled Hermes schema before it writes configuration or integrity metadata. An incomplete structural object is rejected without changing the configuration or hashes. Private URLs remain rejected unless the existing Hermes configuration explicitly sets `security.allow_private_urls: true`. That opt-in allows private URLs for any Hermes configuration value. Public hostnames still receive DNS validation and pinning. Hermes can restart its gateway when it applies a configuration change. Use `--restart` when the command must request and verify that restart. `config set` changes `config.yaml` only. Hermes startup and rebuild can update managed `.env` keys and their integrity hashes without printing their values. -For Deep Agents sandboxes, `config set` is unavailable because managed startup (or an explicit custom image build) materializes the `dcode` configuration as image-owned state. -Run `$$nemoclaw onboard --agent dcode --name --fresh` when you need to change it. -Use `$$nemoclaw config get` to read the current values. +For Deep Agents sandboxes, `config set` is unavailable because managed startup (or an explicit custom image build) materializes the `dcode` configuration as image-owned state. Run `$$nemoclaw onboard --agent dcode --name --fresh` when you need to change it. Use `$$nemoclaw config get` to read the current values. #### `$$nemoclaw shields` -Manage the sandbox config lockdown posture from the host. -Use `shields status` to inspect the current state, `shields up` to lock the sandbox config and restore the captured restrictive policy, and `shields down` to temporarily unlock the config for maintenance. +Manage the sandbox config lockdown posture from the host. Use `shields status` to inspect the current state, `shields up` to lock the sandbox config and restore the captured restrictive policy, and `shields down` to temporarily unlock the config for maintenance. @@ -1793,30 +1180,20 @@ $$nemoclaw my-assistant shields down --timeout 5m --reason "maintenance" ``` | Subcommand | Description | -|------|-------------| +| --- | --- | | `shields status` | Show whether lockdown is configured, active, temporarily unlocked, or in error | | `shields up` | Lock the sandbox config and restore the saved restrictive policy | | `shields down` | Temporarily unlock the sandbox config. Supports `--timeout`, `--reason`, and `--policy` | -If OpenShell rejects the permissive policy before it is applied, `shields down` returns an error and keeps the sandbox in the Shields up state. -The command clears the provisional Shields down record and timer, and `shields status` remains `UP`. -If that record cannot be cleared and NemoClaw writes the rejection marker, `shields status` derives `UP` from that marker. -The auto-restore timer and transition remain the recovery authority. -If the rejection marker also cannot be written, `shields status` reports the incomplete transition as an error. +If OpenShell rejects the permissive policy before it is applied, `shields down` returns an error and keeps the sandbox in the Shields up state. The command clears the provisional Shields down record and timer, and `shields status` remains `UP`. If that record cannot be cleared and NemoClaw writes the rejection marker, `shields status` derives `UP` from that marker. The auto-restore timer and transition remain the recovery authority. If the rejection marker also cannot be written, `shields status` reports the incomplete transition as an error. -If a config path is unsafe, for example a symlink at the Hermes `config.yaml` path, `shields down` refuses that path before it weakens policy, writes a provisional Shields down record, or starts a timer. -The command returns an error and `shields status` remains `UP`. -If an unsafe path appears after the preflight and a provisional Shields down record already exists, the command restores the restrictive policy when it can but keeps the Shields down record until config protection is positively re-verified. This fail-closed behavior also applies when unlock fails after a partial mutation, and requires manual intervention if re-lock cannot be confirmed. +If a config path is unsafe, for example a symlink at the Hermes `config.yaml` path, `shields down` refuses that path before it weakens policy, writes a provisional Shields down record, or starts a timer. The command returns an error and `shields status` remains `UP`. If an unsafe path appears after the preflight and a provisional Shields down record already exists, the command restores the restrictive policy when it can but keeps the Shields down record until config protection is positively re-verified. This fail-closed behavior also applies when unlock fails after a partial mutation, and requires manual intervention if re-lock cannot be confirmed. -If `shields up` reports that the config remains unlocked or drifted, confirm that the sandbox is running and ready, then retry `$$nemoclaw shields up`. -If the retry still fails, rebuild a known-good baseline with `$$nemoclaw rebuild --yes`. +If `shields up` reports that the config remains unlocked or drifted, confirm that the sandbox is running and ready, then retry `$$nemoclaw shields up`. If the retry still fails, rebuild a known-good baseline with `$$nemoclaw rebuild --yes`. -A `CRITICAL` Deep Agents config-lock failure is not an ordinary unlocked or drifted result. -The retry and rebuild guidance above does not apply to a `CRITICAL` Deep Agents config-lock diagnostic. -Do not retry `shields up` or attempt an in-sandbox repair. -Follow [Deep Agents Config Lock Failure Recovery](troubleshooting#deep-agents-config-lock-failure-recovery) to restore a trusted snapshot or recreate the sandbox before retrying. +A `CRITICAL` Deep Agents config-lock failure is not an ordinary unlocked or drifted result. The retry and rebuild guidance above does not apply to a `CRITICAL` Deep Agents config-lock diagnostic. Do not retry `shields up` or attempt an in-sandbox repair. Follow [Deep Agents Config Lock Failure Recovery](troubleshooting#deep-agents-config-lock-failure-recovery) to restore a trusted snapshot or recreate the sandbox before retrying. @@ -1830,116 +1207,41 @@ Host-side gateway recovery uses the same per-sandbox serialization. -For a current NemoClaw-managed Hermes image on the Docker driver, `shields status` is a recovery and reconciliation command, not only a display command. -It first recovers any retained runtime provider state mutation, then applies and verifies the declared recursive posture. -A result exits with status `0` only after the command verifies that posture. -A status check can therefore complete work retained by an interrupted host process. +For a current NemoClaw-managed Hermes image on the Docker driver, `shields status` is a recovery and reconciliation command, not only a display command. It first recovers any retained exact runtime provider state mutation, then applies and verifies the declared recursive posture. A result exits with status `0` only after the command verifies that posture. A status check can therefore complete work retained by an interrupted host process. -If provider recovery restores lockdown while the persisted Shields posture still says mutable, status prints `ERROR (runtime-provider recovery restored lockdown)`, exits with status `2`, and tells you to retry the intended Shields transition. -If live verification of a mutable default or timed Shields down posture fails, status prints `NOT CONFIGURED (DRIFTED...)` or `DOWN (DRIFTED...)`, exits with status `2`, and directs you to run `$$nemoclaw shields up` to reconcile and verify lockdown. -Status does not report `UP`, `DOWN`, or mutable-default with exit status `0` when the provider cannot verify the recursive posture. +If provider recovery restores lockdown while the persisted Shields posture still says mutable, status prints `ERROR (runtime-provider recovery restored lockdown)`, exits with status `2`, and tells you to retry the intended Shields transition. If live verification of a mutable default or timed Shields down posture fails, status prints `NOT CONFIGURED (DRIFTED...)` or `DOWN (DRIFTED...)`, exits with status `2`, and directs you to run `$$nemoclaw shields up` to reconcile and verify lockdown. Status does not report `UP`, `DOWN`, or mutable-default with exit status `0` when the provider cannot verify the exact recursive posture. -While a provider fence is active, ordinary direct-container, SSH, and OpenShell command transports are refused before a sandbox process starts. -They report `Runtime provider state mutation owns direct-container execution for sandbox ''; retry after the provider fence is released.` -Recover and retry as follows: +While an exact provider fence is active, ordinary direct-container, SSH, and OpenShell command transports are refused before a sandbox process starts. They report `Runtime provider state mutation owns direct-container execution for sandbox ''; retry after the provider fence is released.` Recover and retry as follows: 1. Let the active Shields or recovery command finish. 2. Run `$$nemoclaw shields status`. 3. Retry the original command only after provider verification completes and the command exits with status `0`. -Older managed Hermes images use the sealed-plan transition only when the current provider capability is proved absent. -Custom images and legacy Dockerfile workflows remain on their existing transition contract and do not use `provider-state-mutation-v2`. - - - -Before `shields down` opens a new window, NemoClaw must revoke any stale auto-restore timer authority. -If marker cleanup fails, the command reports `Cannot revoke stale auto-restore timer authority` and stops before policy capture, state writes, config unlock, replacement-timer startup, or audit writes. -The sandbox retains its existing configuration and policy posture, and the stale timer authority remains. -Resolve the reported timer-marker error on the trusted host, then retry `shields down`. -When a timed shields-down window reaches its deadline, auto-restore closes the per-sandbox lifecycle deadline gate. -The gate blocks new mutations and waits for the recorded live owner to release its lock generation before auto-restore restores lockdown. -NemoClaw does not signal that process because portable process inspection cannot prove that all descendants stopped. -An interactive command can take over an expired timer. -Interactive recovery has separate transition-takeover and restoration phases. -Each phase makes up to 7 attempts and waits 5 seconds between failures, for up to 30 seconds of retry delay per phase. -Detached recovery uses one 7-attempt budget across deadline setup, main-generation publication, and restoration. -The deadline gate remains closed during those attempts. -If restoration cannot commit, NemoClaw attempts to record durable containment. -If that containment commit also fails, NemoClaw retains any lifecycle and deadline gates it already owns. -A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition. -Correct the reported state-directory write failure, then run `$$nemoclaw shields status` to resume recovery or receive generation recovery guidance. -When recovery cannot complete, an interactive command returns an error, or the detached timer exits with a failure status. -NemoClaw also records durable containment when an owner exits before it can prove that the owner's descendants stopped, or when ownership becomes ambiguous. -Durable containment, retained gates, or the fail-closed state-directory error blocks new mutations until you complete generation operator recovery. -Stop all NemoClaw processes for the sandbox, then follow the paths, identities, tokens, and removal order in the reported error. -Verify each recorded generation is unchanged, remove only the stale generations first, and remove the containment generation last. - -Before a manual Shields transition replaces a policy, NemoClaw requires agreement among the sandbox registry, generated-policy record, and live gateway policy. -`shields down` carries the proven managed MCP policy entries into the relaxed policy. -Restoration removes snapshot-time managed MCP entries before it overlays current entries. -If agreement is absent, a manual Shields transition refuses the replacement policy. -At an expired deadline, auto-restore omits unproven managed MCP policy entries, restores lockdown, and records the omission count in its audit entry. -An MCP server removed during the shields-down window stays removed. -A surviving server keeps its recorded endpoint and address pins while it retains policy ownership. +Older managed Hermes images use the sealed-plan transition only when the current provider capability is proved absent. Custom images and legacy Dockerfile workflows remain on their existing transition contract and do not use `provider-state-mutation-v2`. + + + +Before `shields down` opens a new window, NemoClaw must revoke any stale auto-restore timer authority. If marker cleanup fails, the command reports `Cannot revoke stale auto-restore timer authority` and stops before policy capture, state writes, config unlock, replacement-timer startup, or audit writes. The sandbox retains its existing configuration and policy posture, and the stale timer authority remains. Resolve the reported timer-marker error on the trusted host, then retry `shields down`. When a timed shields-down window reaches its deadline, auto-restore closes the per-sandbox lifecycle deadline gate. The gate blocks new mutations and waits for the recorded live owner to release its exact lock generation before auto-restore restores lockdown. NemoClaw does not signal that process because portable process inspection cannot prove that all descendants stopped. An interactive command can take over an expired timer. Interactive recovery has separate transition-takeover and restoration phases. Each phase makes up to 7 attempts and waits 5 seconds between failures, for up to 30 seconds of retry delay per phase. Detached recovery uses one 7-attempt budget across deadline setup, main-generation publication, and restoration. The deadline gate remains closed during those attempts. If restoration cannot commit, NemoClaw attempts to record durable containment. If that containment commit also fails, NemoClaw retains any exact lifecycle and deadline gates it already owns. A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition. Correct the reported state-directory write failure, then run `$$nemoclaw shields status` to resume recovery or receive exact-generation recovery guidance. When recovery cannot complete, an interactive command returns an error, or the detached timer exits with a failure status. NemoClaw also records durable containment when an owner exits before it can prove that the owner's descendants stopped, or when ownership becomes ambiguous. Durable containment, retained exact gates, or the fail-closed state-directory error blocks new mutations until you complete exact-generation operator recovery. Stop all NemoClaw processes for the sandbox, then follow the paths, identities, tokens, and removal order in the reported error. Verify each recorded generation is unchanged, remove only the exact stale generations first, and remove the exact containment generation last. + +Shields treats OpenShell's current policy as authoritative. `shields down` captures that document as bounded transaction state and preserves its live MCP entries in the temporary relaxed policy without consulting a policy ownership manifest. Restoration performs a three-way reversal of only the changes made by that Shields transaction: a host-side edit made while Shields is down is preserved, while an unchanged temporary value is restored to its pre-transition value. The snapshot and forward document are deleted when the transaction completes; neither becomes durable desired policy state. ### `$$nemoclaw recover` -Repair a stopped in-sandbox gateway and re-establish host-side forwards without opening an SSH session. -Use this after a sandbox crash or whenever `$$nemoclaw status` reports that the sandbox container or agent gateway is not running. - -For a stopped, non-paused Docker-driver container, `recover` starts the existing container before it waits for OpenShell readiness. -It leaves a running or paused container unchanged. -If Docker cannot start the container, `recover` continues to the readiness check and reports the resulting failure. - -`recover` waits up to 30 seconds to acquire the per-sandbox lifecycle lock. -After acquisition, it holds the lock until gateway recovery and forward repair finish. - -For built-in OpenClaw and Hermes sandboxes, `recover` sends an authenticated lifecycle request through registry-scoped privileged direct-container control. -The host selects the controller from the live container topology. -In a direct root-entrypoint container, the request reaches the root PID 1 supervisor. -In an OpenShell-managed container, the request enters the root-owned mode `0500` managed controller through a sanitized root exec while OpenShell remains PID 1. -It does not use ordinary `openshell sandbox exec` or an in-sandbox manual relaunch as a fallback. -When the root-owned managed controller attests two unchanged zero-supervisor process scans with a stable PID 1 and reports `SUPERVISOR_NOT_RUNNING`, a local Docker-driver sandbox with the legacy keepalive startup can enter a transactional container recreation. -The recreation uses a credential-free managed startup command, pins the registered container identity, and retains the previous container for rollback. -Before recreation, NemoClaw backs up the state directories and files declared by the agent manifest. -NemoClaw waits for the replacement to pass managed gateway health and OpenShell re-registration before it restores state. -After state restoration, it restarts the gateway in the replacement container and requires an authenticated `ok` result. -It then runs the managed settle check. -It commits only after the replacement identity, state restoration, gateway restart, and settle check pass. -At the final commit handoff, NemoClaw asks OpenShell to stop the sandbox before it mutates either exact container. -After OpenShell acknowledges that stop, NemoClaw stops the exact replacement, removes the rollback container, and asks OpenShell to start the sandbox through its authoritative lifecycle path. -This preserves OpenShell's stopped/starting event fence while stale Docker removal snapshots settle; raw Docker stop/start events cannot strand the lifecycle row in `Error` or `Deleting`. -If the authoritative stop fails, NemoClaw leaves both containers intact. If the start or final `Ready`/exec/exact-container proof fails after rollback-container removal, NemoClaw reports that automatic rollback is unavailable. -If OpenShell re-registration, state restoration, or a later gateway check fails, NemoClaw attempts to roll back the replacement. -The primary dashboard or API host forward stays stopped. -NemoClaw removes the temporary state backup after a successful restore or rollback. -If state restoration and rollback both fail, it retains the backup and prints host recovery guidance. -Mounted state remains available, but a committed swap does not retain other writable-layer changes. -It is idempotent. -When `recover` repairs a stopped built-in OpenClaw or Hermes gateway, it repeats the recovery action only for these transient results: +Repair a stopped in-sandbox gateway and re-establish host-side forwards without opening an SSH session. Use this after a sandbox crash or whenever `$$nemoclaw status` reports that the sandbox container or agent gateway is not running. + +For a stopped, non-paused Docker-driver container, `recover` starts the existing container before it waits for OpenShell readiness. It leaves a running or paused container unchanged. If Docker cannot start the container, `recover` continues to the readiness check and reports the resulting failure. + +`recover` waits up to 30 seconds to acquire the per-sandbox lifecycle lock. After acquisition, it holds the lock until gateway recovery and forward repair finish. + +For built-in OpenClaw and Hermes sandboxes, `recover` sends an authenticated lifecycle request through registry-scoped privileged direct-container control. The host selects the controller from the live container topology. In a direct root-entrypoint container, the request reaches the root PID 1 supervisor. In an OpenShell-managed container, the request enters the root-owned mode `0500` managed controller through a sanitized root exec while OpenShell remains PID 1. It does not use ordinary `openshell sandbox exec` or an in-sandbox manual relaunch as a fallback. When the root-owned managed controller attests two unchanged zero-supervisor process scans with a stable PID 1 and reports `SUPERVISOR_NOT_RUNNING`, a local Docker-driver sandbox with the legacy keepalive startup can enter a transactional container recreation. The recreation uses a credential-free managed startup command, pins the registered container identity, and retains the previous container for rollback. Before recreation, NemoClaw backs up the state directories and files declared by the agent manifest. NemoClaw waits for the exact replacement to pass managed gateway health and OpenShell re-registration before it restores state. After state restoration, it restarts the gateway in the exact replacement container and requires an authenticated `ok` result. It then runs the managed settle check. It commits only after the replacement identity, state restoration, gateway restart, and settle check pass. At the final commit handoff, NemoClaw asks OpenShell to stop the sandbox before it mutates either exact container. After OpenShell acknowledges that stop, NemoClaw stops the exact replacement, removes the rollback container, and asks OpenShell to start the sandbox through its authoritative lifecycle path. This preserves OpenShell's stopped/starting event fence while stale Docker removal snapshots settle; raw Docker stop/start events cannot strand the lifecycle row in `Error` or `Deleting`. If the authoritative stop fails, NemoClaw leaves both containers intact. If the start or final `Ready`/exec/exact-container proof fails after rollback-container removal, NemoClaw reports that automatic rollback is unavailable. If OpenShell re-registration, state restoration, or a later gateway check fails, NemoClaw attempts to roll back the replacement. The primary dashboard or API host forward stays stopped. NemoClaw removes the temporary state backup after a successful restore or rollback. If state restoration and rollback both fail, it retains the backup and prints host recovery guidance. Mounted state remains available, but a committed swap does not retain other writable-layer changes. It is idempotent. When `recover` repairs a stopped built-in OpenClaw or Hermes gateway, it repeats the recovery action only for these exact transient results: - Status `1` with blank stdout and exactly one stderr line: `SUPERVISOR_NOT_RUNNING`, `SUPERVISOR_DISCOVERY_PENDING`, `PRIVILEGED_CONTROL_UNAVAILABLE`, `GATEWAY_HEALTH_TIMEOUT`, or `SUPERVISOR_BUSY`. - Status `137` with blank stdout and stderr. - Status `1` with blank stdout and exactly one stderr line, `Error response from daemon: Container is restarting, wait until the container is running`. -For the Docker result, `` must be a 64-character lowercase hexadecimal ID that matches the selected registry-owned container. -Recovery makes at most 11 controller attempts in total. -It stops after 3 of those attempts return `SUPERVISOR_BUSY`. -The managed controller emits `SUPERVISOR_DISCOVERY_PENDING` only when an incomplete process-table scan during startup cannot yet prove either one exact supervisor or clean supervisor absence. -That result delays recovery but cannot authorize container recreation or accept a supervisor identity; a later request must perform the complete identity proof again. -Managed settle confirmation treats exact `SUPERVISOR_BUSY` and `SUPERVISOR_DISCOVERY_PENDING` results as inconclusive within its configured window. -Status `137` and the Docker restart result remain terminal during that confirmation. -The managed supervisor startup waiter accepts the four exact startup results, `SUPERVISOR_BUSY`, and the two container-transition results within its separate 11-attempt bound. -Unbound container IDs, reformatted Docker errors, status `137` with nonblank output, and other diagnostic results are terminal. -NemoClaw treats `SUPERVISOR_UNAVAILABLE` as terminal because the managed controller uses it for integrity refusals, ambiguous discovery, and process-identity changes. -It does not repeat the recovery action or treat the settle probe as inconclusive, and instead prints host-side restart and rebuild guidance. -Other controller failures also stop immediately. -Only an exact `SUPERVISOR_NOT_RUNNING` result that remains after the bounded startup retries can enter transactional legacy keepalive recreation. -The pinned controller probe must then confirm the missing supervisor before recreation proceeds. -If the gateway is already running, the command exits zero without force-restarting it; it can still re-evaluate supported safety checks and check or recover host-side forwards. -Use [`$$nemoclaw gateway restart`](#$$nemoclaw-name-gateway-restart) when you deliberately need a running gateway to reload runtime configuration or plugins. +For the Docker result, `` must be a 64-character lowercase hexadecimal ID that matches the selected registry-owned container. Recovery makes at most 11 controller attempts in total. It stops after 3 of those attempts return `SUPERVISOR_BUSY`. The managed controller emits `SUPERVISOR_DISCOVERY_PENDING` only when an incomplete process-table scan during startup cannot yet prove either one exact supervisor or clean supervisor absence. That result delays recovery but cannot authorize container recreation or accept a supervisor identity; a later request must perform the complete identity proof again. Managed settle confirmation treats exact `SUPERVISOR_BUSY` and `SUPERVISOR_DISCOVERY_PENDING` results as inconclusive within its configured window. Status `137` and the Docker restart result remain terminal during that confirmation. The managed supervisor startup waiter accepts the four exact startup results, `SUPERVISOR_BUSY`, and the two container-transition results within its separate 11-attempt bound. Unbound container IDs, reformatted Docker errors, status `137` with nonblank output, and other diagnostic results are terminal. NemoClaw treats `SUPERVISOR_UNAVAILABLE` as terminal because the managed controller uses it for integrity refusals, ambiguous discovery, and process-identity changes. It does not repeat the recovery action or treat the settle probe as inconclusive, and instead prints host-side restart and rebuild guidance. Other controller failures also stop immediately. Only an exact `SUPERVISOR_NOT_RUNNING` result that remains after the bounded startup retries can enter transactional legacy keepalive recreation. The pinned controller probe must then confirm the missing supervisor before recreation proceeds. If the gateway is already running, the command exits zero without force-restarting it; it can still re-evaluate supported safety checks and check or recover host-side forwards. Use [`$$nemoclaw gateway restart`](#$$nemoclaw-name-gateway-restart) when you deliberately need a running gateway to reload runtime configuration or plugins. ```bash $$nemoclaw my-assistant recover @@ -1949,100 +1251,53 @@ $$nemoclaw my-assistant recover -For a portable experimental-profile sandbox with the recorded `ollama-local` provider, `recover` also runs the ownership-bound Ollama checks described for [`connect --probe-only`](#$$nemoclaw-name-connect). -Before it reports success, every completion path verifies the authenticated proxy on port `11435` and requires HTTP 2xx from `inference.local/v1/models`. +For a portable experimental-profile sandbox with the recorded `ollama-local` provider, `recover` also runs the ownership-bound Ollama checks described for [`connect --probe-only`](#$$nemoclaw-name-connect). Before it reports success, every completion path verifies the authenticated proxy on port `11435` and requires HTTP 2xx from `inference.local/v1/models`. -For an active Portable Hermes receipt, `recover` starts only the exact receipt-owned Podman container when it is stopped. -If authenticated health is not ready after that start, recovery launches the receipt-owned `nemoclaw-start` command once and waits for authenticated Hermes health. -For an already-running container, recovery does not launch the startup command, does not stop the container, and only waits for authenticated health. -If a container or OpenShell identity check, reconnect check, or health check later fails after recovery started the container, NemoClaw stops that exact container. -It then requires Podman to report the container as `exited` and OpenShell to report `Error` or `Stopped`. -If NemoClaw cannot prove that rollback, the command reports both the recovery failure and the rollback failure. +For an active Portable Hermes receipt, `recover` starts only the exact receipt-owned Podman container when it is stopped. It waits for the existing in-container `nemoclaw-start` supervisor to establish authenticated Hermes health and never launches another detached `nemoclaw-start` process. If a container or OpenShell identity check, reconnect check, or health check later fails after recovery started the container, NemoClaw stops that exact container. It then requires Podman to report the container as `exited` and OpenShell to report `Error` or `Stopped`. If NemoClaw cannot prove that rollback, the command reports both the recovery failure and the rollback failure. -`recover` re-evaluates the documented Hermes secret boundary against `/sandbox/.hermes/.env` and the supervisor runtime environment on every run, including when the gateway is already healthy. -If the file contains raw secret-shaped values (for example a pasted Telegram, Discord, or Slack bot token in place of the expected `openshell:resolve:env:` placeholder), the command exits non-zero and prints the offending key. -The direct root-entrypoint supervisor stops a running gateway after this refusal, while the managed controller refuses before signaling the observed child. -Replace each flagged value with the `openshell:resolve:env:` placeholder and re-run. -The direct root-entrypoint supervisor verifies the strict root-owned config hash. -The managed controller verifies that strict hash when both Hermes config inputs are root-owned and locked. -Mutable config under the managed topology has no durable root-owned hash anchor and retains the same trust and time-of-check/time-of-use limits as managed cold start. -If the boundary validator or supervisor helper is missing, recovery fails closed, names the sandbox, explains that `/sandbox/.hermes/.env` could not be re-evaluated, and leaves an otherwise healthy gateway untouched. -Rebuild an older sandbox image with `$$nemoclaw rebuild --yes` before retrying. +`recover` re-evaluates the documented Hermes secret boundary against `/sandbox/.hermes/.env` and the supervisor runtime environment on every run, including when the gateway is already healthy. If the file contains raw secret-shaped values (for example a pasted Telegram, Discord, or Slack bot token in place of the expected `openshell:resolve:env:` placeholder), the command exits non-zero and prints the offending key. The direct root-entrypoint supervisor stops a running gateway after this refusal, while the managed controller refuses before signaling the observed child. Replace each flagged value with the `openshell:resolve:env:` placeholder and re-run. The direct root-entrypoint supervisor verifies the strict root-owned config hash. The managed controller verifies that strict hash when both Hermes config inputs are root-owned and locked. Mutable config under the managed topology has no durable root-owned hash anchor and retains the same trust and time-of-check/time-of-use limits as managed cold start. If the boundary validator or supervisor helper is missing, recovery fails closed, names the sandbox, explains that `/sandbox/.hermes/.env` could not be re-evaluated, and leaves an otherwise healthy gateway untouched. Rebuild an older sandbox image with `$$nemoclaw rebuild --yes` before retrying. -While a NemoClaw cron restore gate exists, Hermes `recover` keeps the same lifecycle lock through restore validation and gate release. -That controller call has a 130-second host timeout; the earlier 30-second limit applies only to lifecycle-lock acquisition. +While a NemoClaw cron restore gate exists, Hermes `recover` keeps the same lifecycle lock through restore validation and gate release. That controller call has a 130-second host timeout; the earlier 30-second limit applies only to lifecycle-lock acquisition. -After gateway and forward recovery, Hermes `recover` also checks for a NemoClaw cron restore gate retained by an interrupted rebuild. -It validates the restored cron jobs and scripts before it clears that gate, and it leaves an independent Hermes operator drain active. -A validation failure leaves the NemoClaw gate in place so new Hermes turns and cron dispatch remain blocked. +After gateway and forward recovery, Hermes `recover` also checks for a NemoClaw cron restore gate retained by an interrupted rebuild. It validates the restored cron jobs and scripts before it clears that gate, and it leaves an independent Hermes operator drain active. A validation failure leaves the NemoClaw gate in place so new Hermes turns and cron dispatch remain blocked. -The privileged control path requires a running direct sandbox container that belongs to the named registry entry. -Supported built-in images use either a direct root entrypoint or the OpenShell-managed process shape with OpenShell as PID 1 and exactly one nonroot `nemoclaw-start` supervisor. -An arbitrary nonroot entrypoint that does not match the supported OpenShell-managed process shape fails with the `privileged control unavailable` failure layer. -Kubernetes and other deployments without a matching direct container also fail with that layer. +The privileged control path requires a running direct sandbox container that belongs to the named registry entry. Supported built-in images use either a direct root entrypoint or the OpenShell-managed process shape with OpenShell as PID 1 and exactly one nonroot `nemoclaw-start` supervisor. An arbitrary nonroot entrypoint that does not match the supported OpenShell-managed process shape fails with the `privileged control unavailable` failure layer. Kubernetes and other deployments without a matching direct container also fail with that layer. ### `$$nemoclaw gateway restart` -Force-restart the supported in-sandbox gateway process through the controller for the live container topology. -Use this after runtime configuration or plugin changes that the agent reads only at gateway startup, such as Hermes Langfuse plugin settings. -Unlike `recover`, this command restarts a healthy gateway instead of exiting after the health probe. +Force-restart the supported in-sandbox gateway process through the controller for the live container topology. Use this after runtime configuration or plugin changes that the agent reads only at gateway startup, such as Hermes Langfuse plugin settings. Unlike `recover`, this command restarts a healthy gateway instead of exiting after the health probe. ```bash $$nemoclaw my-assistant gateway restart [--quiet|-q] ``` -On success, the command reports that the gateway was restarted, health passed, and forwards were checked or recovered. -It also checks the dashboard forward, messaging forward, and manifest-declared agent forwards. -`--quiet` suppresses progress lines but still prints refusal diagnostics. -In the direct root-entrypoint topology, PID 1 stops only the gateway child whose process ID and process start identity match the tracked child, applies the restart seal, and launches the replacement under the separate `gateway` UID. -In the OpenShell-managed topology, the installed root controller verifies a stable OpenShell to `nemoclaw-start` to gateway process shape, holds a root-only lifecycle lock, publishes one root-owned exit authorization bound to the gateway process ID, kernel start identity, and live controller identity, pidfd-targets the observed child, waits for the nonroot entrypoint supervisor to respawn it under the sandbox UID, and proves the replacement listener and HTTP health. -That managed process proof prevents PID reuse from redirecting the signal but cannot establish provenance against a malicious same-UID process or create gateway and agent UID isolation. -For Hermes, the entrypoint supervisor also owns the dashboard process, internal API relay, dashboard relay, and gateway log stream. -The managed nonroot supervisor continuously repairs those processes, stops an alive but deaf gateway after four consecutive failed health checks, and quarantines relaunch after five unexpected exits or failed replacement candidates within 60 seconds until sandbox recreation. -That authorization keeps an authenticated host-requested exit out of the crash budget while its root controller remains live; it records host intent for the exit but does not claim that the host signal was the only possible cause in the shared-UID topology. -The host repairs only the host-side OpenShell forwards after the supervisor reports a healthy gateway. +On success, the command reports that the gateway was restarted, health passed, and forwards were checked or recovered. It also checks the dashboard forward, messaging forward, and manifest-declared agent forwards. `--quiet` suppresses progress lines but still prints refusal diagnostics. In the direct root-entrypoint topology, PID 1 stops only the gateway child whose process ID and process start identity match the tracked child, applies the restart seal, and launches the replacement under the separate `gateway` UID. In the OpenShell-managed topology, the installed root controller verifies a stable OpenShell to `nemoclaw-start` to gateway process shape, holds a root-only lifecycle lock, publishes one root-owned exit authorization bound to the exact gateway process ID, kernel start identity, and live controller identity, pidfd-targets the observed child, waits for the nonroot entrypoint supervisor to respawn it under the sandbox UID, and proves the replacement listener and HTTP health. That managed process proof prevents PID reuse from redirecting the signal but cannot establish provenance against a malicious same-UID process or create gateway and agent UID isolation. For Hermes, the entrypoint supervisor also owns the dashboard process, internal API relay, dashboard relay, and gateway log stream. The managed nonroot supervisor continuously repairs those processes, stops an alive but deaf gateway after four consecutive failed health checks, and quarantines relaunch after five unexpected exits or failed replacement candidates within 60 seconds until sandbox recreation. That authorization keeps an authenticated host-requested exit out of the crash budget while its exact root controller remains live; it records host intent for the exit but does not claim that the host signal was the only possible cause in the shared-UID topology. The host repairs only the host-side OpenShell forwards after the supervisor reports a healthy gateway. -For Hermes, both controllers validate `/sandbox/.hermes/.env` against the secret-boundary guard and validate the supervisor runtime environment before restart. -The direct root-entrypoint supervisor verifies `/sandbox/.hermes/config.yaml` and `.env` against the root-owned strict hash and relaunches the process as the `gateway` user. -The managed controller verifies the strict hash when both config inputs are root-owned and locked, but mutable managed config retains cold-start-equivalent trust and time-of-check/time-of-use limits. -Neither controller recomputes a trusted strict hash to adopt direct in-sandbox edits. -Use supported host commands such as `$$nemoclaw config set` and `$$nemoclaw inference set` for intended runtime configuration changes because those commands update the managed config metadata together. -When a strict hash is available and does not match, the command reports the `config hash mismatch` failure layer. -Hermes host config writes, shields transitions, and lifecycle seals share one root-only mutation lock. -Config writes are bound to the digest of the matching read and atomically refresh the strict and compatibility hashes before the prior ownership and mode posture is restored. -The shields transition keeps that lock through recursive filesystem updates, verification, and content-seal capture, and replaces sensitive inodes before lockdown. -If a concurrent lifecycle request reports `SUPERVISOR_BUSY`, or a config or shields command reports `Hermes config mutation is already in progress`, wait for the active operation to finish and retry. -Run `$$nemoclaw shields down` before a Hermes config or inference change; these commands refuse to mutate a shields-up sandbox. +For Hermes, both controllers validate `/sandbox/.hermes/.env` against the secret-boundary guard and validate the supervisor runtime environment before restart. The direct root-entrypoint supervisor verifies `/sandbox/.hermes/config.yaml` and `.env` against the root-owned strict hash and relaunches the process as the `gateway` user. The managed controller verifies the strict hash when both config inputs are root-owned and locked, but mutable managed config retains cold-start-equivalent trust and time-of-check/time-of-use limits. Neither controller recomputes a trusted strict hash to adopt direct in-sandbox edits. Use supported host commands such as `$$nemoclaw config set` and `$$nemoclaw inference set` for intended runtime configuration changes because those commands update the managed config metadata together. When a strict hash is available and does not match, the command reports the `config hash mismatch` failure layer. Hermes host config writes, shields transitions, and lifecycle seals share one root-only mutation lock. Config writes are bound to the digest of the matching read and atomically refresh the strict and compatibility hashes before the prior ownership and mode posture is restored. The shields transition keeps that lock through recursive filesystem updates, verification, and content-seal capture, and replaces sensitive inodes before lockdown. If a concurrent lifecycle request reports `SUPERVISOR_BUSY`, or a config or shields command reports `Hermes config mutation is already in progress`, wait for the active operation to finish and retry. Run `$$nemoclaw shields down` before a Hermes config or inference change; these commands refuse to mutate a shields-up sandbox. -The command can fail at these layers: unsupported agent, privileged control unavailable, supervisor not running, secret-boundary refusal, unsafe config path, config hash mismatch when a strict hash is available, MCP reconciliation refusal, relaunch quarantined, launch failure, health timeout, or forward recovery failure. -`relaunch quarantined` means the in-sandbox supervisor stopped attempting relaunch after a startup refusal or repeated gateway exits, so restart and recovery report the supported repair, `$$nemoclaw rebuild --yes`, instead of a retry. -An older direct-container image without the matching supervisor or managed controller helper reports `privileged control unavailable` and requires `$$nemoclaw rebuild --yes`. -Ordinary OpenShell exec and manual in-sandbox relaunch are not fallback paths. -Terminal agents do not have a gateway runtime and fail as unsupported. +The command can fail at these layers: unsupported agent, privileged control unavailable, supervisor not running, secret-boundary refusal, unsafe config path, config hash mismatch when a strict hash is available, MCP reconciliation refusal, relaunch quarantined, launch failure, health timeout, or forward recovery failure. `relaunch quarantined` means the in-sandbox supervisor stopped attempting relaunch after a startup refusal or repeated gateway exits, so restart and recovery report the supported repair, `$$nemoclaw rebuild --yes`, instead of a retry. An older direct-container image without the matching supervisor or managed controller helper reports `privileged control unavailable` and requires `$$nemoclaw rebuild --yes`. Ordinary OpenShell exec and manual in-sandbox relaunch are not fallback paths. Terminal agents do not have a gateway runtime and fail as unsupported. ### `$$nemoclaw stop` -Stop the sandbox's local runtime container while preserving all of its state. -Workspace files, credentials, network policies, the registry entry, and the OpenShell sandbox record stay in place. -Use this to free CPU, memory, and GPU resources without destroying the sandbox; use [`$$nemoclaw destroy`](#$$nemoclaw-name-destroy) when you want to delete it instead. +Stop the sandbox's local runtime container while preserving all of its state. Workspace files, credentials, network policies, the registry entry, and the OpenShell sandbox record stay in place. Use this to free CPU, memory, and GPU resources without destroying the sandbox; use [`$$nemoclaw destroy`](#$$nemoclaw-name-destroy) when you want to delete it instead. ```bash $$nemoclaw my-assistant stop @@ -2050,29 +1305,30 @@ $$nemoclaw my-assistant stop -For an active Portable Hermes receipt, `stop` targets only the receipt-owned Podman container and waits for its terminal stopped state without using Docker as a fallback. -A `pending` or `configuring` receipt instead directs you to resume Portable onboarding. -Portable Hermes does not run the Docker provider's channel hook or the generic post-stop dashboard-forward cleanup. +For an active Portable Hermes receipt, `stop` targets only the exact receipt-owned Podman container and waits for its terminal stopped state without using Docker as a fallback. A `pending` or `configuring` receipt instead directs you to resume Portable onboarding. Portable Hermes does not run the Docker provider's channel hook or the generic post-stop dashboard-forward cleanup. -For OpenClaw-managed gateways, the command first asks the in-sandbox gateway to shut down its channels gracefully; non-Portable agent-managed gateways (for example Hermes) are supervised inside the sandbox and shut down with the container's stop signal. -Then the container stops; a container stuck in a crash loop is stopped the same way, which also disarms its restart policy. +For OpenClaw-managed gateways, the command first asks the in-sandbox gateway to shut down its channels gracefully; non-Portable agent-managed gateways (for example Hermes) are supervised inside the sandbox and shut down with the container's stop signal. Then the container stops; a container stuck in a crash loop is stopped the same way, which also disarms its restart policy. + -Except for Portable Hermes, after the container stops NemoClaw attempts to stop that sandbox's host dashboard forward. -If the container does not stop, NemoClaw leaves the dashboard forward running. + Except for Portable Hermes, after the container stops NemoClaw attempts to stop that sandbox's + host dashboard forward. If the container does not stop, NemoClaw leaves the dashboard forward + running. -The shared host gateway, tunnel services, and any local NIM inference container serve other sandboxes and keep running. +The shared host gateway, tunnel services, and any local NIM inference container serve other +sandboxes and keep running. -Stopping an already-stopped sandbox succeeds. -Except for Portable Hermes, NemoClaw also attempts to remove any leftover dashboard forward for that sandbox. + Stopping an already-stopped sandbox succeeds. Except for Portable Hermes, NemoClaw also attempts + to remove any leftover dashboard forward for that sandbox. -Stopping an already-stopped sandbox succeeds without changes. + Stopping an already-stopped sandbox succeeds without changes. -The command is available only when NemoClaw holds local-container authority. -Portable profiles use receipt-owned Podman authority; non-Portable local-container paths use the default Docker driver or the vm driver. -Remote drivers such as kubernetes are unavailable, and an unreachable selected runtime produces an outage report instead of a guessed container state. +The command is available only when NemoClaw holds local-container authority. Portable profiles use +receipt-owned Podman authority; non-Portable local-container paths use the default Docker driver or +the vm driver. Remote drivers such as kubernetes are unavailable, and an unreachable selected +runtime produces an outage report instead of a guessed container state. ### `$$nemoclaw start` @@ -2084,119 +1340,63 @@ $$nemoclaw my-assistant start -For an active Portable Hermes receipt, `start` follows the Podman container, supervisor, authenticated health, and rollback contract documented for [`recover`](#$$nemoclaw-name-recover). It does not use Docker as a fallback. -A `pending` or `configuring` receipt instead directs you to resume Portable onboarding. +For an active Portable Hermes receipt, `start` follows the exact Podman container, supervisor, authenticated health, and rollback contract documented for [`recover`](#$$nemoclaw-name-recover); it does not fall back to Docker. A `pending` or `configuring` receipt instead directs you to resume Portable onboarding. -Starting an already-running sandbox skips the container start and still runs the gateway and forward health checks. -A paused container is unpaused. -If the container was removed entirely, `start` fails and points you to `$$nemoclaw rebuild`. +Starting an already-running sandbox skips the container start and still runs the gateway and forward health checks. A paused container is unpaused. If the container was removed entirely, `start` fails and points you to `$$nemoclaw rebuild`. -Before it repairs the gateway and host forwards, `start` waits for OpenShell to report the sandbox in the `Ready` or `Running` state, using the same `300`-second budget and `NEMOCLAW_CONNECT_TIMEOUT` override as `connect --probe-only`. + Before it repairs the gateway and host forwards, `start` waits for OpenShell to report the sandbox + in the `Ready` or `Running` state, using the same `300`-second budget and + `NEMOCLAW_CONNECT_TIMEOUT` override as `connect --probe-only`. -Before it verifies the managed terminal runtime, `start` waits for OpenShell to report the sandbox in the `Ready` or `Running` state, using the same `300`-second budget and `NEMOCLAW_CONNECT_TIMEOUT` override as `connect --probe-only`. + Before it verifies the managed terminal runtime, `start` waits for OpenShell to report the sandbox + in the `Ready` or `Running` state, using the same `300`-second budget and + `NEMOCLAW_CONNECT_TIMEOUT` override as `connect --probe-only`. -When that deadline expires, `start` keeps the existing container, exits non-zero, and prints the `NEMOCLAW_CONNECT_TIMEOUT` value to use on the next run. +When that deadline expires, `start` keeps the existing container, exits non-zero, and prints the +`NEMOCLAW_CONNECT_TIMEOUT` value to use on the next run. -After the gateway and forward checks pass, `start` sends one inference request through `https://inference.local` using the sandbox's recorded provider and model. -A gateway that answers the `/v1/models` probe can still reject an inference request or return an invalid result, so the command exits non-zero in either case. -It prints the probe result, including the HTTP status when the route returned one, and points you to the sandbox doctor command. -Each run sends one 16-token request through the stored provider credential, so `start` waits up to 30 seconds for it and consumes provider tokens on a hosted route. -When the sandbox records no provider or no model, `start` skips the request and exits `0`. -`doctor` still classifies an HTTP `401` or `403` route response as reachable, so correct the provider credential when `start` reports one of those statuses. +After the gateway and forward checks pass, `start` sends one inference request through `https://inference.local` using the sandbox's recorded provider and model. A gateway that answers the `/v1/models` probe can still reject an inference request or return an invalid result, so the command exits non-zero in either case. It prints the probe result, including the HTTP status when the route returned one, and points you to the sandbox doctor command. Each run sends one 16-token request through the stored provider credential, so `start` waits up to 30 seconds for it and consumes provider tokens on a hosted route. When the sandbox records no provider or no model, `start` skips the request and exits `0`. `doctor` still classifies an HTTP `401` or `403` route response as reachable, so correct the provider credential when `start` reports one of those statuses. ### `$$nemoclaw status` -Show sandbox-scoped status, health, and inference configuration for one registered sandbox. -Use this form when you care about a specific sandbox's live OpenShell state, agent runtime, inference health, GPU proof, permissions, and recovery hints. -Do not pass a sandbox name to `$$nemoclaw status`; that command is the global all-sandbox/service overview. -NemoClaw resolves the sandbox's recorded owning OpenShell gateway before querying live state. -If another gateway is active, it selects the owner and queries again instead of trusting a result from the sibling gateway. +Show sandbox-scoped status, health, and inference configuration for one registered sandbox. Use this form when you care about a specific sandbox's live OpenShell state, agent runtime, inference health, GPU proof, permissions, and recovery hints. Do not pass a sandbox name to `$$nemoclaw status`; that command is the global all-sandbox/service overview. NemoClaw resolves the sandbox's recorded owning OpenShell gateway before querying live state. If another gateway is active, it selects the owner and queries again instead of trusting a result from the sibling gateway. -For Portable Hermes, `status` reports `Portable lifecycle phase: pending`, `configuring`, or `active` from the receipt authority without running Docker or OpenClaw status work. -An active receipt must match its sandbox registry lifecycle identity; a mismatch fails instead of reporting status from another runtime. - - - -For a `compatible-endpoint` route that uses `openai-completions`, the text output prints `Reasoning effort` as `low`, `medium`, `high`, or `endpoint-default`. -The line is omitted for another provider or API family. - -Pass `--json` to emit a structured per-sandbox report instead of the text renderer. -The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `baselineExclusions`, `baselineExclusionStates`, `baselineExclusionTransition`, `failureLayer`, `terminalRuntimeHealth`, `servingProcessHealth`, and `dockerPaused`. -`baselineExclusions` is an array of baseline keys recorded for durable replay and is empty when the sandbox has none. -`baselineExclusionStates` reports each recorded key with its current verification state. -The `excluded` state means the reviewed entry still matches the active agent baseline and the key is absent from the live OpenShell policy. -Other states identify agent drift, changed or removed baseline content, an unreadable baseline or live policy, or a live policy that contains the excluded key. -`baselineExclusionTransition` is `null` when policy state is settled; otherwise it identifies the interrupted `exclude` or `restore` key that must be reconciled before sandbox creation or recreation, rebuild, or cross-sandbox snapshot cloning. -The schema-version `1` `model` and `provider` fields keep their established live-route meaning when the gateway route is readable. -Use `recordedRoute` for the sandbox's durable provider and model and `liveRoute` for the gateway-global route. -When the live shared route differs, text output prints both routes and JSON output sets `routeDrift.live`, `routeDrift.recorded`, and `routeDrift.canConnect`. -When `routeDrift.canConnect` is `false`, `connect` cannot safely restore the recorded route because provider-global identity differs or required route or gateway metadata is incomplete. -Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for the route-sharing workflow. -`openshellDriver` and `openshellVersion` are always strings (falling back to `"unknown"` when the registry has no value), so consumers can rely on `typeof` checks. -`agent` is always a string and reports `openclaw` when the registry records no agent for the sandbox. -`failureLayer` is `null` when no preflight failure was detected and otherwise one of `docker_unreachable`, `sandbox_container_stopped`, or `sandbox_dashboard_port_conflict`; when set, `inferenceHealth` is suppressed to `null` so automation does not see a stale remote-provider healthy status during a local outage. -`inferenceHealth.ok` reports whether the inference route returned a structurally valid result for one request sent from inside the sandbox. -The result must match Chat Completions, Responses, or Anthropic Messages for the selected route. -An empty body, malformed JSON, provider-error envelope, or wrong response shape reports `unhealthy`, even with a 2xx status. -The probe captures at most 64 KiB and does not include the response body in diagnostics. -The route probe treats any final HTTP status from `200` through `499` as reachable, so a route with an invalidated provider credential answers HTTP `401` while the route is up. -The request uses the live gateway route's provider and model, and falls back to the recorded values when the live route is unreadable. -When the live provider matches the recorded provider, the request uses the sandbox's recorded API family, even when only the model differs. -This includes `openai-responses`. -When the live provider differs, NemoClaw does not carry the recorded API family to the live provider. -An ordinary run sends one 16-token request through the stored provider credential, with a 30-second timeout, and consumes provider tokens on a hosted route. -When the same `status` run recovers a managed gateway, it retries the route and inference request together up to three total attempts, with a two-second delay between failed attempts. -Each attempt can consume another 16 tokens on a hosted route. -When NemoClaw sends an inference request, `inferenceHealth.subprobes` reports the route probe result as the `route reachability` hop, so a failing verdict still shows that the route itself answered. -`inferenceHealth.failureLabel` reports why the inference request failed: +For Portable Hermes, `status` reports `Portable lifecycle phase: pending`, `configuring`, or `active` from the receipt authority without running Docker or OpenClaw status work. An active receipt must match its sandbox registry lifecycle identity; a mismatch fails instead of reporting status from another runtime. + + + +For a `compatible-endpoint` route that uses `openai-completions`, the text output prints `Reasoning effort` as `low`, `medium`, `high`, or `endpoint-default`. The line is omitted for another provider or API family. + +Pass `--json` to emit a structured per-sandbox report instead of the text renderer. The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `policiesAvailable`, `failureLayer`, `terminalRuntimeHealth`, `servingProcessHealth`, and `dockerPaused`. `policies` is derived from the current OpenShell policy; NemoClaw does not persist a second preset list or baseline-exclusion ledger. `policiesAvailable` is `false` when that live policy cannot be read or parsed, distinguishing an unavailable result from a verified empty `policies` array; text status prints `Policies: unavailable` for the same state. The schema-version `1` `model` and `provider` fields keep their established live-route meaning when the gateway route is readable. Use `recordedRoute` for the sandbox's durable provider and model and `liveRoute` for the gateway-global route. When the live shared route differs, text output prints both routes and JSON output sets `routeDrift.live`, `routeDrift.recorded`, and `routeDrift.canConnect`. When `routeDrift.canConnect` is `false`, `connect` cannot safely restore the recorded route because provider-global identity differs or required route or gateway metadata is incomplete. Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for the route-sharing workflow. `openshellDriver` and `openshellVersion` are always strings (falling back to `"unknown"` when the registry has no value), so consumers can rely on `typeof` checks. `agent` is always a string and reports `openclaw` when the registry records no agent for the sandbox. `failureLayer` is `null` when no preflight failure was detected and otherwise one of `docker_unreachable`, `sandbox_container_stopped`, or `sandbox_dashboard_port_conflict`; when set, `inferenceHealth` is suppressed to `null` so automation does not see a stale remote-provider healthy status during a local outage. `inferenceHealth.ok` reports whether the inference route returned a structurally valid result for one request sent from inside the sandbox. The result must match Chat Completions, Responses, or Anthropic Messages for the selected route. An empty body, malformed JSON, provider-error envelope, or wrong response shape reports `unhealthy`, even with a 2xx status. The probe captures at most 64 KiB and does not include the response body in diagnostics. The route probe treats any final HTTP status from `200` through `499` as reachable, so a route with an invalidated provider credential answers HTTP `401` while the route is up. The request uses the live gateway route's provider and model, and falls back to the recorded values when the live route is unreadable. When the live provider matches the recorded provider, the request uses the sandbox's recorded API family, even when only the model differs. This includes `openai-responses`. When the live provider differs, NemoClaw does not carry the recorded API family to the live provider. An ordinary run sends one 16-token request through the stored provider credential, with a 30-second timeout, and consumes provider tokens on a hosted route. When the same `status` run recovers a managed gateway, it retries the route and inference request together up to three total attempts, with a two-second delay between failed attempts. Each attempt can consume another 16 tokens on a hosted route. When NemoClaw sends an inference request, `inferenceHealth.subprobes` reports the route probe result as the `route reachability` hop, so a failing verdict still shows that the route itself answered. `inferenceHealth.failureLabel` reports why the inference request failed: - `unauthorized` when the route rejected it with HTTP `401` or `403`. - `unhealthy` when the route returned another failing HTTP status or an invalid 2xx response body. - `unreachable` when the request returned no HTTP status, including a probe that could not run. -A host-side upstream probe under `inferenceHealth.subprobes` stays a diagnostic and does not change `inferenceHealth.ok`, because the sandbox route is the one the agent uses. -When the route probe failed, or the sandbox records no provider or no model, NemoClaw skips the inference request and `inferenceHealth` reports the route probe result alone. -`dockerPaused` is `true` when NemoClaw detects that the Docker-driver sandbox container is paused. -In that case, text output keeps OpenShell's authoritative phase but prints a `docker unpause ` recovery hint instead of sending you directly to rebuild. -For terminal runtime sandboxes, the command also checks cgroup OOM kill counters. -If the counter records an OOM kill, text output prints `Runtime health: degraded (... OOM kill recorded)` and points you to `$$nemoclaw rebuild`; JSON output reports `terminalRuntimeHealth.kind: "degraded"` with the OOM kill count and source counter path. -For a present gateway runtime, text output prints `Serving process ( gateway): not checked`, and JSON output reports `servingProcessHealth: { "checked": false }`. -The existing inference probes run in a fresh sandbox command, so they do not attest that the long-running gateway process has equivalent inference access. -NemoClaw does not probe the serving process yet. -For terminal runtimes, `servingProcessHealth` is `null` and the text output omits this line because there is no long-running gateway process. -The command exits non-zero when the sandbox is missing locally, the gateway state is not `present`, the gateway reports a schema/protobuf mismatch (mirrored as `rpcIssue`), `failureLayer` is non-null, the authoritative in-sandbox inference route fails or cannot be probed, or a terminal runtime sandbox reports a recorded OOM kill. -When the canonical text command targets an unregistered name, it reports that the sandbox is not registered and tells you to run `$$nemoclaw list`. -The alias form `$$nemoclaw status --json` requires the sandbox to be registered locally; the canonical form `$$nemoclaw sandbox status --json` is the one to use from automation that may run against an unknown sandbox name, since it still emits a JSON document with `found: false` instead of a text error. - -For a sandbox that owns managed llama.cpp, text output also reports the recipe ID, model digest, image reference, `https://inference.local/v1` endpoint, and lifecycle state. -It does not print the managed API key or its fingerprint. -The lifecycle state is one of these values: +A host-side upstream probe under `inferenceHealth.subprobes` stays a diagnostic and does not change `inferenceHealth.ok`, because the sandbox route is the one the agent uses. When the route probe failed, or the sandbox records no provider or no model, NemoClaw skips the inference request and `inferenceHealth` reports the route probe result alone. `dockerPaused` is `true` when NemoClaw detects that the Docker-driver sandbox container is paused. In that case, text output keeps OpenShell's authoritative phase but prints a `docker unpause ` recovery hint instead of sending you directly to rebuild. For terminal runtime sandboxes, the command also checks cgroup OOM kill counters. If the counter records an OOM kill, text output prints `Runtime health: degraded (... OOM kill recorded)` and points you to `$$nemoclaw rebuild`; JSON output reports `terminalRuntimeHealth.kind: "degraded"` with the OOM kill count and source counter path. For a present gateway runtime, text output prints `Serving process ( gateway): not checked`, and JSON output reports `servingProcessHealth: { "checked": false }`. The existing inference probes run in a fresh sandbox command, so they do not attest that the long-running gateway process has equivalent inference access. NemoClaw does not probe the serving process yet. For terminal runtimes, `servingProcessHealth` is `null` and the text output omits this line because there is no long-running gateway process. The command exits non-zero when the sandbox is missing locally, the gateway state is not `present`, the gateway reports a schema/protobuf mismatch (mirrored as `rpcIssue`), `failureLayer` is non-null, the authoritative in-sandbox inference route fails or cannot be probed, or a terminal runtime sandbox reports a recorded OOM kill. When the canonical text command targets an unregistered name, it reports that the sandbox is not registered and tells you to run `$$nemoclaw list`. The alias form `$$nemoclaw status --json` requires the sandbox to be registered locally; the canonical form `$$nemoclaw sandbox status --json` is the one to use from automation that may run against an unknown sandbox name, since it still emits a JSON document with `found: false` instead of a text error. -| State | Meaning | -|---|---| -| `preparing` | The gateway-scoped owner exists, but `receipt.json` is absent. | -| `running` | The receipt-owned container is running under the recorded Docker authority. | -| `stopped` | The receipt-owned container exists but is stopped. | -| `absent` | The finalized receipt exists, but its container is absent. | -| `conflict` | The Docker authority or runtime identity differs from the receipt. | -| `unknown` | NemoClaw cannot read or prove the state. | - -The managed llama.cpp check forces a nonzero exit for `absent`, `conflict`, or `unknown`. -Other sandbox and inference checks can also make the command fail. -Rerun the same `NEMOCLAW_PROVIDER=install-llama-cpp` and `NEMOCLAW_LLAMACPP_RECIPE` onboarding selection to recover a stopped or interrupted runtime. -Inspect and correct an identity conflict before retrying. +For a sandbox that owns managed llama.cpp, text output also reports the recipe ID, model digest, image reference, `https://inference.local/v1` endpoint, and lifecycle state. It does not print the managed API key or its fingerprint. The lifecycle state is one of these values: + +| State | Meaning | +| ----------- | --------------------------------------------------------------------------------- | +| `preparing` | The gateway-scoped owner exists, but `receipt.json` is absent. | +| `running` | The exact receipt-owned container is running under the recorded Docker authority. | +| `stopped` | The exact receipt-owned container exists but is stopped. | +| `absent` | The finalized receipt exists, but its exact container is absent. | +| `conflict` | The Docker authority or runtime identity differs from the receipt. | +| `unknown` | NemoClaw cannot read or prove the state. | + +The managed llama.cpp check forces a nonzero exit for `absent`, `conflict`, or `unknown`. Other sandbox and inference checks can also make the command fail. Rerun the same `NEMOCLAW_PROVIDER=install-llama-cpp` and `NEMOCLAW_LLAMACPP_RECIPE` onboarding selection to recover a stopped or interrupted runtime. Inspect and correct an identity conflict before retrying. -For a Deep Agents sandbox, text output includes `DCode auto-approval capability: disabled` or `DCode auto-approval capability: thread-opt-in`. -JSON output reports the same configured value in `dcodeAutoApprovalMode`. -This value does not attest that auto-approval is active in any live TUI thread. +For a Deep Agents sandbox, text output includes `DCode auto-approval capability: disabled` or `DCode auto-approval capability: thread-opt-in`. JSON output reports the same configured value in `dcodeAutoApprovalMode`. This value does not attest that auto-approval is active in any live TUI thread. @@ -2206,12 +1406,10 @@ $$nemoclaw my-assistant status --json $$nemoclaw sandbox status my-assistant --json ``` -The command probes `https://inference.local/v1/models` from inside the sandbox, and when that probe reports the route reachable it sends one inference request over the same route. -That inference request is the authoritative inference health check, and both checks exercise the route that agent traffic uses. -The main `Inference` line reports one of these states: +The command probes `https://inference.local/v1/models` from inside the sandbox, and when that probe reports the route reachable it sends one inference request over the same route. That inference request is the authoritative inference health check, and both checks exercise the route that agent traffic uses. The main `Inference` line reports one of these states: | State | Meaning | -|-------|---------| +| --- | --- | | `healthy` | The route returned a structurally valid result for the inference request. | | `unauthorized` | The route rejected the inference request with HTTP `401` or `403`. | | `reachable` | The route returned an HTTP status from `200` through `499` and NemoClaw did not send an inference request. | @@ -2220,35 +1418,18 @@ The main `Inference` line reports one of these states: | `not probed` | NemoClaw could not run the authoritative route probe from a reachable sandbox. | | `not verified` | NemoClaw could not verify the sandbox or gateway state, so it skips inference probing. | -An authentication response on the route probe alone confirms that the route is reachable, not that provider credentials are valid. -`$$nemoclaw doctor` sends no inference request, so it reports an HTTP `401` or `403` route response as reachable and exits `0` where `status` reports `unauthorized`. -The command can also print direct host-side provider checks such as `Inference (upstream)` and provider-specific subprobes. -For supported remote providers, this diagnostic sends an authenticated request to the configured model and accepts only a recognized Chat Completions, streaming Chat Completions, or Anthropic Messages response. -It uses a 3-second connection timeout, a 5-second total timeout, and an 8-token output limit. -If the request reaches the time limit, NemoClaw reports the provider as `not probed` and leaves model health unverified instead of reporting it as unhealthy. -These checks are diagnostic only and do not override the authoritative `inference.local` result or determine the command exit status. - -The `Inference (upstream)` check authenticates with the host credential that NemoClaw resolves for the provider, such as `NVIDIA_INFERENCE_API_KEY`. -The gateway stores the provider credential that the sandbox route uses. -The CLI cannot read the stored value back, so the two credentials can hold different secrets. -When the `inference.local` route has already served the inference request, an `unauthorized` result on `Inference (upstream)` describes the host credential. -NemoClaw then reports that check as `not probed` and names both credential sources. -An `Inference (upstream)` check that fails for another reason, such as `unreachable`, still reports its own state. -Local backend and auth proxy checks, such as `Inference (auth proxy)`, always report their own state and their own repair step. -`$$nemoclaw doctor` sends no inference request, so it always reports the `Inference (upstream)` state that it measured. - -Local providers add host-side backend diagnostics. -For Local Ollama, the command can also print an `Inference (auth proxy)` diagnostic when a proxy token is available. -Use these diagnostics to identify a failing auxiliary hop after checking the main `Inference` line. +An authentication response on the route probe alone confirms that the route is reachable, not that provider credentials are valid. `$$nemoclaw doctor` sends no inference request, so it reports an HTTP `401` or `403` route response as reachable and exits `0` where `status` reports `unauthorized`. The command can also print direct host-side provider checks such as `Inference (upstream)` and provider-specific subprobes. For supported remote providers, this diagnostic sends an authenticated request to the configured model and accepts only a recognized Chat Completions, streaming Chat Completions, or Anthropic Messages response. It uses a 3-second connection timeout, a 5-second total timeout, and an 8-token output limit. If the request reaches the time limit, NemoClaw reports the provider as `not probed` and leaves model health unverified instead of reporting it as unhealthy. These checks are diagnostic only and do not override the authoritative `inference.local` result or determine the command exit status. + +The `Inference (upstream)` check authenticates with the host credential that NemoClaw resolves for the provider, such as `NVIDIA_INFERENCE_API_KEY`. The gateway stores the provider credential that the sandbox route uses. The CLI cannot read the stored value back, so the two credentials can hold different secrets. When the `inference.local` route has already served the inference request, an `unauthorized` result on `Inference (upstream)` describes the host credential. NemoClaw then reports that check as `not probed` and names both credential sources. An `Inference (upstream)` check that fails for another reason, such as `unreachable`, still reports its own state. Local backend and auth proxy checks, such as `Inference (auth proxy)`, always report their own state and their own repair step. `$$nemoclaw doctor` sends no inference request, so it always reports the `Inference (upstream)` state that it measured. + +Local providers add host-side backend diagnostics. For Local Ollama, the command can also print an `Inference (auth proxy)` diagnostic when a proxy token is available. Use these diagnostics to identify a failing auxiliary hop after checking the main `Inference` line. For cloud-only providers, the output omits the NIM status line unless a NIM container is registered or an unexpected NIM container is running. When the sandbox's recorded driver is `docker` and the host Docker daemon is not reachable, the command prints the `docker_unreachable` failure layer with the message `Docker daemon is not reachable.` as the first line of stdout, suppresses the host-side `Inference` probe (which otherwise hits the remote provider directly and is misleading when the local stack is down), and exits with a non-zero status. -When the host Docker daemon is reachable but the per-sandbox container is stopped, the initial preflight records the `sandbox_container_stopped` failure layer and suppresses the host-side `Inference` probe. -If the owning OpenShell gateway is healthy but no longer lists the registered Docker-driver sandbox, status attempts post-reboot recovery from the labeled container. -It waits for Docker readiness, restores the in-sandbox gateway and host forwards, and refreshes preflight before probing inference. -A successful recovery clears the stale stopped-container failure. +When the host Docker daemon is reachable but the per-sandbox container is stopped, the initial preflight records the `sandbox_container_stopped` failure layer and suppresses the host-side `Inference` probe. If the owning OpenShell gateway is healthy but no longer lists the registered Docker-driver sandbox, status attempts post-reboot recovery from the labeled container. It waits for Docker readiness, restores the in-sandbox gateway and host forwards, and refreshes preflight before probing inference. A successful recovery clears the stale stopped-container failure. + If OpenShell already reports the registered Docker-driver sandbox as present and `Ready`, status verifies the OpenClaw gateway and host forward. It recovers either component when the verification reports it absent. @@ -2259,29 +1440,30 @@ Address the reported recovery layer, then run the displayed `$$nemoclaw -If the sandbox or gateway cannot be verified, the command exits non-zero instead of reporting healthy inference from stale registry state. -When a locally registered sandbox is missing from the live gateway, status preserves the registry entry so the suggested `rebuild --yes` recovery can still find the sandbox metadata. +If the sandbox or gateway cannot be verified, the command exits non-zero instead of reporting healthy inference from stale registry state. When a locally registered sandbox is missing from the live gateway, status preserves the registry entry for inspection and directs the operator to remove that stale entry with `$$nemoclaw destroy --yes` before clean onboarding. Rebuild cannot recreate a missing sandbox because no authoritative OpenShell policy remains. + -Gateway and dashboard health checks treat HTTP `401` from device auth as a live service, not as an offline gateway. + Gateway and dashboard health checks treat HTTP `401` from device auth as a live service, not as an + offline gateway. -When sandbox GPU passthrough is enabled, the `Sandbox GPU` line includes the last CUDA usability proof state. -It reports `(CUDA verified)`, `(CUDA unverified)`, or `(last CUDA proof failed: @@ -2332,8 +1513,7 @@ Expected output: ... ``` -If the sandbox is running an older Hermes version than this NemoClaw release expects, `status` and `connect` add an `Update` line pointing at `nemohermes rebuild` to pick up the newer version. -The rebuild reuses the existing sandbox name and persisted credentials, so messaging tokens and provider keys carry over. +If the sandbox is running an older Hermes version than this NemoClaw release expects, `status` and `connect` add an `Update` line pointing at `nemohermes rebuild` to pick up the newer version. The rebuild reuses the existing sandbox name and persisted credentials, so messaging tokens and provider keys carry over. @@ -2356,71 +1536,43 @@ Expected output: ... ``` -If the sandbox is running an older Deep Agents Code version than this NemoClaw release expects, `status` and `connect` add an `Update` line pointing at `nemo-deepagents rebuild` to pick up the newer version. -The rebuild reuses the existing sandbox name and preserved manifest-defined state, so skills, app state, and managed config carry over while credentials stay in host-side OpenShell state. +If the sandbox is running an older Deep Agents Code version than this NemoClaw release expects, `status` and `connect` add an `Update` line pointing at `nemo-deepagents rebuild` to pick up the newer version. The rebuild reuses the existing sandbox name and preserved manifest-defined state, so skills, app state, and managed config carry over while credentials stay in host-side OpenShell state. ### `$$nemoclaw doctor` -Run a focused health check for one sandbox and the host services it depends on. -The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, configured-provider model invocation, Ollama reachability, and the cloudflared tunnel state. +Run a focused health check for one sandbox and the host services it depends on. The command checks the local CLI build, Docker daemon, OpenShell CLI, NemoClaw gateway container, gateway port mapping, live sandbox state, inference route, configured-provider model invocation, Ollama reachability, and the cloudflared tunnel state. + -For gateway-based agents, it also reports messaging channel conflicts within the selected OpenShell gateway's sandbox registry. + For gateway-based agents, it also reports messaging channel conflicts within the selected + OpenShell gateway's sandbox registry. -`doctor` also checks whether the sandbox registry contains the metadata required for snapshot, rebuild, upgrade, recovery, and reboot. -When lifecycle metadata is incomplete, the report names the missing or invalid fields and affected operations without printing stored values. -Dashboard metadata is required only for agents that manage a dashboard. -If the registered gateway binding is invalid, `doctor` reports a failed gateway check and does not select, probe, or recover a gateway from that binding. +`doctor` also checks whether the sandbox registry contains the metadata required for snapshot, rebuild, upgrade, recovery, and reboot. When lifecycle metadata is incomplete, the report names the missing or invalid fields and affected operations without printing stored values. Dashboard metadata is required only for agents that manage a dashboard. If the registered gateway binding is invalid, `doctor` reports a failed gateway check and does not select, probe, or recover a gateway from that binding. -For Portable Hermes, `doctor` reports a `Portable lifecycle` check with the receipt phase. -The `pending` and `configuring` phases produce a warning, while `active` passes when the receipt and registry authority agree. -This read-only path does not run Docker or OpenClaw checks, and `doctor --fix` is not supported while the Portable Hermes receipt exists. +For Portable Hermes, `doctor` reports a `Portable lifecycle` check with the receipt phase. The `pending` and `configuring` phases produce a warning, while `active` passes when the receipt and registry authority agree. This read-only path does not run Docker or OpenClaw checks, and `doctor --fix` is not supported while the Portable Hermes receipt exists. -For inference health, `doctor` treats the probe to `https://inference.local/v1/models` from inside the sandbox as authoritative. -HTTP responses from `200` through `499`, including `401` and `403`, pass this check. -HTTP `500` through `599`, interim `100` through `199`, transport failures with status `000`, invalid status values, and an unavailable authoritative probe fail the check. -Direct provider and upstream probes use the same authenticated model-invocation checks as status and remain diagnostic only, so their failure does not fail `doctor` when the authoritative in-sandbox route is reachable. -For gateway runtimes, `doctor` also reports an informational `Serving process: not checked` result because its fresh sandbox probes do not attest the long-running gateway process. -This result does not fail the readiness check. -Terminal runtimes omit it because they have no long-running gateway process. -The `Inference` `Route` check warns when either the provider or model is unknown. -After the gateway is healthy, run `$$nemoclaw status` to refresh the route information. - -For each recorded baseline exclusion, `doctor` compares the approval with the active agent baseline and verifies that the excluded key is absent from the live OpenShell policy. -An unreadable live policy produces a warning because NemoClaw cannot verify enforcement. -A live policy that contains the excluded key fails the check and requires policy repair before you rely on the exclusion. +For inference health, `doctor` treats the probe to `https://inference.local/v1/models` from inside the sandbox as authoritative. HTTP responses from `200` through `499`, including `401` and `403`, pass this check. HTTP `500` through `599`, interim `100` through `199`, transport failures with status `000`, invalid status values, and an unavailable authoritative probe fail the check. Direct provider and upstream probes use the same authenticated model-invocation checks as status and remain diagnostic only, so their failure does not fail `doctor` when the authoritative in-sandbox route is reachable. For gateway runtimes, `doctor` also reports an informational `Serving process: not checked` result because its fresh sandbox probes do not attest the long-running gateway process. This result does not fail the readiness check. Terminal runtimes omit it because they have no long-running gateway process. The `Inference` `Route` check warns when either the provider or model is unknown. After the gateway is healthy, run `$$nemoclaw status` to refresh the route information. -Warnings do not make the command fail. -Failed checks, including a failed or unavailable authoritative inference route, exit non-zero so scripts can use `doctor` as a readiness gate. -Use `--json` for machine-readable output. -For a `compatible-endpoint` route that uses `openai-completions`, the JSON report includes an informational `Inference` check labeled `Reasoning effort`. -The check reports `low`, `medium`, `high`, or `endpoint-default` and never includes credentials. -Because the check has `info` status, it does not change the command's exit status. +Warnings do not make the command fail. Failed checks, including a failed or unavailable authoritative inference route, exit non-zero so scripts can use `doctor` as a readiness gate. Use `--json` for machine-readable output. For a `compatible-endpoint` route that uses `openai-completions`, the JSON report includes an informational `Inference` check labeled `Reasoning effort`. The check reports `low`, `medium`, `high`, or `endpoint-default` and never includes credentials. Because the check has `info` status, it does not change the command's exit status. -For a sandbox that owns managed llama.cpp, `doctor` adds secret-free identity and runtime checks. -The runtime check passes only when the container is running. -It warns for `preparing` or `stopped`, and it fails for `absent`, `conflict`, or `unknown`. -The recovery hint tells you to rerun onboarding for the same sandbox so NemoClaw can use the persisted receipt and create journal. +For a sandbox that owns managed llama.cpp, `doctor` adds secret-free identity and runtime checks. The runtime check passes only when the exact container is running. It warns for `preparing` or `stopped`, and it fails for `absent`, `conflict`, or `unknown`. The recovery hint tells you to rerun onboarding for the same sandbox so NemoClaw can use the persisted receipt and create journal. -For OpenClaw sandboxes, `doctor` also checks the mutable config permission contract. -If `openclaw doctor --fix` was run inside the sandbox, it can tighten `/sandbox/.openclaw` and `openclaw.json` to a single-user `700/600` layout, which stops the gateway from persisting config changes. -`doctor` reports this as a `Config permissions` warning; pass `--fix` to restore the group-writable `2770/660` contract without rebuilding. -Restarting the sandbox repairs the same drift automatically. +For OpenClaw sandboxes, `doctor` also checks the mutable config permission contract. If `openclaw doctor --fix` was run inside the sandbox, it can tighten `/sandbox/.openclaw` and `openclaw.json` to a single-user `700/600` layout, which stops the gateway from persisting config changes. `doctor` reports this as a `Config permissions` warning; pass `--fix` to restore the group-writable `2770/660` contract without rebuilding. Restarting the sandbox repairs the same drift automatically. ```bash $$nemoclaw my-assistant doctor [--json | --fix] ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--json` | Emit the report as JSON | | `--fix` | Restore the mutable OpenClaw config permission contract if it was tightened. Mutually exclusive with `--json` | @@ -2431,28 +1583,21 @@ $$nemoclaw my-assistant doctor [--json | --fix] $$nemoclaw my-assistant doctor [--json] ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| -------- | ----------------------- | | `--json` | Emit the report as JSON | ### `$$nemoclaw exec` -Run a command non-interactively inside a running sandbox through the OpenShell exec endpoint. -The command runs as the sandbox user with `HOME=/sandbox`. -Use `--` to separate `exec` options from the command you want to run inside the sandbox. +Run a command non-interactively inside a running sandbox through the OpenShell exec endpoint. The command runs as the sandbox user with `HOME=/sandbox`. Use `--` to separate `exec` options from the command you want to run inside the sandbox. -After the remote command exits, NemoClaw verifies and, when needed, restores the mutable OpenClaw config permission contract. -When cleanup succeeds, `exec` preserves the remote command's exit code. -When cleanup fails closed, `exec` returns the cleanup failure and reports both statuses on `stderr`. +After the remote command exits, NemoClaw verifies and, when needed, restores the mutable OpenClaw config permission contract. When cleanup succeeds, `exec` preserves the remote command's exit code. When cleanup fails closed, `exec` returns the cleanup failure and reports both statuses on `stderr`. -A successful direct `openclaw pairing approve googlechat ` command in a registered OpenClaw sandbox with a selected owning managed gateway restarts that gateway after cleanup, so the new sender allowlist applies to the next message. -If the approval commits but cleanup or restart fails, `exec` exits with status `1` and reports that the approval was not rolled back. -When an owning gateway was selected, it directs you to run `$$nemoclaw gateway restart` after correcting any cleanup problem. -Without an owning managed gateway, NemoClaw does not attempt activation or print a managed restart command; unregistered and non-OpenClaw sandboxes do not receive the automatic restart. +A successful direct `openclaw pairing approve googlechat ` command in a registered OpenClaw sandbox with a selected owning managed gateway restarts that gateway after cleanup, so the new sender allowlist applies to the next message. If the approval commits but cleanup or restart fails, `exec` exits with status `1` and reports that the approval was not rolled back. When an owning gateway was selected, it directs you to run `$$nemoclaw gateway restart` after correcting any cleanup problem. Without an owning managed gateway, NemoClaw does not attempt activation or print a managed restart command; unregistered and non-OpenClaw sandboxes do not receive the automatic restart. @@ -2465,23 +1610,19 @@ The command exits with the remote command's exit code. $$nemoclaw my-assistant exec [--workdir ] [--tty|--no-tty] [--timeout ] [--stdin|--no-stdin] -- [args...] ``` -By default, NemoClaw inherits caller stdin only when it is a terminal. -Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. -Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. +By default, NemoClaw inherits caller stdin only when it is a terminal. Non-terminal or unavailable stdin is closed so SSH, CI, and other one-shot commands cannot wait on an inherited pipe. Pass `--stdin` to forward an intentional pipe, or `--no-stdin` to close terminal stdin explicitly. -OpenShell preserves line endings and quote characters inside each command argument, so inline scripts and heredocs can be passed as one argument after `--`. -For example, a shell variable keeps the multi-line script in one argv element: +OpenShell preserves line endings and quote characters inside each command argument, so inline scripts and heredocs can be passed as one argument after `--`. For example, a shell variable keeps the multi-line script in one argv element: ```bash script=$'cat <<\'EOF\'\nline one\nline two\nEOF' $$nemoclaw exec -- bash -lc "$script" ``` -NUL bytes are still rejected in command arguments. -Line breaks are accepted only in command argv: `--workdir` remains single-line, and NemoClaw does not expose OpenShell request-environment injection on this command. +NUL bytes are still rejected in command arguments. Line breaks are accepted only in command argv: `--workdir` remains single-line, and NemoClaw does not expose OpenShell request-environment injection on this command. | Flag | Description | -|------|-------------| +| --- | --- | | `--workdir ` | Set the working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: does not exist inside the sandbox` and exits with status `1` without invoking the inner command. | | `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection | | `--timeout ` | Timeout in seconds. Use `0` for no timeout | @@ -2489,13 +1630,7 @@ Line breaks are accepted only in command argv: `--workdir` remains single-line, ### `$$nemoclaw logs` -View sandbox logs. -Use `--follow` to stream output in real time. -Use `--tail ` or `-n ` to limit the number of returned lines. -Use `--since ` to show recent logs only, such as `5m`, `1h`, or `30s`. -The command reads both agent gateway output and OpenShell audit events, so policy denials appear alongside the gateway log stream. -If one log source is unavailable, NemoClaw prints a warning and keeps reading the remaining source. -NemoClaw's `--tail ` flag is a line-count flag; the lower-level `openshell logs --tail` flag means follow live output, so use `openshell logs -n ` when running OpenShell directly for a fixed line count. +View sandbox logs. Use `--follow` to stream output in real time. Use `--tail ` or `-n ` to limit the number of returned lines. Use `--since ` to show recent logs only, such as `5m`, `1h`, or `30s`. The command reads both agent gateway output and OpenShell audit events, so policy denials appear alongside the gateway log stream. If one log source is unavailable, NemoClaw prints a warning and keeps reading the remaining source. NemoClaw's `--tail ` flag is a line-count flag; the lower-level `openshell logs --tail` flag means follow live output, so use `openshell logs -n ` when running OpenShell directly for a fixed line count. ```bash $$nemoclaw my-assistant logs [--follow] [--tail |-n ] [--since ] @@ -2505,50 +1640,40 @@ $$nemoclaw my-assistant logs [--follow] [--tail |-n ] [--since -Print the browser dashboard URL for a running sandbox. -For OpenClaw sandboxes this includes the authenticated URL fragment. -For agent dashboards that manage their own session, such as Hermes Agent, this prints the plain dashboard URL. -Use this when you are on a remote machine, using an SSH or reverse tunnel, or need a complete URL for a browser session. +Print the browser dashboard URL for a running sandbox. For OpenClaw sandboxes this includes the authenticated URL fragment. For agent dashboards that manage their own session, such as Hermes Agent, this prints the plain dashboard URL. Use this when you are on a remote machine, using an SSH or reverse tunnel, or need a complete URL for a browser session. ```bash $$nemoclaw my-assistant dashboard-url $$nemoclaw my-assistant dashboard-url --quiet ``` -The default output includes a label and a warning. -Pass `--quiet` or `-q` to print only the URL to stdout so scripts can capture it: +The default output includes a label and a warning. Pass `--quiet` or `-q` to print only the URL to stdout so scripts can capture it: ```bash URL=$($$nemoclaw my-assistant dashboard-url --quiet) ``` -Treat the authenticated dashboard URL like a password. -Do not log it, share it, or commit it to version control. -This warning applies when the command prints an OpenClaw tokenized URL. + Treat the authenticated dashboard URL like a password. Do not log it, share it, or commit it to + version control. This warning applies when the command prints an OpenClaw tokenized URL. -Print the browser dashboard URL for a running Hermes sandbox. -Hermes manages dashboard sessions itself, so this command prints a plain URL without an OpenClaw `#token=` fragment. -The built-in dashboard is forwarded on port `18789` by default. +Print the browser dashboard URL for a running Hermes sandbox. Hermes manages dashboard sessions itself, so this command prints a plain URL without an OpenClaw `#token=` fragment. The built-in dashboard is forwarded on port `18789` by default. ```bash nemohermes my-assistant dashboard-url nemohermes my-assistant dashboard-url --quiet ``` -The Hermes OpenAI-compatible API is separate and serves `/v1` on a per-sandbox port that defaults to `8642`. -Run `openshell forward list` to read the host bind for the dashboard and API forwards. +The Hermes OpenAI-compatible API is separate and serves `/v1` on a per-sandbox port that defaults to `8642`. Run `openshell forward list` to read the host bind for the dashboard and API forwards. -`dashboard-url` is not applicable to Deep Agents sandboxes because the managed harness is a terminal runtime without a dashboard port. -Use `$$nemoclaw launch ` to start `dcode`. -Use `$$nemoclaw connect` instead when you want a sandbox shell. +`dashboard-url` is not applicable to Deep Agents sandboxes because the managed harness is a terminal runtime without a dashboard port. Use `$$nemoclaw launch ` to start `dcode`. Use `$$nemoclaw connect` instead when you want a sandbox shell. @@ -2556,22 +1681,16 @@ Use `$$nemoclaw connect` instead when you want a sandbox shell. -Print the OpenClaw gateway auth token for a running sandbox to stdout. -The token is required by `openclaw tui` and the OpenClaw dashboard URL. -Use `dashboard-url` for browser access; use `gateway-token` only when automation needs the raw token. -Pipe it into automation or capture it into an environment variable: +Print the OpenClaw gateway auth token for a running sandbox to stdout. The token is required by `openclaw tui` and the OpenClaw dashboard URL. Use `dashboard-url` for browser access; use `gateway-token` only when automation needs the raw token. Pipe it into automation or capture it into an environment variable: ```bash TOKEN=$($$nemoclaw my-assistant gateway-token --quiet) export OPENCLAW_GATEWAY_TOKEN="$TOKEN" ``` -The token is written to stdout with no surrounding text. -A one-line security warning is written to stderr; pass `--quiet` (or `-q`) to suppress it. -The command exits non-zero with a diagnostic on stderr when the sandbox is not registered or when the token cannot be retrieved (for example, if the sandbox is not running). +The token is written to stdout with no surrounding text. A one-line security warning is written to stderr; pass `--quiet` (or `-q`) to suppress it. The command exits non-zero with a diagnostic on stderr when the sandbox is not registered or when the token cannot be retrieved (for example, if the sandbox is not running). -The token also authenticates the Control UI config endpoint served by the gateway on the forwarded dashboard port. -There is no `controlui.bootstrap.config.json` path; the supported endpoint is `/__openclaw/control-ui-config.json`, and it requires the token (unauthenticated requests return `401` with a JSON body): +The token also authenticates the Control UI config endpoint served by the gateway on the forwarded dashboard port. There is no `controlui.bootstrap.config.json` path; the supported endpoint is `/__openclaw/control-ui-config.json`, and it requires the token (unauthenticated requests return `401` with a JSON body): ```bash TOKEN=$($$nemoclaw my-assistant gateway-token --quiet) @@ -2580,22 +1699,13 @@ curl -fsS -H "Authorization: Bearer $TOKEN" \ ``` -Treat the gateway token like a password. -Do not log it, share it, or commit it to version control. + Treat the gateway token like a password. Do not log it, share it, or commit it to version control. -Print the Hermes API bearer token for a running sandbox to stdout. -NemoClaw retrieves the sandbox's `API_SERVER_KEY`, which authenticates OpenAI-compatible clients on the forwarded API port. -During a normal sandbox lifecycle, the token is generated once for each sandbox home. -Different sandbox homes receive different tokens. -NemoClaw preserves it across a gateway restart, sandbox stop and start, and host OpenShell gateway restart. -When you rebuild or replace the sandbox, the replacement home receives a new token. -At gateway startup, NemoClaw also generates a new token when `API_SERVER_KEY` is missing or is not exactly 64 lowercase hexadecimal characters. -If an ordinary restart changes the token while the existing `API_SERVER_KEY` was present and valid, collect the before and after sandbox identity plus redacted mint logs and report it as a bug. -Capture the token and pass it in the `Authorization` header: +Print the Hermes API bearer token for a running sandbox to stdout. NemoClaw retrieves the sandbox's `API_SERVER_KEY`, which authenticates OpenAI-compatible clients on the forwarded API port. During a normal sandbox lifecycle, the token is generated once for each sandbox home. Different sandbox homes receive different tokens. NemoClaw preserves it across a gateway restart, sandbox stop and start, and host OpenShell gateway restart. When you rebuild or replace the sandbox, the replacement home receives a new token. At gateway startup, NemoClaw also generates a new token when `API_SERVER_KEY` is missing or is not exactly 64 lowercase hexadecimal characters. If an ordinary restart changes the token while the existing `API_SERVER_KEY` was present and valid, collect the before and after sandbox identity plus redacted mint logs and report it as a bug. Capture the token and pass it in the `Authorization` header: ```bash TOKEN=$(nemohermes my-assistant gateway-token --quiet) @@ -2604,31 +1714,21 @@ curl -fsS -H "Authorization: Bearer $TOKEN" \ ``` -Treat the token like a password. -Do not log it, share it, or commit it to version control. + Treat the token like a password. Do not log it, share it, or commit it to version control. -The sandbox must be running for `nemohermes my-assistant gateway-token --quiet` to retrieve the token. -Use this supported command instead of reading or editing `.hermes/.env` directly. -For browser access to the dashboard, use `nemohermes my-assistant dashboard-url`. +The sandbox must be running for `nemohermes my-assistant gateway-token --quiet` to retrieve the token. Use this supported command instead of reading or editing `.hermes/.env` directly. For browser access to the dashboard, use `nemohermes my-assistant dashboard-url`. -`gateway-token` is not applicable to Deep Agents sandboxes because there is no OpenClaw gateway token. -Model traffic uses the OpenShell-managed `inference.local` route configured by NemoClaw. +`gateway-token` is not applicable to Deep Agents sandboxes because there is no OpenClaw gateway token. Model traffic uses the OpenShell-managed `inference.local` route configured by NemoClaw. ### `$$nemoclaw destroy` -Stop managed local inference resources, remove the host-side Docker image built during onboard, and delete the sandbox. -This removes the sandbox from the registry. -For Ollama-backed sandboxes, `destroy` also asks Ollama to unload currently loaded models and clears stale auth proxy state on a best-effort basis. -For Model Router sandboxes, `destroy` keeps the process and recovery identity when another sandbox uses the port or when session, process, or absence checks are inconclusive. -It also preserves a replacement onboarding session when the captured session identity changed. -If the captured session uses the destroyed sandbox name with another router port, `destroy` clears only the sandbox association and preserves that router's recovery identity. -For lock order, same-port peer handling, and cleanup checks, refer to [Set Up Model Router](../inference/hosted-inference/set-up-model-router#router-and-sandbox-lifecycle-locks). +Stop managed local inference resources, remove the host-side Docker image built during onboard, and delete the sandbox. This removes the sandbox from the registry. For Ollama-backed sandboxes, `destroy` also asks Ollama to unload currently loaded models and clears stale auth proxy state on a best-effort basis. For Model Router sandboxes, `destroy` keeps the process and recovery identity when another sandbox uses the port or when session, process, or absence checks are inconclusive. It also preserves a replacement onboarding session when the captured session identity changed. If the captured session uses the destroyed sandbox name with another router port, `destroy` clears only the sandbox association and preserves that router's recovery identity. For lock order, same-port peer handling, and cleanup checks, refer to [Set Up Model Router](../inference/hosted-inference/set-up-model-router#router-and-sandbox-lifecycle-locks). If `destroy` warns that it could not identify or stop a listener for the deleted sandbox: @@ -2646,120 +1746,50 @@ Back up your workspace first with `$$nemoclaw snapshot create` or refer t If you want to upgrade the sandbox while preserving state, use `$$nemoclaw rebuild` instead. -If another terminal has an active SSH session to the sandbox, `destroy` prints an active-session warning and requires a second confirmation before it proceeds. -Pass `--yes`, `-y`, or `--force`, or set `NEMOCLAW_NON_INTERACTIVE=1`, to authorize deletion without prompting in scripted workflows. -These controls do not suppress the active-session warning. -The warning lists the detected process IDs, and destroy still terminates those sessions with a `Broken pipe` error. +If another terminal has an active SSH session to the sandbox, `destroy` prints an active-session warning and requires a second confirmation before it proceeds. Pass `--yes`, `-y`, or `--force`, or set `NEMOCLAW_NON_INTERACTIVE=1`, to authorize deletion without prompting in scripted workflows. These controls do not suppress the active-session warning. The warning lists the detected process IDs, and destroy still terminates those sessions with a `Broken pipe` error. -Before changing a Docker-backed sandbox, NemoClaw inspects every container with the requested `openshell.ai/sandbox-name` label. -The command continues when Docker returns no matching containers. -For one matching container, the command continues only when all these labels have the required values: +Before changing a Docker-backed sandbox, NemoClaw inspects every container with the requested `openshell.ai/sandbox-name` label. The command continues when Docker returns no matching containers. For one matching container, the command continues only when all these labels have the required values: - `openshell.ai/managed-by=openshell` - A nonempty `openshell.ai/sandbox-workspace` - A nonempty `openshell.ai/sandbox-id` -If the initial inspection cannot complete, more than one container matches, a matching container has conflicting or incomplete labels, or Docker returns malformed identity data, `destroy` exits before changing sandbox resources. -The identity checks still apply with `--force`, `--yes`, or `NEMOCLAW_NON_INTERACTIVE=1`; those controls authorize confirmation but do not authorize an unproven container identity. -NemoClaw rechecks the identity after read-only preflight, before provider cleanup, and synchronously at the sandbox-deletion boundary. -If a later recheck detects drift or fails, `destroy` refuses sandbox deletion, restores managed MCP preparation when possible, preserves local ownership state, and reports any earlier cleanup already performed. -If OpenShell reports the sandbox absent after preflight captured one matching Docker container, `destroy` rechecks and removes only that container ID. -If Docker reports another OpenShell-managed container, removal fails, or NemoClaw cannot confirm removal, `destroy` exits nonzero and preserves the registry entry. -Correct the reported Docker state, then rerun `destroy`. -If Docker cannot complete the inspection, correct the reported Docker error before you rerun `destroy`. -For common recovery steps, refer to [Docker is not running](troubleshooting#docker-is-not-running) and [Docker permission denied on Linux](troubleshooting#docker-permission-denied-on-linux). +If the initial inspection cannot complete, more than one container matches, a matching container has conflicting or incomplete labels, or Docker returns malformed identity data, `destroy` exits before changing sandbox resources. The identity checks still apply with `--force`, `--yes`, or `NEMOCLAW_NON_INTERACTIVE=1`; those controls authorize confirmation but do not authorize an unproven container identity. NemoClaw rechecks the exact identity after read-only preflight, before provider cleanup, and synchronously at the sandbox-deletion boundary. If a later recheck detects drift or fails, `destroy` refuses sandbox deletion, restores managed MCP preparation when possible, preserves local ownership state, and reports any earlier cleanup already performed. If OpenShell reports the sandbox absent after preflight captured one matching Docker container, `destroy` rechecks and removes only that exact container ID. If Docker reports another OpenShell-managed container, removal fails, or NemoClaw cannot confirm removal, `destroy` exits nonzero and preserves the registry entry. Correct the reported Docker state, then rerun `destroy`. If Docker cannot complete the inspection, correct the reported Docker error before you rerun `destroy`. For common recovery steps, refer to [Docker is not running](troubleshooting#docker-is-not-running) and [Docker permission denied on Linux](troubleshooting#docker-permission-denied-on-linux). If `destroy` reports conflicting, incomplete, or malformed identity data, inspect the matching containers: -~~~bash +```bash docker ps -a --no-trunc \ --filter "label=openshell.ai/sandbox-name=my-assistant" \ --format 'table {{.ID}}\t{{.Label "openshell.ai/managed-by"}}\t{{.Label "openshell.ai/sandbox-workspace"}}\t{{.Label "openshell.ai/sandbox-id"}}' -~~~ +``` -The labels show what each container claims. -They do not prove container ownership. +The labels show what each container claims. They do not prove container ownership. -Do not remove or recreate a container until you verify its purpose, ownership, and data-retention requirements. -Removing or recreating a container can discard state that is not stored in a volume. + Do not remove or recreate a container until you verify its purpose, ownership, and data-retention + requirements. Removing or recreating a container can discard state that is not stored in a volume. -Resolve a conflict through the workflow that created the conflicting container. -Docker cannot change labels on an existing container. -Rerun the query after you resolve the conflict. -Rerun `destroy` only when the query returns one complete expected label set that you verified belongs to the target sandbox, or no containers after you independently confirm that the sandbox is absent. +Resolve a conflict through the workflow that created the conflicting container. Docker cannot change labels on an existing container. Rerun the query after you resolve the conflict. Rerun `destroy` only when the query returns one complete expected label set that you verified belongs to the target sandbox, or no containers after you independently confirm that the sandbox is absent. -If the Hermes sandbox has managed MCP entries, shields must be down before destroy can scrub their adapter configuration. -Use `$$nemoclaw shields down --timeout 15m --reason "MCP maintenance"`, allowing at least 15 minutes per configured server. -If sandbox deletion is refused after destroy has restored lockdown, NemoClaw opens an owner-bound timed rollback window, restores the preserved MCP state, and re-locks shields; the timer retains auto-restore authority if the host process exits. +If the Hermes sandbox has managed MCP entries, shields must be down before destroy can scrub their adapter configuration. Use `$$nemoclaw shields down --timeout 15m --reason "MCP maintenance"`, allowing at least 15 minutes per configured server. If sandbox deletion is refused after destroy has restored lockdown, NemoClaw opens an owner-bound timed rollback window, restores the preserved MCP state, and re-locks shields; the timer retains auto-restore authority if the host process exits. -If a shields auto-restore timer is active, `destroy` holds the same per-sandbox transition through state wipe and deletion. -It restores and verifies lockdown and revokes the active timer before deletion. -It clears the remaining local shields state only after deletion succeeds. -If the pre-delete re-lock fails, the command warns and attempts to destroy the sandbox. -If the destroy operation succeeds, it destroys the sandbox and deletes its unguarded configuration. -If the destroy operation fails, NemoClaw keeps the local shields state and the auto-restore timer. -Detached auto-restore uses one 7-attempt recovery budget to restore lockdown. -Waiting for a verified live sandbox mutation owner does not consume an attempt. -The deadline gate remains closed during that wait. -If the recovery budget is exhausted, durable containment blocks new sandbox mutations. -Run `$$nemoclaw shields status` and follow its generation recovery guidance. -If deletion fails after hardening, the command keeps the surviving sandbox's locked shields state instead of cleaning it up as though deletion succeeded. -By default, unattended final-sandbox destroys (`--yes`, `--force`, or `NEMOCLAW_NON_INTERACTIVE=1`) remove the shared NemoClaw gateway on macOS so the host listener is released, while Linux preserves it for reuse. -Pass `--cleanup-gateway` to force removal, or `--no-cleanup-gateway` to force preservation. -These flags always override both `NEMOCLAW_CLEANUP_GATEWAY` and the platform default. -If the pre-delete workspace wipe completes with a nonzero status, `destroy` continues, but the retained volume may still contain old files. -Use a different sandbox name to avoid reusing that retained volume. -If workspace cleanup reaches its 60-second timeout, NemoClaw cannot confirm the remote result. -NemoClaw stops before provider cleanup and sandbox deletion, attempts to restore any prepared MCP state, and preserves the local registry entry. -Run `$$nemoclaw status` to check or start the recorded OpenShell gateway. -If a shields auto-restore timer remains active, run `$$nemoclaw shields status` to verify bounded recovery or follow its durable-containment guidance, then retry `destroy` only after shields recovery permits it. -If no timer remains active, retry after the recorded gateway is available. -Cleaning up the gateway after the last sandbox also purges the shared cluster volume that retains the per-name persistent volume. -If NemoClaw detects active SSH sessions before destroy, it warns that destroy terminates them with a Broken pipe error and lists their process IDs. -This warning prints before the confirmation prompt and when `--yes` or `--force` skips that prompt. -If final gateway cleanup finds a live PID-file process whose command line does not prove it owns the target gateway, `destroy` exits non-zero after sandbox and registry deletion and skips gateway and volume removal. -NemoClaw preserves the per-gateway PID file and runtime marker so you can inspect the process. -Stop only the listener that matches the target gateway, then rerun `destroy` to converge cleanup. -When the default-port gateway runs under the packaged OpenShell gateway service, gateway cleanup stops that service before it reaps host processes, so the gateway port is released instead of being rebound by the service manager. -The service is stopped, not disabled or removed, and the next onboarding run starts it again. -On headless Linux, the packaged service can exist while its `systemd` user manager is unavailable and the gateway runs through the standalone fallback. -For this recognized manager-unavailable failure only, `destroy` uses the per-gateway PID file when the service is not enabled for automatic activation. -If the recorded PID is live, its command line must match the gateway name and port before `destroy` stops it. -If the recorded process has exited, `destroy` continues only after it verifies that the gateway port is free. -If a live PID does not prove gateway ownership or the port remains occupied, `destroy` exits non-zero and preserves the runtime evidence for inspection. -For any other service stop failure, `destroy` exits non-zero after sandbox and registry deletion, prints the status command for the service, and skips gateway and volume removal. -If the OpenShell command completes with a gateway transport error and the sandbox has no managed MCP ownership state, `--force` removes only NemoClaw's local registry entry and local artifacts. -Gateway-side deletion remains unconfirmed, shared host-service and gateway teardown are skipped, and the sandbox and retained volume may still exist if the gateway returns. -Start the gateway with `$$nemoclaw status` and retry destroy when you need a confirmed deletion. -If the OpenShell sandbox deletion command reaches its 60-second timeout, NemoClaw cannot confirm whether OpenShell deleted the sandbox. -NemoClaw preserves the local registry entry under both `--yes` and `--force`. -Run `$$nemoclaw status` to check or start the recorded OpenShell gateway. -If the preceding output also reports a failed pre-delete re-lock, run `$$nemoclaw shields status` to verify recovery or follow its durable-containment guidance, then retry `destroy` only after shields recovery permits it. -Otherwise, retry after the recorded gateway is available. -Managed MCP ownership disables the local-only fallback because provider cleanup requires the retained ownership state, and other delete failures remain fatal. -A failed pre-delete re-lock also disables the local-only fallback, because the auto-restore timer is then the only authority that can lock the configuration again after the gateway returns. +If a shields auto-restore timer is active, `destroy` holds the same per-sandbox transition through state wipe and deletion. It restores and verifies lockdown and revokes the active timer before deletion. It clears the remaining local shields state only after deletion succeeds. If the pre-delete re-lock fails, the command warns and attempts to destroy the sandbox. If the destroy operation succeeds, it destroys the sandbox and deletes its unguarded configuration. If the destroy operation fails, NemoClaw keeps the local shields state and the auto-restore timer. Detached auto-restore uses one 7-attempt recovery budget to restore lockdown. Waiting for a verified live sandbox mutation owner does not consume an attempt. The deadline gate remains closed during that wait. If the recovery budget is exhausted, durable containment blocks new sandbox mutations. Run `$$nemoclaw shields status` and follow its exact-generation recovery guidance. If deletion fails after hardening, the command keeps the surviving sandbox's locked shields state instead of cleaning it up as though deletion succeeded. By default, unattended final-sandbox destroys (`--yes`, `--force`, or `NEMOCLAW_NON_INTERACTIVE=1`) remove the shared NemoClaw gateway on macOS so the host listener is released, while Linux preserves it for reuse. Pass `--cleanup-gateway` to force removal, or `--no-cleanup-gateway` to force preservation. These flags always override both `NEMOCLAW_CLEANUP_GATEWAY` and the platform default. If the pre-delete workspace wipe completes with a nonzero status, `destroy` continues, but the retained volume may still contain old files. Use a different sandbox name to avoid reusing that retained volume. If workspace cleanup reaches its 60-second timeout, NemoClaw cannot confirm the remote result. NemoClaw stops before provider cleanup and sandbox deletion, attempts to restore any prepared MCP state, and preserves the local registry entry. Run `$$nemoclaw status` to check or start the recorded OpenShell gateway. If a shields auto-restore timer remains active, run `$$nemoclaw shields status` to verify bounded recovery or follow its durable-containment guidance, then retry `destroy` only after shields recovery permits it. If no timer remains active, retry after the recorded gateway is available. Cleaning up the gateway after the last sandbox also purges the shared cluster volume that retains the per-name persistent volume. If NemoClaw detects active SSH sessions before destroy, it warns that destroy terminates them with a Broken pipe error and lists their process IDs. This warning prints before the confirmation prompt and when `--yes` or `--force` skips that prompt. If final gateway cleanup finds a live PID-file process whose command line does not prove it owns the target gateway, `destroy` exits non-zero after sandbox and registry deletion and skips gateway and volume removal. NemoClaw preserves the per-gateway PID file and runtime marker so you can inspect the process. Stop only the listener that matches the target gateway, then rerun `destroy` to converge cleanup. When the default-port gateway runs under the packaged OpenShell gateway service, gateway cleanup stops that service before it reaps host processes, so the gateway port is released instead of being rebound by the service manager. The service is stopped, not disabled or removed, and the next onboarding run starts it again. On headless Linux, the packaged service can exist while its `systemd` user manager is unavailable and the gateway runs through the standalone fallback. For this recognized manager-unavailable failure only, `destroy` uses the per-gateway PID file when the service is not enabled for automatic activation. If the recorded PID is live, its command line must match the exact gateway name and port before `destroy` stops it. If the recorded process has exited, `destroy` continues only after it verifies that the gateway port is free. If a live PID does not prove gateway ownership or the port remains occupied, `destroy` exits non-zero and preserves the runtime evidence for inspection. For any other service stop failure, `destroy` exits non-zero after sandbox and registry deletion, prints the status command for the service, and skips gateway and volume removal. If the OpenShell command completes with a gateway transport error and the sandbox has no managed MCP ownership state, `--force` removes only NemoClaw's local registry entry and local artifacts. Gateway-side deletion remains unconfirmed, shared host-service and gateway teardown are skipped, and the sandbox and retained volume may still exist if the gateway returns. Start the gateway with `$$nemoclaw status` and retry destroy when you need a confirmed deletion. If the OpenShell sandbox deletion command reaches its 60-second timeout, NemoClaw cannot confirm whether OpenShell deleted the sandbox. NemoClaw preserves the local registry entry under both `--yes` and `--force`. Run `$$nemoclaw status` to check or start the recorded OpenShell gateway. If the preceding output also reports a failed pre-delete re-lock, run `$$nemoclaw shields status` to verify recovery or follow its durable-containment guidance, then retry `destroy` only after shields recovery permits it. Otherwise, retry after the recorded gateway is available. Managed MCP ownership disables the local-only fallback because exact provider cleanup requires the retained ownership state, and other delete failures remain fatal. A failed pre-delete re-lock also disables the local-only fallback, because the auto-restore timer is then the only authority that can lock the configuration again after the gateway returns. ```bash $$nemoclaw my-assistant destroy [--yes|-y|--force] [--cleanup-gateway|--no-cleanup-gateway] ``` -After OpenShell confirms deletion of a sandbox that owns managed llama.cpp, `destroy` revalidates the container, internal network, lifecycle journal, and gateway-scoped receipt. -It removes those resources by inspected ID, then removes the API key and managed ownership state. -It preserves the shared `~/.cache/huggingface/` cache. -If cleanup fails, `destroy` preserves the sandbox registry entry and ownership state so you can correct the reported conflict and retry. +After OpenShell confirms deletion of a sandbox that owns managed llama.cpp, `destroy` revalidates the exact container, internal network, lifecycle journal, and gateway-scoped receipt. It removes those resources by inspected ID, then removes the API key and managed ownership state. It preserves the shared `~/.cache/huggingface/` cache. If exact cleanup fails, `destroy` preserves the sandbox registry entry and ownership state so you can correct the reported conflict and retry. ### `$$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. +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 @@ -2774,16 +1804,12 @@ $$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. -Presets extend the baseline network policy with additional endpoints. -Before applying, the command shows which endpoints the preset would open and prompts for confirmation. -The scope comes from the preset YAML and includes each endpoint's host, port, access, protocol, TLS, and enforcement settings, allowed methods and paths, and binary allowlist. -When a lifecycle operation reapplies a preset, NemoClaw compares it with the live policy and reports whether the preset opens new egress, replaces a drifted entry, or is already effective with no new egress. +Add a policy preset to a sandbox. Presets extend the baseline network policy with additional endpoints. Before applying, the command shows which endpoints the preset would open and prompts for confirmation. The scope comes from the exact preset YAML and includes each endpoint's host, port, access, protocol, TLS, and enforcement settings, allowed methods and paths, and binary allowlist. When a lifecycle operation reapplies a preset, NemoClaw compares it with the live policy and reports whether the preset opens new egress, replaces a drifted entry, or is already effective with no new egress. ```bash $$nemoclaw my-assistant policy add @@ -2795,45 +1821,14 @@ To apply a specific preset without the interactive picker, pass its name as a po $$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. -Without a preset name, a run with `NEMOCLAW_NON_INTERACTIVE=1` reports that non-interactive mode requires a preset name. -A run without a terminal on stdin instead reports that no input is available on stdin. -Both exit non-zero rather than open the picker. -If the preset name is unknown, the command exits non-zero with a clear error. -If the interactive picker receives a nonnumeric or out-of-range selection, the command prints the validation error, exits non-zero, and applies no preset. -If a named preset is already applied, the command compares the preset content with the live policy. -When the content matches, the command reports no changes and exits zero. -When the content differs, the command shows the normal preview and asks for confirmation before applying the preset again. -This includes changes to the preset file. -The comparison requires both the preset content and the live policy. -If either cannot be read, the command exits non-zero. -The command also exits non-zero when the name belongs to a custom preset applied with `--from-file`. -Use `--from-file` to apply that custom preset again. -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. -When a baseline key is durably excluded, NemoClaw reserves that key and refuses built-in, custom, channel, and MCP policy additions that would define it again. -Restore the baseline entry before applying a preset that intentionally owns the same key, or rename a custom preset entry whose key represents different access. -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 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. -For built-in presets in that same case, the command applies the preset and returns success, because a built-in preset stays discoverable from the gateway. -It warns that `policy list` will report the preset as active on gateway, missing from local state. - -With `--from-file` or `--from-dir`, pass a repeatable `--trusted-private-host ` option to admit matching RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local endpoints. -The option is invalid for built-in presets. -You can supply exact hosts through `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` instead, and NemoClaw combines the variable with command options. -NemoClaw resolves each exact matching host and adds generated `allowed_ips` pins to an in-memory copy of the preset. -User-authored `allowed_ips` remains rejected. -Dry-run output shows the generated pins, and rebuild replays the transformed preset recorded in the sandbox registry without widening it from ambient DNS. -A snapshot alone does not grant private-host authority to a clean target; after a cross-sandbox restore, reapply the source preset with explicit trust. +The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` for the same non-interactive behavior. If the preset is already present with identical content, the command reports no changes. If its content differs, NemoClaw previews the change and asks for confirmation before applying it again. + +Every mutation starts from the round-trippable base document returned by OpenShell, merges the requested built-in or custom content, submits the complete document, and verifies the live result. NemoClaw stores no applied-preset list or custom-policy copy in its registry. Custom preset names are encoded in namespaced keys in the live policy so later `policy list` and `policy remove` commands can derive them from OpenShell. If the live policy cannot be read or parsed, the command exits without writing a replacement. + +With `--from-file` or `--from-dir`, pass a repeatable `--trusted-private-host ` option to admit matching RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local endpoints. The option is invalid for built-in presets. You can supply exact hosts through `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` instead, and NemoClaw combines the variable with command options. NemoClaw resolves each matching exact host and adds generated `allowed_ips` pins to an in-memory copy of the preset. User-authored `allowed_ips` remains rejected. Dry-run output shows the generated pins. Applying the preset places those pins in the current OpenShell policy, which rebuild carries forward without re-resolving ambient DNS. | Flag | Description | -|------|-------------| +| --- | --- | | `--from-file ` | Apply a custom preset YAML file instead of a built-in preset | | `--from-dir ` | Apply every custom preset YAML file in a directory in lexicographic order | | `--trusted-private-host ` | Admit one private endpoint host from a custom preset and generate address pins; repeat for additional hosts | @@ -2867,34 +1862,20 @@ For batch workflows, apply all preset files from a directory: $$nemoclaw my-assistant policy add --from-dir ./presets/ --yes ``` -Review every host in custom preset files before applying them. -Custom presets bypass the built-in preset review process and can widen sandbox egress. +Review every host in custom preset files before applying them. Custom presets bypass the built-in preset review process and can widen sandbox egress. ### `$$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. -NemoClaw does not render network-policy rule bodies as prose in `policy list` output. -Recorded baseline exclusions appear in a separate section. -`active` means the reviewed digest still matches the current baseline, `baseline changed — re-review required` means the current entry differs, and `baseline entry removed — restore to clear` means the current release no longer defines the key. -Use `status` or `doctor` to additionally verify that the approval belongs to the active agent and the excluded key is absent from the live policy. -`repair required — interrupted exclude/restore; rebuild blocked` means NemoClaw preserved a durable transaction journal after a crash or persistence failure; rerun the displayed policy command to reconcile it before sandbox creation or recreation, rebuild, or cross-sandbox snapshot cloning. +List available policy presets and show which ones match the current OpenShell policy. Built-in rows are scoped to the active agent. Custom preset rows are decoded from namespaced keys in that live document. NemoClaw does not cross-reference a local preset registry or display baseline-exclusion records. -Each active preset is annotated with its provenance so you can tell why it is applied: +Each active preset is annotated with display-time provenance: -- `[from tier]` — the preset name matches an entry in the sandbox's current tier definition (see [Policy Tiers](../reference/network-policies#policy-tiers)). -- `[from agent]` — the preset name matches a NemoClaw-managed agent preset and the active agent matches that label. -- `[user-added]` — anything else: presets applied later through `policy add`, presets that match no tier or agent default, or presets that match the opposite agent's reserved names on a sandbox running the other agent. -- `[source unverified]` — the row is active but the local registry and live gateway state disagree. - When the gateway cannot be queried, this renders as `[source unverified (gateway unreachable)]`. - The provenance check is suppressed in these trust-degraded states because the source cannot be confirmed against both halves of the sandbox policy view. +- `[from tier]` means the name appears in the current tier definition. +- `[from agent]` means the name is an agent-specific preset for the active agent. +- `[user-added]` covers other live presets. +- `[source unverified (gateway unreachable)]` appears only when OpenShell cannot be read; no local policy fallback is shown. -Provenance tags are inferred from the sandbox's current tier and agent metadata at display time and are not persisted per preset. -A preset whose name appears in the sandbox's current tier YAML is labelled `[from tier]` even when an operator added it manually with `policy add` after onboarding. -Agent-specific preset names are only labelled `[from agent]` when the active agent matches that label. +Provenance tags are inferred from the sandbox's current tier and agent metadata at display time and are not persisted per preset. A preset whose name appears in the sandbox's current tier YAML is labelled `[from tier]` even when an operator added it manually with `policy add` after onboarding. Agent-specific preset names are only labelled `[from agent]` when the active agent matches that label. ```bash $$nemoclaw my-assistant policy list @@ -2902,8 +1883,7 @@ $$nemoclaw my-assistant policy list ### `$$nemoclaw policy remove` -Remove a previously applied policy preset from a sandbox. -The command lists the presets the local registry records together with the presets the live gateway enforces, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress. +Remove a previously applied policy preset from a sandbox. The command derives applied presets from the current OpenShell policy, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress. ```bash $$nemoclaw my-assistant policy remove @@ -2915,53 +1895,29 @@ To remove a specific preset non-interactively, pass its name as a positional arg $$nemoclaw my-assistant policy remove pypi --yes ``` -Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`. -Without a preset name, `policy remove` reports the same two picker errors as `policy add` and exits non-zero. -If the preset is unknown, or neither the local registry nor the live gateway holds it, the command exits non-zero with a clear error. -A preset the gateway enforces without a local registry record is removable, which is the state `policy list` reports as active on gateway, missing from local state. -When NemoClaw cannot query the gateway, the command checks the local registry alone; with a preset name it also reports that the gateway could not be queried. +Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`. Without a preset name, `policy remove` reports the same two picker errors as `policy add` and exits non-zero. If the preset is unknown or absent from the live OpenShell policy, the command exits non-zero with a clear error. When NemoClaw cannot query OpenShell, it refuses the mutation instead of consulting a local policy record. -| Flag | Description | -|------|-------------| -| `--yes`, `--force` | Skip the confirmation prompt (requires a preset name) | -| `--dry-run` | Preview which endpoints would be removed without applying changes | +| Flag | Description | +| ------------------ | ----------------------------------------------------------------- | +| `--yes`, `--force` | Skip the confirmation prompt (requires a preset name) | +| `--dry-run` | Preview which endpoints would be removed without applying changes | Unchecking a preset in the onboard TUI checkbox also removes it from the sandbox. ### `$$nemoclaw policy exclude ` -Persistently exclude one entry from the agent baseline policy after previewing the egress and support impact that the change removes. -The preview names the supported features that may stop working. -The command refuses an entry that does not have a reviewed feature-impact disclosure. -The versioned exclusion record is bound to the reviewed baseline content and active agent, then replayed during rebuild. -If the active agent or entry changes, rebuild fails closed until you clear or review the exclusion again. -The command refuses to exclude a key that an applied preset already owns, because removing that live key would also remove the preset's access. -The critical `managed_inference` entry cannot currently be excluded pending product direction. -Use `--force` or `--yes` for explicit non-interactive acknowledgement, or `--dry-run` to preview without changing the sandbox. -A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, does not prompt and requires one of those acknowledgement flags. +Remove one exact entry from the current OpenShell policy after previewing the egress and support impact that the change removes. The preview names the supported features that may stop working. The command refuses an entry that does not have a reviewed feature-impact disclosure. No exclusion record or replay journal is written: OpenShell's resulting policy is the complete state, and rebuild carries that live document forward. The command refuses to exclude a key that an applied preset also requires, because removing that live key would remove the preset's access. The critical `managed_inference` entry cannot currently be excluded pending product direction. Use `--force` or `--yes` for explicit non-interactive acknowledgement, or `--dry-run` to preview without changing the sandbox. A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, does not prompt and requires one of those acknowledgement flags. ```bash $$nemoclaw my-assistant policy exclude nous_research --dry-run $$nemoclaw my-assistant policy exclude nous_research --force ``` -When a release changes an excluded entry, first run `$$nemoclaw policy restore --dry-run` to preview the current baseline egress that restoration would allow again. -After you review the output, run `$$nemoclaw policy restore --force` to allow that egress again and clear the stale exclusion record. -Then preview the current exclusion scope with `$$nemoclaw policy exclude --dry-run` and reapply it with `$$nemoclaw policy exclude --force` only if you still accept the support impact. -When a release removes the entry, first run `$$nemoclaw policy restore --dry-run` to confirm that restoration will clear only the stale exclusion record. -After you review the output, run `$$nemoclaw policy restore --force`; there is no replacement scope to review or approve. +To restore an entry, run `$$nemoclaw policy restore --dry-run` to preview the current baseline egress, then run it with `--force` after review. If the current baseline no longer defines the key, the command reports that there is nothing to restore and leaves the live OpenShell policy unchanged. ### `$$nemoclaw policy restore ` -Restore a previously excluded entry from the current agent baseline and clear its durable exclusion record. -When the current baseline still defines the entry, `--dry-run` lists the egress that restoration would allow again. -After you review the output, `--force` allows that egress again and clears the exclusion record. -When the baseline no longer defines the entry, `--dry-run` states that restoration will clear only the stale exclusion record. -After you review the output, `--force` clears that record without changing live egress. -Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement. -As with `policy exclude`, a run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, does not prompt. -If a restore is interrupted, NemoClaw finalizes it only when the durable exclusion still exactly matches the staged exclusion and the current release baseline still exactly matches the journaled live target. -If either value changed or the current baseline is unreadable, the journal remains in `repair required` state so you can inspect and re-review the current scope instead of silently accepting a different entry. +Restore one entry from the current agent baseline into the current OpenShell policy. `--dry-run` lists the egress that restoration would allow again; after review, `--force` applies it. Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement. As with `policy exclude`, a run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, does not prompt. The command writes no exclusion record or journal and verifies the resulting live OpenShell policy before returning. ```bash $$nemoclaw my-assistant policy restore nous_research --dry-run @@ -2971,15 +1927,13 @@ $$nemoclaw my-assistant policy restore nous_research --force The restore command accepts these flags: | Flag | Description | -|------|-------------| +| --- | --- | | `--yes`, `-y`, `--force` | Skip the confirmation prompt | | `--dry-run` | Preview the egress restoration or stale-record cleanup without applying changes | ### `$$nemoclaw policy explain` -Print a redacted summary of the active policy context for a sandbox so an agent or operator can reason about what is allowed, what is blocked, and how to request a change. -The output covers the recorded tier, applied presets and allowed host categories, known unapplied presets, baseline exclusions and their support impact, policy-change commands, and the support boundaries between NemoClaw, OpenShell, and the agent. -Raw policy YAML, rule bodies, and credential metadata are deliberately not included. +Print a redacted summary of the current OpenShell policy context for a sandbox so an agent or operator can reason about what is allowed, what is blocked, and how to request a change. The output covers inferred tier and preset context, allowed host categories, known unapplied presets, policy-change commands, and the support boundaries between NemoClaw, OpenShell, and the agent. Raw policy YAML, rule bodies, and credential metadata are deliberately not included. ```bash $$nemoclaw my-assistant policy explain @@ -2998,25 +1952,23 @@ Pass `--write` to refresh that file on demand without changing the policy: ```bash $$nemoclaw my-assistant policy explain --write ``` + -The context also documents how a failed host or integration attempt should be classified. -The classifications are `blocked-by-policy`, `missing-approval`, `unsupported`, and `unknown`, so the agent can pick a remediation step instead of surfacing a lower-level network error. +The context also documents how a failed host or integration attempt should be classified. The classifications are `blocked-by-policy`, `missing-approval`, `unsupported`, and `unknown`, so the agent can pick a remediation step instead of surfacing a lower-level network error. -| Flag | Description | -|------|-------------| +| Flag | Description | +| -------- | ------------------------------------------------------------------------- | | `--json` | Emit the policy context as a structured JSON object for agent consumption | + -| `--write` | Refresh `/sandbox/.openclaw/workspace/POLICY.md` inside the sandbox in addition to printing | + | `--write` | Refresh `/sandbox/.openclaw/workspace/POLICY.md` inside the sandbox in addition to + printing | ### `$$nemoclaw hosts-add` -Add a host alias to the sandbox pod template. -Use this when a sandbox needs a stable LAN-only name, such as a local SearXNG or internal model endpoint, without dropping to `docker exec` and `kubectl patch`. -Host alias commands use the legacy Kubernetes gateway `Sandbox` resource path. -In that older topology, the `openshell-cluster-nemoclaw` container runs an embedded k3s cluster with a `sandboxes.agents.x-k8s.io` custom resource definition, and an `agent-sandbox-controller` reconciles each `Sandbox` resource into the agent pod. -They are not supported on Docker-driver or VM-driver sandboxes because those drivers do not run the gateway cluster container that owns this resource. +Add a host alias to the sandbox pod template. Use this when a sandbox needs a stable LAN-only name, such as a local SearXNG or internal model endpoint, without dropping to `docker exec` and `kubectl patch`. Host alias commands use the legacy Kubernetes gateway `Sandbox` resource path. In that older topology, the `openshell-cluster-nemoclaw` container runs an embedded k3s cluster with a `sandboxes.agents.x-k8s.io` custom resource definition, and an `agent-sandbox-controller` reconciles each `Sandbox` resource into the agent pod. They are not supported on Docker-driver or VM-driver sandboxes because those drivers do not run the gateway cluster container that owns this resource. ```bash $$nemoclaw my-assistant hosts-add searxng.local 192.168.1.105 @@ -3024,8 +1976,8 @@ $$nemoclaw my-assistant hosts-add searxng.local 192.168.1.105 The command validates the hostname and IP address, rejects duplicate hostnames, and patches `spec.podTemplate.spec.hostAliases` on the sandbox resource. -| Flag | Description | -|------|-------------| +| Flag | Description | +| ----------- | ----------------------------------------------------------------------------- | | `--dry-run` | Print the JSON patch for the resulting `hostAliases` list without applying it | ### `$$nemoclaw hosts-list` @@ -3044,21 +1996,17 @@ Remove a hostname from the sandbox `hostAliases` list. $$nemoclaw my-assistant hosts-remove searxng.local ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| ----------- | ----------------------------------------------------------------------------- | | `--dry-run` | Print the JSON patch for the resulting `hostAliases` list without applying it | ### `$$nemoclaw channels list` -List the messaging channels supported by the sandbox's agent runtime with a short description. -Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams are available for OpenClaw and Hermes. -WeChat, WhatsApp, and Microsoft Teams are experimental. -OpenClaw and Hermes also support experimental Google Chat. +List the messaging channels supported by the sandbox's agent runtime with a short description. Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams are available for OpenClaw and Hermes. WeChat, WhatsApp, and Microsoft Teams are experimental. OpenClaw and Hermes also support experimental Google Chat. -The command reads the sandbox registry to select agent-compatible channel manifests. -It does not inspect messaging credentials or the live sandbox runtime. +The command reads the sandbox registry to select agent-compatible channel manifests. It does not inspect messaging credentials or the live sandbox runtime. ```bash $$nemoclaw my-assistant channels list @@ -3066,78 +2014,35 @@ $$nemoclaw my-assistant channels list ### `$$nemoclaw channels add ` -Register a messaging channel with the sandbox and rebuild so the image picks up the new channel. -Channel enrollment uses these credential and pairing flows: - -- **Token paste** (`telegram`, `discord`, `slack`, `teams`): the command prompts for required channel inputs. - It registers secret credentials with the OpenShell gateway and saves non-secret configuration for rebuilds. -- **Host-side QR** (`wechat`, experimental): the command renders an iLink QR code on the host and you scan it from WeChat on your phone. - On confirm, NemoClaw captures the bot token, registers it with the OpenShell gateway, and stores non-secret per-account metadata (`WECHAT_ACCOUNT_ID`, `WECHAT_BASE_URL`, `WECHAT_USER_ID`) for the in-sandbox bridge. - NemoClaw automatically adds the scanning operator's WeChat user ID to `WECHAT_ALLOWED_IDS`. - Supply additional comma-separated IDs to authorize more DM senders. - NemoClaw advertises WeChat for both OpenClaw (the `@tencent-weixin/openclaw-weixin` plugin) and Hermes (the built-in iLink WeChat adapter). -- **In-sandbox QR** (`whatsapp`, experimental): the command records the channel without a host-side token or OpenShell credential provider. - NemoClaw advertises WhatsApp for OpenClaw and Hermes sandboxes; after rebuild, run `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes. - This intentionally leaves QR-created mutable session state in the sandbox until you unpair it or clear the durable agent state. - -Google Chat uses an experimental token-paste flow for the service-account JSON on both agents. -OpenClaw also requires interactive public-webhook confirmation and prompts for optional app-principal configuration during enrollment. -Hermes instead prompts for the Google Cloud project ID, complete Pub/Sub subscription name, and email sender allowlist. -It pulls inbound events from Pub/Sub over REST and does not create a public webhook endpoint. - -After registering the channel, NemoClaw asks whether to rebuild immediately. -Running `add` for an already-configured channel overwrites the stored credentials where applicable. -The operation is idempotent. -Static channel credentials use a validated endpointless OpenShell provider profile so the gateway can replace their sandbox placeholders. -NemoClaw validates the existing profile, provider type, and credential keys before it reuses a provider. -A missing, malformed, conflicting, or incompatible provider state stops the operation before reuse. -Hermes Discord uses its dedicated static provider type because its policy binds the Discord API and Gateway endpoints. -Channel names are trimmed and lowercased before NemoClaw stores credentials, names bridge providers, or prints rebuild messages. -NemoClaw requires the matching built-in network policy preset YAML to be present. -A missing or malformed preset YAML (no `network_policies:` section) aborts `channels add` before any token prompt, registry write, or rebuild prompt. -After validating that preset, NemoClaw discloses its effect before prompting for credentials or changing gateway or registry state. -It prints the effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective. -The `--dry-run` path prints the same disclosure without collecting credentials or applying changes. -With the preset file in place, NemoClaw applies it to the sandbox before the rebuild so the bridge has egress to its upstream API. -When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. -When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. -Restore the preset YAML and re-run `$$nemoclaw channels add `. -For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. +Register a messaging channel with the sandbox and rebuild so the image picks up the new channel. Channel enrollment uses these credential and pairing flows: + +- **Token paste** (`telegram`, `discord`, `slack`, `teams`): the command prompts for required channel inputs. It registers secret credentials with the OpenShell gateway and saves non-secret configuration for rebuilds. +- **Host-side QR** (`wechat`, experimental): the command renders an iLink QR code on the host and you scan it from WeChat on your phone. On confirm, NemoClaw captures the bot token, registers it with the OpenShell gateway, and stores non-secret per-account metadata (`WECHAT_ACCOUNT_ID`, `WECHAT_BASE_URL`, `WECHAT_USER_ID`) for the in-sandbox bridge. NemoClaw automatically adds the scanning operator's WeChat user ID to `WECHAT_ALLOWED_IDS`. Supply additional comma-separated IDs to authorize more DM senders. NemoClaw advertises WeChat for both OpenClaw (the `@tencent-weixin/openclaw-weixin` plugin) and Hermes (the built-in iLink WeChat adapter). +- **In-sandbox QR** (`whatsapp`, experimental): the command records the channel without a host-side token or OpenShell credential provider. NemoClaw advertises WhatsApp for OpenClaw and Hermes sandboxes; after rebuild, run `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes. This intentionally leaves QR-created mutable session state in the sandbox until you unpair it or clear the durable agent state. + +Google Chat uses an experimental token-paste flow for the service-account JSON on both agents. OpenClaw also requires interactive public-webhook confirmation and prompts for optional app-principal configuration during enrollment. Hermes instead prompts for the Google Cloud project ID, complete Pub/Sub subscription name, and email sender allowlist. It pulls inbound events from Pub/Sub over REST and does not create a public webhook endpoint. + +After registering the channel, NemoClaw asks whether to rebuild immediately. Running `add` for an already-configured channel overwrites the stored credentials where applicable. The operation is idempotent. Static channel credentials use a validated endpointless OpenShell provider profile so the gateway can replace their sandbox placeholders. NemoClaw validates the existing profile, provider type, and credential keys before it reuses a provider. A missing, malformed, conflicting, or incompatible provider state stops the operation before reuse. Hermes Discord uses its dedicated static provider type because its policy binds the Discord API and Gateway endpoints. Channel names are trimmed and lowercased before NemoClaw stores credentials, names bridge providers, or prints rebuild messages. NemoClaw requires the matching built-in network policy preset YAML to be present. A missing or malformed preset YAML (no `network_policies:` section) aborts `channels add` before any token prompt, registry write, or rebuild prompt. After validating that preset, NemoClaw discloses its effect before prompting for credentials or changing gateway or registry state. It prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective. The `--dry-run` path prints the same disclosure without collecting credentials or applying changes. With the preset file in place, NemoClaw applies it to the sandbox before the rebuild so the bridge has egress to its upstream API. When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. Restore the preset YAML and re-run `$$nemoclaw channels add `. For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. ```bash $$nemoclaw my-assistant channels add telegram ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--dry-run` | Validate the channel name and matching policy preset without prompting for credentials, contacting the gateway, or rebuilding | | `--force` | Add the channel despite a credential conflict, shared-resource conflict, or incomplete required check. This flag is the only conflict override. | -Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. -The conflict check compares only sandboxes in the selected OpenShell gateway's sandbox registry. -It cannot detect Slack token reuse across independent OpenShell gateways. -Run only one active Slack sandbox on each OpenShell gateway, and use distinct Slack bot and app tokens across gateways. -Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. -Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. -Discord applies that default only when a server ID is configured. -A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, fails fast on any missing token and shows no rebuild prompt. -Instead, the change is queued and you are told to run `$$nemoclaw rebuild` manually. -An SSH command without `-t`, a service unit, or a CI job has no terminal on stdin, so it does not need `NEMOCLAW_NON_INTERACTIVE=1` to reach this path. -If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. +Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. The conflict check compares only sandboxes in the selected OpenShell gateway's sandbox registry. It cannot detect Slack token reuse across independent OpenShell gateways. Run only one active Slack sandbox on each OpenShell gateway, and use distinct Slack bot and app tokens across gateways. Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. Discord applies that default only when a server ID is configured. A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, fails fast on any missing token and shows no rebuild prompt. Instead, the change is queued and you are told to run `$$nemoclaw rebuild` manually. An SSH command without `-t`, a service unit, or a CI job has no terminal on stdin, so it does not need `NEMOCLAW_NON_INTERACTIVE=1` to reach this path. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. ### `$$nemoclaw channels remove ` -Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. -Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. -When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway. -If the matching built-in policy preset is applied, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone. -NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild. +Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway. If the matching built-in policy preset is applied, NemoClaw also removes that preset from the current OpenShell policy so the upstream API is no longer allow-listed after the channel is gone. No session preset list exists to synchronize. For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directories before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect: - OpenClaw: `/sandbox/.openclaw//` (for example `/sandbox/.openclaw/whatsapp/`). -- Hermes: `/sandbox/.hermes/platforms//` (for example `/sandbox/.hermes/platforms/whatsapp/`). - For WhatsApp, NemoClaw also clears the current Dashboard profile at `/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/` and the legacy migration source at `/sandbox/.hermes/dashboard-home/platforms/whatsapp/session/`. +- Hermes: `/sandbox/.hermes/platforms//` (for example `/sandbox/.hermes/platforms/whatsapp/`). For WhatsApp, NemoClaw also clears the current Dashboard profile at `/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session/` and the legacy migration source at `/sandbox/.hermes/dashboard-home/platforms/whatsapp/session/`. The cleanup tries `openshell sandbox exec` first and falls back to SSH if the exec wrapper does not return the success sentinel. If both transports fail (the sandbox is stopped, the gateway is down, or SSH cannot reach it) the command refuses to proceed to the rebuild and asks you to start the sandbox and re-run, so a half-removed state cannot leave stale Baileys auth files behind for the next rebuild to restore. @@ -3146,22 +2051,17 @@ $$nemoclaw my-assistant channels remove telegram ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--dry-run` | Report the channel that would be removed without clearing credentials or rebuilding | -As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` or a run without a terminal on stdin skips the rebuild prompt and queues the change for a manual `$$nemoclaw rebuild`. -`channels start` and `channels stop` follow the same rule. -If you omit the required `` argument, the CLI prints the `channels remove ` usage with the supported channel list. +As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` or a run without a terminal on stdin skips the rebuild prompt and queues the change for a manual `$$nemoclaw rebuild`. `channels start` and `channels stop` follow the same rule. If you omit the required `` argument, the CLI prints the `channels remove ` usage with the supported channel list. Host-side removal is the supported path because managed startup (or an explicit custom image build) materializes agent channel config as image-owned state (`/sandbox/.openclaw/openclaw.json` for OpenClaw and `/sandbox/.hermes/.env` for Hermes); agent-specific channel removals inside the sandbox would modify the running config but not persist changes across rebuilds. ### `$$nemoclaw channels stop ` -Pause one configured messaging channel without clearing its credentials. -The command verifies that the sandbox's agent runtime supports the channel before reading configured or disabled channel state. -It then requires the channel to be configured for the sandbox. -The channel is marked disabled in the per-sandbox registry, and the rebuild omits its runtime configuration, token upsert, and startup effects. -Generic channel providers and refresh bridges remain detached while the channel is stopped. +Pause one configured messaging channel without clearing its credentials. The command verifies that the sandbox's agent runtime supports the channel before reading configured or disabled channel state. It then requires the channel to be configured for the sandbox. The channel is marked disabled in the per-sandbox registry, and the rebuild omits its runtime configuration, token upsert, and startup effects. Generic channel providers and refresh bridges remain detached while the channel is stopped. + When a stopped Hermes Discord channel keeps a credential-bound policy, the rebuild retains and attaches only the validated static provider that the policy requires. @@ -3175,44 +2075,31 @@ $$nemoclaw my-assistant channels stop telegram ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--dry-run` | Report the channel that would be disabled without updating the registry or rebuilding | Use `channels stop` instead of `channels remove` when you want to pause a bridge temporarily. `channels remove` is destructive to credentials; `channels stop` is not. ### `$$nemoclaw channels start ` -Re-enable a channel previously paused with `channels stop`. -The command verifies that the sandbox's agent runtime supports the channel before reading configured or disabled channel state. -It then requires the channel to be configured for the sandbox. -NemoClaw removes the channel from the disabled list and records it as enabled in the messaging plan. -The rebuild uses that plan to attach the existing bridge provider before applying its matching built-in network policy preset to the replacement sandbox. -Before updating the disabled list, NemoClaw prints the effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective. -If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you run `$$nemoclaw rebuild`. +Re-enable a channel previously paused with `channels stop`. The command verifies that the sandbox's agent runtime supports the channel before reading configured or disabled channel state. It then requires the channel to be configured for the sandbox. NemoClaw removes the channel from the disabled list and records it as enabled in the messaging plan. The rebuild uses that plan to attach the existing bridge provider before applying its matching built-in network policy preset to the replacement sandbox. Before updating the disabled list, NemoClaw prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective. If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you run `$$nemoclaw rebuild`. ```bash $$nemoclaw my-assistant channels start telegram ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--dry-run` | Report the channel that would be re-enabled without updating the registry or rebuilding | ### `$$nemoclaw channels status` -Run messaging channel status checks. -Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons. -For channel and agent combinations that support a detailed probe, the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check. -With `--channel`, it prints the detailed status for that channel. +Run messaging channel status checks. Without `--channel`, the command prints a compact summary for every configured channel, including registration, policy coverage, and non-secret rendered config comparisons. For channel and agent combinations that support a detailed probe, the summary adds a `Runtime health: not checked in summary view` pointer instead of running the probe, so it never reads as healthy without an explicit check. With `--channel`, it prints the detailed status for that channel. -For an OpenClaw WhatsApp sandbox, `--channel whatsapp` probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage. -A paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. -The detailed WhatsApp probe stays focused on QR/session runtime diagnostics and does not include rendered-config comparison lines. -For a Hermes WhatsApp sandbox, `--channel whatsapp` first checks the shared gateway path and the `profiles/dashboard-home` path for `creds.json`. -The status compatibility probe has this contract: +For an OpenClaw WhatsApp sandbox, `--channel whatsapp` probes the sandbox to separately report pairing/session state, the Noise WebSocket connection, inbound event delivery, and policy coverage. A paired channel with no observed inbound delivery exits non-zero with verdict `idle` so an unhealthy bridge cannot pass as healthy. The detailed WhatsApp probe stays focused on QR/session runtime diagnostics and does not include rendered-config comparison lines. For a Hermes WhatsApp sandbox, `--channel whatsapp` first checks the shared gateway path and the `profiles/dashboard-home` path for `creds.json`. The status compatibility probe has this contract: | Property | Contract | -|------|-------------| +| --- | --- | | Owner | The NemoClaw WhatsApp status hook owns the probe. | | Scope | The hook reads only `platforms.whatsapp.extra.session_path` from `/sandbox/.hermes/config.yaml` when the durable session path has no `creds.json`. The hook does not write the field or configure Hermes. | | Validation | The value must be an absolute path under `/sandbox/.hermes` with no `.` or `..` segment. | @@ -3220,35 +2107,13 @@ The status compatibility probe has this contract: | Support period | Keep the probe only while a supported Hermes image can place dashboard-paired credentials outside the durable path. | | Retirement | [Issue #8947](https://github.com/NVIDIA/NemoClaw/issues/8947) tracks removal after every supported Hermes image uses the durable path for dashboard pairing and gateway startup. | -After validation, the command checks the configured path and reports a `Session path override` signal. -An unsupported path produces a warning, and the report uses the default gateway path. -If the dashboard path has credentials and the gateway path is empty, the report tells you to run `$$nemoclaw channels remove whatsapp` and then `$$nemoclaw channels add whatsapp`, because a rebuild restores the legacy session instead of dropping it. -Pair again from the dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`. -Rerun `$$nemoclaw channels status --channel whatsapp`. -NemoClaw does not treat a Hermes session file as live inbound-health evidence. - -For Telegram, `--channel telegram` probes the sandbox to report the gateway process, Bot API reachability, and inbound delivery alongside the config comparison. -Detailed non-wait JSON keeps the schema-version-1 `{schemaVersion,sandbox,channel,report}` envelope. -When a probe-capable channel is paused, the command skips the live probe, returns `report.verdict` as `info` with paused registration and runtime-health signals, and exits 0. -It classifies the state as `healthy`, `idle`, `token_rejected`, `unreachable`, `not_started`, `policy_gap`, `config_gap`, `unknown`, or `probe_failed`. -A network or egress failure, or a non-authentication Bot API startup HTTP error, produces `unreachable`; a 401 or 404 response produces `token_rejected`. -It reads the gateway's own startup and poll log breadcrumbs rather than issuing its own Bot API request, so the resolved bot token never leaves the gateway. -The verdict reflects the most recent evidence in the log window, so a bridge that recovered after a blocked start reports `healthy` while one blocked again reports `unreachable`. -Telegram health is probed only for OpenClaw sandboxes; a Hermes Telegram sandbox uses the basic config report. - -For OpenClaw Slack, `--channel slack` reports registration, policy coverage, the account runtime, Socket Mode transport, and the account probe. -Add `--wait` to poll these conditions until Slack becomes operational, a terminal error occurs, or the timeout expires. -The default timeout is 180 seconds, and `--timeout ` requires `--wait`. -NemoClaw treats the timeout as the total budget for polling and live probes, limits each live probe to the remaining budget, and starts no probe at or after the deadline. -Use `--json` with `--wait` for a structured readiness result with `readiness.state`, `readiness.category`, `readiness.reason`, `readiness.retryable`, `readiness.attempts`, `readiness.elapsedMs`, `readiness.lastTransitionAt`, and `readiness.lastObserved`. -Slack `--wait` applies only to OpenClaw sandboxes and uses its manifest-defined check; other channel manifests return `readiness_not_supported` until they define one. -For an OpenClaw sandbox, if Slack is paused with `channels stop`, the command skips the live probe and returns one terminal result with `readiness.reason` set to `channel_paused`. -On timeout, the command exits nonzero with `readiness.state`, `readiness.category`, and `readiness.reason` set to `timeout`; its `readiness.retryable` mirrors the last observed state, whose category and reason preserve the underlying cause. - -For registered channel details and the compact summary, the status output compares non-secret config inputs from the sandbox registry against the values rendered into the agent config, such as Telegram group policy in `openclaw.json` or mention mode in Hermes config. -Secret inputs, including tokens, are not printed. -If the registry contains a non-secret expected value but NemoClaw cannot read or check the rendered source, the comparison is a warning and the detail includes `(not checked)`. -Optional unset inputs remain informational. +After validation, the command checks the configured path and reports a `Session path override` signal. An unsupported path produces a warning, and the report uses the default gateway path. If the dashboard path has credentials and the gateway path is empty, the report tells you to run `$$nemoclaw channels remove whatsapp` and then `$$nemoclaw channels add whatsapp`, because a rebuild restores the legacy session instead of dropping it. Pair again from the dashboard so credentials use `/sandbox/.hermes/platforms/whatsapp/session`. Rerun `$$nemoclaw channels status --channel whatsapp`. NemoClaw does not treat a Hermes session file as live inbound-health evidence. + +For Telegram, `--channel telegram` probes the sandbox to report the gateway process, Bot API reachability, and inbound delivery alongside the config comparison. Detailed non-wait JSON keeps the schema-version-1 `{schemaVersion,sandbox,channel,report}` envelope. When a probe-capable channel is paused, the command skips the live probe, returns `report.verdict` as `info` with paused registration and runtime-health signals, and exits 0. It classifies the state as `healthy`, `idle`, `token_rejected`, `unreachable`, `not_started`, `policy_gap`, `config_gap`, `unknown`, or `probe_failed`. A network or egress failure, or a non-authentication Bot API startup HTTP error, produces `unreachable`; a 401 or 404 response produces `token_rejected`. It reads the gateway's own startup and poll log breadcrumbs rather than issuing its own Bot API request, so the resolved bot token never leaves the gateway. The verdict reflects the most recent evidence in the log window, so a bridge that recovered after a blocked start reports `healthy` while one blocked again reports `unreachable`. Telegram health is probed only for OpenClaw sandboxes; a Hermes Telegram sandbox uses the basic config report. + +For OpenClaw Slack, `--channel slack` reports registration, policy coverage, the account runtime, Socket Mode transport, and the account probe. Add `--wait` to poll these conditions until Slack becomes operational, a terminal error occurs, or the timeout expires. The default timeout is 180 seconds, and `--timeout ` requires `--wait`. NemoClaw treats the timeout as the total budget for polling and live probes, limits each live probe to the remaining budget, and starts no probe at or after the deadline. Use `--json` with `--wait` for a structured readiness result with `readiness.state`, `readiness.category`, `readiness.reason`, `readiness.retryable`, `readiness.attempts`, `readiness.elapsedMs`, `readiness.lastTransitionAt`, and `readiness.lastObserved`. Slack `--wait` applies only to OpenClaw sandboxes and uses its manifest-defined check; other channel manifests return `readiness_not_supported` until they define one. For an OpenClaw sandbox, if Slack is paused with `channels stop`, the command skips the live probe and returns one terminal result with `readiness.reason` set to `channel_paused`. On timeout, the command exits nonzero with `readiness.state`, `readiness.category`, and `readiness.reason` set to `timeout`; its `readiness.retryable` mirrors the last observed state, whose category and reason preserve the underlying cause. + +For registered channel details and the compact summary, the status output compares non-secret config inputs from the sandbox registry against the values rendered into the agent config, such as Telegram group policy in `openclaw.json` or mention mode in Hermes config. Secret inputs, including tokens, are not printed. If the registry contains a non-secret expected value but NemoClaw cannot read or check the rendered source, the comparison is a warning and the detail includes `(not checked)`. Optional unset inputs remain informational. ```bash $$nemoclaw my-assistant channels status @@ -3258,81 +2123,40 @@ $$nemoclaw my-assistant channels status --channel slack --wait --timeout 180 --j ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--channel ` | Channel to inspect in detail | | `--wait` | Wait for the selected channel's manifest-defined readiness check (requires `--channel`) | | `--timeout ` | Stop waiting after this number of seconds (default: `180`; requires `--wait`) | | `--json` | Emit the status or readiness report as JSON; non-ready terminal and timeout results exit nonzero | -Without `--wait`, a detailed channel status request with `--json` returns the `schemaVersion`, `sandbox`, `channel`, and `report` envelope. -A paused channel retains that envelope when NemoClaw skips its live probe. -With `--wait`, the result contains top-level `status` and `readiness` fields, and the detailed channel status envelope is under `status`. - -Each live probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout. -The WhatsApp probe returns strict OpenClaw status JSON to the host, where NemoClaw allowlists pairing, liveness, connection-state, and timestamp fields before rendering the report and discards phone-number and free-text error fields. -The Hermes WhatsApp probe returns fixed session-presence booleans to the host. -When the shared gateway path is empty, a sandbox-local parser can also return the configured WhatsApp session path. -The parser does not return other Hermes configuration values. -NemoClaw validates the path before it uses the path in a second session-presence probe. -The Telegram probe returns only matched gateway log lines to the host, where NemoClaw reduces them to fixed classifications without rendering the raw lines, message bodies, or tokens. -The Slack probe returns OpenClaw status JSON to the host. -NemoClaw reduces it to allowlisted account booleans, credential availability, probe success, fixed error categories, and timestamps; the rendered and JSON readiness reports omit tokens and free-text errors. +Each live probe is bounded by an in-sandbox `openshell sandbox exec` with a hard timeout. The WhatsApp probe returns strict OpenClaw status JSON to the host, where NemoClaw allowlists pairing, liveness, connection-state, and timestamp fields before rendering the report and discards phone-number and free-text error fields. The Hermes WhatsApp probe returns fixed session-presence booleans to the host. When the shared gateway path is empty, a sandbox-local parser can also return the configured WhatsApp session path. The parser does not return other Hermes configuration values. NemoClaw validates the path before it uses the path in a second session-presence probe. The Telegram probe returns only matched gateway log lines to the host, where NemoClaw reduces them to fixed classifications without rendering the raw lines, message bodies, or tokens. The Slack probe returns OpenClaw status JSON to the host. NemoClaw reduces it to allowlisted account booleans, credential availability, probe success, fixed error categories, and timestamps; the rendered and JSON readiness reports omit tokens and free-text errors. ### `$$nemoclaw mcp list` -List MCP servers configured for a sandbox. -The command reports the selected agent's MCP support status and, for each configured server, whether the generated OpenShell provider, policy, and agent adapter are present. +List MCP servers configured for a sandbox. The command reports the selected agent's MCP support status and, for each configured server, whether the generated OpenShell provider, policy, and agent adapter are present. ```bash $$nemoclaw my-assistant mcp list [--json] ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| -------- | ----------------------------------------------------------------------------- | | `--json` | Emit sandbox, support, and MCP server state as JSON without credential values | ### `$$nemoclaw mcp add` -Add an MCP Streamable HTTP server to a sandbox. -Pass `--url` for the MCP endpoint and the required single `--env KEY` bearer credential for the sandbox-side MCP client. -Pass a repeatable `--trusted-private-host ` option to admit an RFC1918, CGNAT, or IPv6 unique local destination for the current command. -The declaration must equal the normalized host from `--url`. -For managed MCP, use a DNS hostname for an IPv6 unique local address because NemoClaw has not qualified direct IPv6-literal MCP URLs. -You can supply exact hosts through `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` instead, and NemoClaw combines the variable with command options. -NemoClaw records the resulting trust intent and address pins, so restart, rebuild, and restore do not depend on the ambient environment. -NemoClaw registers that credential in an OpenShell provider, installs a generated OpenShell `protocol: mcp` policy for the target endpoint, attaches the provider to the running sandbox, and writes only an OpenShell resolver placeholder for the recorded key into the agent configuration. -Inline `--env KEY=VALUE` is rejected because it would expose the value in NemoClaw process arguments. -Load the variable from a secret manager or masked prompt, export it without recording the value in shell history, and pass only `--env KEY`. -All endpoints must use HTTPS. -The full URL and path are persisted and displayed, so URLs cannot contain userinfo, query strings, fragments, known secret-shaped path material, percent-escaped or glob-style paths, or port zero. -Server names must start with a letter and contain at most 64 letters, digits, hyphens, or underscores, and endpoint hostnames must use canonical lowercase DNS labels. -NemoClaw rejects invalid names and endpoints before it writes lifecycle state or changes OpenShell resources. -NemoClaw generates a narrow `protocol: mcp` policy for the destination, literal path, adapter binaries, pinned addresses, explicit MCP methods, and a 131,072-byte request-body limit. -OpenShell `0.0.106` evaluates that policy before replacing the attached provider placeholder in the allowed request header. -NemoClaw imports the endpointless `nemoclaw-mcp-v1` profile and binds the dedicated provider to that endpoint with `credential_binding.provider`. -OpenShell withholds the credential before the binding is active and outside the bound host, port, and path. -The sandbox client connects directly through OpenShell's existing egress path, and NemoClaw does not run a host-side MCP data-plane bridge, proxy, relay, or listener. -After the add commits, NemoClaw freshly verifies the generated policy, expected provider attachment, recorded provider ID, `nemoclaw-mcp-v1` type, valid resource version, and exactly one credential key matching the recorded key. -If those readiness checks pass, it sends a differential pair of wire-level MCP `initialize` requests from inside the sandbox — one with the placeholder header and one with an unresolvable control bearer — to verify that OpenShell resolves the credential on egress; otherwise it reports an inconclusive `probe skipped` result and sends no request. -Neither outcome fails the committed add, and `--no-probe` skips this check. -For full setup details, see [Add an MCP Server](../manage-sandboxes/mcp-servers/add-an-mcp-server). +Add an MCP Streamable HTTP server to a sandbox. Pass `--url` for the MCP endpoint and the required single `--env KEY` bearer credential for the sandbox-side MCP client. Pass a repeatable `--trusted-private-host ` option to admit an exact RFC1918, CGNAT, or IPv6 unique local destination for the current command. The declaration must equal the normalized host from `--url`. For managed MCP, use a DNS hostname for an IPv6 unique local address because NemoClaw has not qualified direct IPv6-literal MCP URLs. You can supply exact hosts through `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` instead, and NemoClaw combines the variable with command options. NemoClaw records the resulting exact trust intent and address pins, so restart, rebuild, and restore do not depend on the ambient environment. NemoClaw registers that credential in an OpenShell provider, installs a generated OpenShell `protocol: mcp` policy for the target endpoint, attaches the provider to the running sandbox, and writes only an OpenShell resolver placeholder for the recorded key into the agent configuration. Inline `--env KEY=VALUE` is rejected because it would expose the value in NemoClaw process arguments. Load the variable from a secret manager or masked prompt, export it without recording the value in shell history, and pass only `--env KEY`. All endpoints must use HTTPS. The full URL and path are persisted and displayed, so URLs cannot contain userinfo, query strings, fragments, known secret-shaped path material, percent-escaped or glob-style paths, or port zero. Server names must start with a letter and contain at most 64 letters, digits, hyphens, or underscores, and endpoint hostnames must use canonical lowercase DNS labels. NemoClaw rejects invalid names and endpoints before it writes lifecycle state or changes OpenShell resources. NemoClaw generates a narrow `protocol: mcp` policy for the destination, literal path, adapter binaries, pinned addresses, explicit MCP methods, and a 131,072-byte request-body limit. OpenShell `0.0.106` evaluates that policy before replacing the attached provider placeholder in the allowed request header. NemoClaw imports the endpointless `nemoclaw-mcp-v1` profile and binds the dedicated provider to that endpoint with `credential_binding.provider`. OpenShell withholds the credential before the binding is active and outside the bound host, port, and path. The sandbox client connects directly through OpenShell's existing egress path, and NemoClaw does not run a host-side MCP data-plane bridge, proxy, relay, or listener. After the add commits, NemoClaw freshly verifies the exact generated policy, expected provider attachment, recorded provider ID, `nemoclaw-mcp-v1` type, valid resource version, and exactly one credential key matching the recorded key. If those readiness checks pass, it sends a differential pair of wire-level MCP `initialize` requests from inside the sandbox — one with the placeholder header and one with an unresolvable control bearer — to verify that OpenShell resolves the credential on egress; otherwise it reports an inconclusive `probe skipped` result and sends no request. Neither outcome fails the committed add, and `--no-probe` skips this check. For full setup details, see [Add an MCP Server](../manage-sandboxes/mcp-servers/add-an-mcp-server). -Hermes MCP add, restart, and remove mutate managed config and are refused while shields are up. -Hermes includes the credential revision from the readiness check in the resolver placeholder and preserves it through inspection, rollback, and lifecycle reconciliation while OpenShell still reports it. -Run `$$nemoclaw shields down --timeout 15m --reason "MCP maintenance"` before the mutation, then run `$$nemoclaw shields up` after it; list and status remain read-only. -Allow at least 15 minutes per configured server for an all-server restart or destroy; rebuild opens its own crash-recoverable maintenance window. -Keep shields down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. +Hermes MCP add, restart, and remove mutate managed config and are refused while shields are up. Hermes includes the credential revision from the readiness check in the resolver placeholder and preserves it through inspection, rollback, and lifecycle reconciliation while OpenShell still reports it. Run `$$nemoclaw shields down --timeout 15m --reason "MCP maintenance"` before the mutation, then run `$$nemoclaw shields up` after it; list and status remain read-only. Allow at least 15 minutes per configured server for an all-server restart or destroy; rebuild opens its own crash-recoverable maintenance window. Keep shields down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. -Deep Agents MCP add and restart require the managed MCP v2 capability in the sandbox image. -If `mcp add` or `mcp restart` reports an older v1 runtime, run `nemo-deepagents rebuild` before retrying. -NemoClaw writes managed server definitions to `/sandbox/.deepagents/.nemoclaw-mcp.json`; user-owned `.mcp.json` files are not auto-loaded by the managed harness. +Deep Agents MCP add and restart require the managed MCP v2 capability in the sandbox image. If `mcp add` or `mcp restart` reports an older v1 runtime, run `nemo-deepagents rebuild` before retrying. NemoClaw writes managed server definitions to `/sandbox/.deepagents/.nemoclaw-mcp.json`; user-owned `.mcp.json` files are not auto-loaded by the managed harness. @@ -3355,45 +2179,22 @@ unset LOCAL_MCP_TOKEN ### `$$nemoclaw mcp status` -Inspect MCP server state for one server or for all configured servers. -Status includes OpenShell provider presence and credential-key shape, provider attachment, generated policy content match, adapter registration, current host-variable availability, and the selected agent's MCP support mode. -For a trusted private endpoint, status also compares current DNS answers with recorded pins without changing the policy. -Text output reports `private address pins: match`, `drift`, or `unresolved`. -JSON output reports the same value in `trustedPrivateTarget.state` and includes the recorded pins. -When a single server is named, status requests a differential wire-level credential-resolution probe. -It sends no probe traffic unless the generated policy equals the effective gateway policy, the expected provider attachment is confirmed, and the live provider has the recorded ID, `nemoclaw-mcp-v1` type, a valid resource version, and exactly one credential key matching the recorded key; a readiness failure reports `unknown` with a `probe skipped` detail. -When ready, the same MCP `initialize` is sent from inside the sandbox once with the recorded resolver placeholder header and once with a deliberately-unresolvable control bearer. -Classification uses the two HTTP status codes plus curl exit codes for transport, timeout, and policy-denial outcomes; response bodies are never captured or printed. -A `verified` verdict requires the placeholder request to be accepted (HTTP 2xx) while the control is rejected — the only outcome that proves a valid credential was on the wire. -Identical HTTP 400, 401, or 403 rejections raise a warning that names the hypotheses — the placeholder forwarded verbatim, an expired or revoked credential that resolved correctly, or (for HTTP 400) endpoint request validation — and tells you to verify the stored credential first. -For HTTP 401 or 403, a confirmed-valid credential means the host is not rewriting placeholders and agent runtimes receive the same auth failure and skip the server; HTTP 400 remains inconclusive because the endpoint may reject the probe request itself. -Every other outcome — differing rejections (an endpoint may reject two different literal bearers differently), endpoints that skip authentication, endpoint outages, policy denials, and unreachable sandboxes — reports as `unknown` rather than blaming the credential rewrite, and a persisted URL that fails the current authenticated-endpoint boundary is never probed. - -Pass `--tools` with one server name to request a live tool inventory. -The shared client runs through the managed registration's existing OpenShell credential provider and policy; OpenShell injects the credential at that boundary, and the runtime never accepts it as an argument, environment value, or authorization option. -It performs `initialize`, `notifications/initialized`, and paginated `tools/list`, then attempts to close the MCP session and transport. -Cleanup errors do not replace the bounded discovery result. -It retains and returns deterministic tool names only, never prints the other tool-definition fields returned by `tools/list`, and never calls a tool. -The operation is bounded by total and per-request timeouts plus response-byte, page, tool-count, cursor-length, and tool-name limits. - -Use `--tools` only with a configured endpoint you trust to advertise names while authenticated. -The endpoint controls its returned names and can derive them from the request or credential it receives; NemoClaw validates and bounds the text but cannot prove that the endpoint did not encode credential-derived data in an otherwise valid name. - -The `toolDiscovery` JSON field contains `ok`, `count`, `tools`, and `truncated`, plus a redacted `detail` on failure or a bounded partial result. -These names are the server's point-in-time advertised tools, not an attestation of the tools visible to the model after agent filters, progressive disclosure, or session state. -An older sandbox image without the shared client reports that the sandbox must be rebuilt. - -Tool discovery is opt-in and sends authenticated network traffic to the configured endpoint. -Passing `--tools` suppresses the named-server credential-resolution probe that otherwise runs by default. -Pass `--probe --tools` to request both checks explicitly. -An unsuccessful discovery does not remove the ordinary provider, policy, environment, or adapter status from the result. +Inspect MCP server state for one server or for all configured servers. Status includes OpenShell provider presence and credential-key shape, provider attachment, generated policy content match, adapter registration, current host-variable availability, and the selected agent's MCP support mode. For a trusted private endpoint, status also compares current DNS answers with recorded pins without changing the policy. Text output reports `private address pins: match`, `drift`, or `unresolved`. JSON output reports the same value in `trustedPrivateTarget.state` and includes the recorded pins. When a single server is named, status requests a differential wire-level credential-resolution probe. It sends no probe traffic unless the exact generated policy matches the effective gateway policy, the expected provider attachment is confirmed, and the live provider has the recorded ID, `nemoclaw-mcp-v1` type, a valid resource version, and exactly one credential key matching the recorded key; a readiness failure reports `unknown` with a `probe skipped` detail. When ready, the same MCP `initialize` is sent from inside the sandbox once with the recorded resolver placeholder header and once with a deliberately-unresolvable control bearer. Classification uses the two HTTP status codes plus curl exit codes for transport, timeout, and policy-denial outcomes; response bodies are never captured or printed. A `verified` verdict requires the placeholder request to be accepted (HTTP 2xx) while the control is rejected — the only outcome that proves a valid credential was on the wire. Identical HTTP 400, 401, or 403 rejections raise a warning that names the hypotheses — the placeholder forwarded verbatim, an expired or revoked credential that resolved correctly, or (for HTTP 400) endpoint request validation — and tells you to verify the stored credential first. For HTTP 401 or 403, a confirmed-valid credential means the host is not rewriting placeholders and agent runtimes receive the same auth failure and skip the server; HTTP 400 remains inconclusive because the endpoint may reject the probe request itself. Every other outcome — differing rejections (an endpoint may reject two different literal bearers differently), endpoints that skip authentication, endpoint outages, policy denials, and unreachable sandboxes — reports as `unknown` rather than blaming the credential rewrite, and a persisted URL that fails the current authenticated-endpoint boundary is never probed. + +Pass `--tools` with one server name to request a live tool inventory. The shared client runs through the managed registration's existing OpenShell credential provider and policy; OpenShell injects the credential at that boundary, and the runtime never accepts it as an argument, environment value, or authorization option. It performs `initialize`, `notifications/initialized`, and paginated `tools/list`, then attempts to close the MCP session and transport. Cleanup errors do not replace the bounded discovery result. It retains and returns deterministic tool names only, never prints the other tool-definition fields returned by `tools/list`, and never calls a tool. The operation is bounded by total and per-request timeouts plus response-byte, page, tool-count, cursor-length, and tool-name limits. + +Use `--tools` only with a configured endpoint you trust to advertise names while authenticated. The endpoint controls its returned names and can derive them from the request or credential it receives; NemoClaw validates and bounds the text but cannot prove that the endpoint did not encode credential-derived data in an otherwise valid name. + +The `toolDiscovery` JSON field contains `ok`, `count`, `tools`, and `truncated`, plus a redacted `detail` on failure or a bounded partial result. These names are the server's point-in-time advertised tools, not an attestation of the exact tools visible to the model after agent filters, progressive disclosure, or session state. An older sandbox image without the shared client reports that the sandbox must be rebuilt. + +Tool discovery is opt-in and sends authenticated network traffic to the configured endpoint. Passing `--tools` suppresses the named-server credential-resolution probe that otherwise runs by default. Pass `--probe --tools` to request both checks explicitly. An unsuccessful discovery does not remove the ordinary provider, policy, environment, or adapter status from the result. ```bash $$nemoclaw my-assistant mcp status [server] [--json] [--probe|--no-probe] [--tools] ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--json` | Emit status as JSON without credential values | | `--probe` | Request the wire-level credential-resolution probe for every listed server; entries that fail readiness checks are skipped | | `--no-probe` | Skip the probe; it defaults on only when a single server is named | @@ -3401,29 +2202,16 @@ $$nemoclaw my-assistant mcp status [server] [--json] [--probe|--no-probe] [--too ### `$$nemoclaw mcp restart` -Refresh one MCP server registration, or every server on the sandbox when no server is supplied. -Restart reapplies the generated policy, reattaches the OpenShell provider when needed, and refreshes the sandbox agent adapter registration. -For a trusted-private entry, restart replays recorded address pins without resolving the endpoint again or widening the policy. -For a public entry, restart resolves the hostname again and refreshes the policy with the current validated public addresses. -If the recorded host variable is exported, restart replaces the provider credential and waits for its new opaque revision. -Otherwise, restart reuses an existing provider whose current metadata match the registry. -A missing provider requires the variable to be exported before retrying. -An existing provider with the profile-less legacy `generic` type must be removed and added again with its credential exported. -OpenShell 0.0.106 cannot bind that provider to an MCP endpoint, so restart and rebuild fail closed instead of activating it. -When that provider is already absent but its name still blocks sandbox exec, -restart first detaches only the dangling sandbox-spec reference, then runs the -agent capability probe before changing a live provider or policy. +Refresh one MCP server registration, or every server on the sandbox when no server is supplied. Restart reapplies the generated policy, reattaches the OpenShell provider when needed, and refreshes the sandbox agent adapter registration. For a trusted-private entry, restart replays recorded address pins without resolving the endpoint again or widening the policy. For a public entry, restart resolves the hostname again and refreshes the policy with the current validated public addresses. If the recorded host variable is exported, restart replaces the provider credential and waits for its new opaque revision. Otherwise, restart reuses an existing provider whose current metadata match the registry. A missing provider requires the variable to be exported before retrying. An existing provider with the profile-less legacy `generic` type must be removed and added again with its credential exported. OpenShell 0.0.106 cannot bind that provider to an MCP endpoint, so restart and rebuild fail closed instead of activating it. When that provider is already absent but its name still blocks sandbox exec, restart first detaches only the dangling sandbox-spec reference, then runs the agent capability probe before changing a live provider or policy. -Hermes shields must be down for this config mutation. -Keep them down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. +Hermes shields must be down for this config mutation. Keep them down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. -Deep Agents restart refreshes the NemoClaw-managed `/sandbox/.deepagents/.nemoclaw-mcp.json` projection and validates the HTTPS-only server definitions before `dcode` sees them. -If the sandbox still uses the older v1 MCP projection, rebuild first so restart can use the v2 capability. +Deep Agents restart refreshes the NemoClaw-managed `/sandbox/.deepagents/.nemoclaw-mcp.json` projection and validates the HTTPS-only server definitions before `dcode` sees them. If the sandbox still uses the older v1 MCP projection, rebuild first so restart can use the v2 capability. @@ -3437,106 +2225,59 @@ Remove an MCP server from a sandbox. -Hermes shields must be down for this config mutation. -Keep them down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. +Hermes shields must be down for this config mutation. Keep them down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. -For an ordinary managed entry, NemoClaw unregisters the sandbox agent adapter, removes the owned generated policy, detaches and deletes the recorded OpenShell provider, and clears the sandbox registry entry. -For a stored legacy entry whose credential name is no longer accepted, it first detaches the provider so adapter cleanup cannot start with that credential attached. -Deep Agents teardown does not require managed MCP capability v2 from the old image. -For a v1 image, NemoClaw removes the registry-owned entry from the legacy `.mcp.json` while preserving unrelated user state; a replacement image must pass the v2 capability check before post-rebuild providers or policy are restored. -The command fails closed on observed drift. -`--force` may remove a modified same-name agent adapter entry, but provider deletion still requires the recorded ID and credential key plus an accepted managed provider type, and policy deletion still requires live policy content to equal the recorded owned content. -A legacy `generic` provider is accepted only for cleanup. -Residuals preserve registry state. -OpenShell `0.0.106` mutates providers by name, so do not concurrently replace a managed provider through another OpenShell client during this command. +For an ordinary managed entry, NemoClaw unregisters the sandbox agent adapter, removes the exact owned generated policy, detaches and deletes the recorded OpenShell provider, and clears the sandbox registry entry. For a stored legacy entry whose credential name is no longer accepted, it first detaches the exact provider so adapter cleanup cannot start with that credential attached. Deep Agents teardown does not require managed MCP capability v2 from the old image. For a v1 image, NemoClaw removes the exact registry-owned entry from the legacy `.mcp.json` while preserving unrelated user state; a replacement image must pass the v2 capability check before post-rebuild providers or policy are restored. The command fails closed on observed drift. `--force` may remove a modified same-name agent adapter entry, but provider deletion still requires the recorded ID and credential key plus an accepted managed provider type, and policy deletion still requires exact owned content. An exact legacy `generic` provider is accepted only for cleanup. Residuals preserve registry state. OpenShell `0.0.106` mutates providers by name, so do not concurrently replace a managed provider through another OpenShell client during this command. -When an interrupted destroy leaves a prepared-only transaction, deletion is not durably confirmed. -If the sandbox is still live, run `$$nemoclaw mcp remove --force` with the affected server name. -NemoClaw clears the prepared marker only after cleanup succeeds without residuals and no bridge entries remain. -A failed cleanup, a wrong server name, residual resources, or any remaining bridge entry preserves the marker for another retry. +When an interrupted destroy leaves a prepared-only transaction, deletion is not durably confirmed. If the sandbox is still live, run `$$nemoclaw mcp remove --force` with the affected server name. NemoClaw clears the prepared marker only after cleanup succeeds without residuals and no bridge entries remain. A failed cleanup, a wrong server name, residual resources, or any remaining bridge entry preserves the marker for another retry. -A pending marker, including a transaction with both prepared and pending markers, means the registry records that OpenShell deletion was already confirmed. -`mcp remove --force` refuses this state. -Run `$$nemoclaw destroy` to finish the idempotent provider and policy cleanup. +A pending marker, including a transaction with both prepared and pending markers, means the registry records that OpenShell deletion was already confirmed. `mcp remove --force` refuses this state. Run `$$nemoclaw destroy` to finish the idempotent provider and policy cleanup. ```bash $$nemoclaw my-assistant mcp remove github [--force] ``` | Flag | Description | -|------|-------------| -| `--force` | Remove same-name adapter config and continue ownership provider and policy cleanup. For a prepared-only destroy, attempt recovery when the sandbox is still live and clear the marker only after residual-free cleanup drains every bridge entry. | +| --- | --- | +| `--force` | Remove same-name adapter config and continue exact-ownership provider and policy cleanup. For a prepared-only destroy, attempt recovery when the sandbox is still live and clear the marker only after residual-free cleanup drains every bridge entry. | ### `$$nemoclaw skill install ` -Deploy a skill directory to a running sandbox. -The command validates the `SKILL.md` frontmatter, which requires a `name` field. -It uploads selected non-dot regular files while preserving their subdirectory structure. -It then performs agent-specific post-install steps. +Deploy a skill directory to a running sandbox. The command validates the `SKILL.md` frontmatter, which requires a `name` field. It uploads selected non-dot regular files while preserving their subdirectory structure. It then performs agent-specific post-install steps. ```bash $$nemoclaw my-assistant skill install ./my-skill/ ``` -The skill directory must contain a `SKILL.md` file with YAML frontmatter that includes a `name` field. -Skill names must contain only alphanumeric characters, dots, hyphens, and underscores. +The skill directory must contain a `SKILL.md` file with YAML frontmatter that includes a `name` field. Skill names must contain only alphanumeric characters, dots, hyphens, and underscores. -OpenClaw plugins are a different kind of extension. -To install an OpenClaw plugin, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). -For OpenClaw, the command uploads the skill to the OpenClaw state directory and mirrors it into `$HOME/.openclaw/skills/` when the agent home directory differs from the state directory. -That mirror makes skills listed by `openclaw skills list` available at session startup. -If mirror creation fails, NemoClaw prints a warning so you can reinstall or inspect the home directory permissions. -OpenClaw caches skill content per session, so the command also refreshes the OpenClaw session index after every install and update to avoid stale `SKILL.md` data. +OpenClaw plugins are a different kind of extension. To install an OpenClaw plugin, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). For OpenClaw, the command uploads the skill to the OpenClaw state directory and mirrors it into `$HOME/.openclaw/skills/` when the agent home directory differs from the state directory. That mirror makes skills listed by `openclaw skills list` available at session startup. If mirror creation fails, NemoClaw prints a warning so you can reinstall or inspect the home directory permissions. OpenClaw caches skill content per session, so the command also refreshes the OpenClaw session index after every install and update to avoid stale `SKILL.md` data. -Hermes plugins are different from NemoClaw skills. -`skill install` uploads agent skills, while Hermes plugin configuration is managed by the Hermes runtime and the NemoClaw Hermes plugin baked into the sandbox image. -The NemoClaw Hermes plugin reloads installed skills when a new chat session starts. -Start a new Hermes chat session after an install or update; a gateway restart is not required. +Hermes plugins are different from NemoClaw skills. `skill install` uploads agent skills, while Hermes plugin configuration is managed by the Hermes runtime and the NemoClaw Hermes plugin baked into the sandbox image. The NemoClaw Hermes plugin reloads installed skills when a new chat session starts. Start a new Hermes chat session after an install or update; a gateway restart is not required. -For Deep Agents, the command installs a fresh skill directly into `/sandbox/.deepagents/agent/skills/`, the directory Deep Agents Code loads at session start. -Before upload, NemoClaw copies each selected regular file into a private host snapshot and rejects a path that changes identity during the copy. -It creates the archive from that snapshot and records each path, normalized mode, and SHA-256 digest. -It rejects symlinks and special files. -Inside the sandbox, it stages the archive and verifies that its paths, normalized modes, and SHA-256 digests match the host snapshot. -It then moves the staged directory into place only if the destination is still absent. -On success, the command prints the content digest that the sandbox confirmed: one SHA-256 digest over the recorded paths, normalized modes, and file digests. -Record that value to compare it with the digest printed by a later install of the same skill directory. -Deep Agents Code and its built-in skill creator also write to this directory. -The command therefore refuses any name whose file, directory, or symlink already exists. -Updates are not automatic. -Use `$$nemoclaw connect` to inspect the existing directory. -Update it manually only after confirming ownership. -The legacy `/sandbox/.deepagents/skills/` path is not written or treated as ownership proof. -The managed `dcode` launchers discover newly installed skills on the next session without accepting executable hook configuration. -Installation does not enable project hooks or unmanaged MCP files. +For Deep Agents, the command installs a fresh skill directly into `/sandbox/.deepagents/agent/skills/`, the directory Deep Agents Code loads at session start. Before upload, NemoClaw copies each selected regular file into a private host snapshot and rejects a path that changes identity during the copy. It creates the archive from that snapshot and records each path, normalized mode, and SHA-256 digest. It rejects symlinks and special files. Inside the sandbox, it stages the archive and verifies that its paths, normalized modes, and SHA-256 digests match the host snapshot. It then moves the staged directory into place only if the destination is still absent. On success, the command prints the content digest that the sandbox confirmed: one SHA-256 digest over the recorded paths, normalized modes, and file digests. Record that value to compare it with the digest printed by a later install of the same skill directory. Deep Agents Code and its built-in skill creator also write to this directory. The command therefore refuses any name whose file, directory, or symlink already exists. Updates are not automatic. Use `$$nemoclaw connect` to inspect the existing directory. Update it manually only after confirming ownership. The legacy `/sandbox/.deepagents/skills/` path is not written or treated as ownership proof. The managed `dcode` launchers discover newly installed skills on the next session without accepting executable hook configuration. Installation does not enable project hooks or unmanaged MCP files. -Run `$$nemoclaw skill install --help` to print usage for this subcommand. -If you pass a plugin-shaped directory to `skill install`, the CLI prints a plugin-specific hint instead of treating it as a missing skill file. +Run `$$nemoclaw skill install --help` to print usage for this subcommand. If you pass a plugin-shaped directory to `skill install`, the CLI prints a plugin-specific hint instead of treating it as a missing skill file. -Files with names starting with `.` (dotfiles) are skipped and listed in the output. -Files with unsafe path characters are rejected to prevent shell injection. -Symlinks and other non-regular paths are rejected rather than followed or copied. +Files with names starting with `.` (dotfiles) are skipped and listed in the output. Files with unsafe path characters are rejected to prevent shell injection. Symlinks and other non-regular paths are rejected rather than followed or copied. -For OpenClaw and Hermes, an existing sandbox skill is updated in place and chat history is preserved. -Deep Agents supports only fresh-name installs because its active skill directory is shared with agent-authored content. -Follow the agent-specific activation guidance above after installation. +For OpenClaw and Hermes, an existing sandbox skill is updated in place and chat history is preserved. Deep Agents supports only fresh-name installs because its active skill directory is shared with agent-authored content. Follow the agent-specific activation guidance above after installation. ### `$$nemoclaw skill remove ` -Remove an installed skill from a running sandbox by skill name when the selected agent supports automatic removal. -The command validates the skill name before it applies the agent-specific removal behavior below. +Remove an installed skill from a running sandbox by skill name when the selected agent supports automatic removal. The command validates the skill name before it applies the agent-specific removal behavior below. @@ -3545,16 +2286,12 @@ For OpenClaw, the command also removes the OpenClaw home-directory mirror when p -Start a new Hermes chat session for the removal to take effect. -A gateway restart is not required. +Start a new Hermes chat session for the removal to take effect. A gateway restart is not required. -For Deep Agents, automatic removal is refused before any sandbox files change. -The active `/sandbox/.deepagents/agent/skills/` directory is shared with agent-authored content, so its presence alone cannot prove NemoClaw owns it. -Use `$$nemoclaw connect` to inspect the existing directory. -Remove it manually only after confirming ownership. +For Deep Agents, automatic removal is refused before any sandbox files change. The active `/sandbox/.deepagents/agent/skills/` directory is shared with agent-authored content, so its presence alone cannot prove NemoClaw owns it. Use `$$nemoclaw connect` to inspect the existing directory. Remove it manually only after confirming ownership. @@ -3562,16 +2299,13 @@ Remove it manually only after confirming ownership. $$nemoclaw my-assistant skill remove my-skill ``` -Use the skill name from the `SKILL.md` frontmatter, not the local directory name. -Skill names must contain only alphanumeric characters, dots, hyphens, and underscores, and cannot be `.` or `..`. +Use the skill name from the `SKILL.md` frontmatter, not the local directory name. Skill names must contain only alphanumeric characters, dots, hyphens, and underscores, and cannot be `.` or `..`. ### `$$nemoclaw agents list` -List the OpenClaw agents configured in the sandbox. -This is a thin pass-through to `openclaw agents list` via `openshell sandbox exec`; the OpenClaw CLI owns the gateway `agents.list` call, output formatting, and binding summaries. -Flags accepted by the in-sandbox CLI (`--json`, `--bindings`) are forwarded verbatim. +List the OpenClaw agents configured in the sandbox. This is a thin pass-through to `openclaw agents list` via `openshell sandbox exec`; the OpenClaw CLI owns the gateway `agents.list` call, output formatting, and binding summaries. Flags accepted by the in-sandbox CLI (`--json`, `--bindings`) are forwarded verbatim. ```bash $$nemoclaw my-assistant agents list @@ -3581,8 +2315,7 @@ $$nemoclaw my-assistant agents list --bindings ### `$$nemoclaw agents add` -Run the OpenClaw interactive add wizard inside the sandbox. -This is a thin pass-through to `openclaw agents add` via `openshell sandbox exec`; flags accepted by the in-sandbox CLI are forwarded verbatim. +Run the OpenClaw interactive add wizard inside the sandbox. This is a thin pass-through to `openclaw agents add` via `openshell sandbox exec`; flags accepted by the in-sandbox CLI are forwarded verbatim. ```bash $$nemoclaw my-assistant agents add @@ -3591,9 +2324,7 @@ $$nemoclaw my-assistant agents add work --model gpt-4o ### `$$nemoclaw agents delete ` -Remove an OpenClaw agent from the sandbox. -This is a thin pass-through to `openclaw agents delete ` via `openshell sandbox exec`; the OpenClaw CLI owns gateway dispatch (`agents.delete`), host-side workspace removal, and config edits. -Flags accepted by the in-sandbox CLI (`--force`, `--json`) are forwarded verbatim. +Remove an OpenClaw agent from the sandbox. This is a thin pass-through to `openclaw agents delete ` via `openshell sandbox exec`; the OpenClaw CLI owns gateway dispatch (`agents.delete`), host-side workspace removal, and config edits. Flags accepted by the in-sandbox CLI (`--force`, `--json`) are forwarded verbatim. ```bash $$nemoclaw my-assistant agents delete work @@ -3602,11 +2333,7 @@ $$nemoclaw my-assistant agents delete work --force --json ### `$$nemoclaw agents apply` -Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../configure-agents/declarative-agents-manifest). -The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. -Per-agent `model`, `subagents.*`, `tools`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `$$nemoclaw onboard --agents --recreate-sandbox` to bake those fields. -When the diff removes orphan agents, NemoClaw invokes OpenClaw's confirmation-skipping delete mode internally. -`--non-interactive` controls the host-side `agents apply` prompt and is not forwarded to OpenClaw's delete command. +Reconcile the live sandbox roster against a declarative [agents.yaml manifest](../configure-agents/declarative-agents-manifest). The verb lists current agents via `openclaw agents list --json`, diffs them against the manifest, and adds missing secondaries or deletes orphan ones through `openclaw agents add|delete`. Per-agent `model`, `subagents.*`, `tools`, top-level `defaults`, and `main` overrides need a sandbox rebuild and are surfaced as warnings rather than silently dropped; rerun `$$nemoclaw onboard --agents --recreate-sandbox` to bake those fields. When the diff removes orphan agents, NemoClaw invokes OpenClaw's confirmation-skipping delete mode internally. `--non-interactive` controls the host-side `agents apply` prompt and is not forwarded to OpenClaw's delete command. ```bash $$nemoclaw my-assistant agents apply -f ./agents.yaml @@ -3622,9 +2349,7 @@ Pass `-f` / `--file ` to point at the manifest; `--yes` confirms th ### `$$nemoclaw sessions` -List OpenClaw conversation sessions in the sandbox. -With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent. -NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output. +List OpenClaw conversation sessions in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent. NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output. ```bash $$nemoclaw my-assistant sessions @@ -3636,8 +2361,7 @@ $$nemoclaw my-assistant sessions --all-agents --json ### `$$nemoclaw sessions` -List Hermes conversation sessions in the sandbox. -NemoClaw invokes `hermes sessions list` via `openshell sandbox exec`, forwards native Hermes flags such as `--source` and `--limit`, and streams the output unchanged. +List Hermes conversation sessions in the sandbox. NemoClaw invokes `hermes sessions list` via `openshell sandbox exec`, forwards native Hermes flags such as `--source` and `--limit`, and streams the output unchanged. ```bash $$nemoclaw my-assistant sessions @@ -3650,8 +2374,7 @@ $$nemoclaw my-assistant sessions --source cli --limit 20 ### `$$nemoclaw sessions list` -Invoke `openclaw sessions list` inside the sandbox. -NemoClaw forwards every flag the in-sandbox CLI accepts (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`) and filters the resulting default table or JSON so internal `nemoclaw-onboard-warmup-*` sessions are hidden. +Invoke `openclaw sessions list` inside the sandbox. NemoClaw forwards every flag the in-sandbox CLI accepts (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`) and filters the resulting default table or JSON so internal `nemoclaw-onboard-warmup-*` sessions are hidden. ```bash $$nemoclaw my-assistant sessions list @@ -3663,8 +2386,7 @@ $$nemoclaw my-assistant sessions list --agent main --json ### `$$nemoclaw sessions list` -Invoke `hermes sessions list` inside the sandbox. -NemoClaw forwards native Hermes flags such as `--source` and `--limit` and streams the output unchanged. +Invoke `hermes sessions list` inside the sandbox. NemoClaw forwards native Hermes flags such as `--source` and `--limit` and streams the output unchanged. ```bash $$nemoclaw my-assistant sessions list @@ -3673,9 +2395,7 @@ $$nemoclaw my-assistant sessions list --source cli --limit 20 ### `$$nemoclaw sessions delete ` -Invoke `hermes sessions delete --yes` inside the sandbox to remove a session from the Hermes store. -Pass a native Hermes session id from `sessions list` (for example `20260727_130357_cb2b61`). -The OpenClaw-only `--agent`, `--keep-transcript`, `--json`, and `--verbose` flags are not supported on a Hermes sandbox. +Invoke `hermes sessions delete --yes` inside the sandbox to remove a session from the Hermes store. Pass a native Hermes session id from `sessions list` (for example `20260727_130357_cb2b61`). The OpenClaw-only `--agent`, `--keep-transcript`, `--json`, and `--verbose` flags are not supported on a Hermes sandbox. ```bash $$nemoclaw my-assistant sessions delete 20260727_130357_cb2b61 @@ -3687,8 +2407,7 @@ $$nemoclaw my-assistant sessions delete 20260727_130357_cb2b61 ### `$$nemoclaw sessions reset ` -Archive a session and rebind its key to a fresh `sessionId` by invoking the OpenClaw gateway `sessions.reset` RPC inside the sandbox. -Goes through `openshell sandbox exec` -> `openclaw gateway call sessions.reset`, so the gateway owns archival, lock handling, and lifecycle events; the host never edits `sessions.json` directly. +Archive a session and rebind its key to a fresh `sessionId` by invoking the OpenClaw gateway `sessions.reset` RPC inside the sandbox. Goes through `openshell sandbox exec` -> `openclaw gateway call sessions.reset`, so the gateway owns archival, lock handling, and lifecycle events; the host never edits `sessions.json` directly. ```bash $$nemoclaw my-assistant sessions reset main @@ -3698,20 +2417,17 @@ $$nemoclaw my-assistant sessions reset agent:main:main --json ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--agent ` | Agent id when `` is an alias rather than the canonical `agent::` form. | | `--reason new\|reset` | `reset` (default) archives the prior transcript; `new` rebinds without preserving the archive trail. | | `--json` | Print the reset result as JSON. | | `--verbose` | Print the gateway entry payload after a successful reset. | -The `` argument accepts an alias (e.g. `main`, `telegram:t-1`) or the canonical `agent::` form. -Mismatched `--agent` plus canonical-key combinations are refused before the gateway is invoked. +The `` argument accepts an alias (e.g. `main`, `telegram:t-1`) or the canonical `agent::` form. Mismatched `--agent` plus canonical-key combinations are refused before the gateway is invoked. ### `$$nemoclaw sessions delete ` -Remove a session entry by invoking the OpenClaw gateway `sessions.delete` RPC inside the sandbox. -The gateway refuses to remove the agent's main session. -The transcript on disk is removed by default; pass `--keep-transcript` to retain it. +Remove a session entry by invoking the OpenClaw gateway `sessions.delete` RPC inside the sandbox. The gateway refuses to remove the agent's main session. The transcript on disk is removed by default; pass `--keep-transcript` to retain it. ```bash $$nemoclaw my-assistant sessions delete telegram:t-1 @@ -3721,7 +2437,7 @@ $$nemoclaw my-assistant sessions delete agent:main:slack:c-9 --json ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--agent ` | Agent id when `` is an alias rather than the canonical `agent::` form. | | `--keep-transcript` | Retain the session transcript on disk after the entry is removed. | | `--json` | Print the delete result as JSON. | @@ -3733,13 +2449,7 @@ $$nemoclaw my-assistant sessions delete agent:main:slack:c-9 --json ### `$$nemoclaw sessions export [keys...]` -Export an OpenClaw sandbox's session history from the running sandbox to the host. -The command enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` files, plus optional `.trajectory.jsonl` files. -It never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. -By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload. -With no positional keys, the command exports every non-internal session for the agent; if only internal warm-up sessions exist, the command reports that there are no sessions to bundle and writes no artifact. -Internal `nemoclaw-onboard-warmup-*` sessions are excluded from export-all output, but passing an explicit warm-up session key still exports that session for debugging. -Pass one or more keys, as aliases or canonical `agent::` keys, to filter. +Export an OpenClaw sandbox's session history from the running sandbox to the host. The command enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` files, plus optional `.trajectory.jsonl` files. It never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload. With no positional keys, the command exports every non-internal session for the agent; if only internal warm-up sessions exist, the command reports that there are no sessions to bundle and writes no artifact. Internal `nemoclaw-onboard-warmup-*` sessions are excluded from export-all output, but passing an explicit warm-up session key still exports that session for debugging. Pass one or more keys, as aliases or canonical `agent::` keys, to filter. ```bash $$nemoclaw my-assistant sessions export @@ -3749,20 +2459,14 @@ $$nemoclaw my-assistant sessions export --format tar --out ./bundles/alpha.tgz - ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--agent ` | Agent id when `` are aliases rather than the canonical `agent::` form. | | `--format ` | `dir` (default) writes a directory of session files; `tar` writes a single `.tgz` bundle for sharing/upload. | | `--out ` | Host destination. Defaults to `./sessions-/` for `dir` or `./sessions--.tgz` for `tar`. | | `--include-trajectory` | Include the large `*.trajectory.jsonl` files in the export. Excluded by default. | | `--json` | Print the export manifest as JSON instead of a status line. | -Mismatched `--agent` plus canonical-key combinations are refused before any download runs. -Session keys that begin with `-` are rejected at the command boundary instead of being silently dropped. -Session JSONL can contain pasted secrets, such as API keys or tokens, so exported files are written owner-only (`0600`). -The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. -If the staging cleanup fails, the command warns with the retained path and a manual removal command. -The retained artifact can contain session JSONL with pasted secrets. -Run the removal command from the warning, then inspect that same retained path to confirm it no longer exists. +Mismatched `--agent` plus canonical-key combinations are refused before any download runs. Session keys that begin with `-` are rejected at the command boundary instead of being silently dropped. Session JSONL can contain pasted secrets, such as API keys or tokens, so exported files are written owner-only (`0600`). The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. If the staging cleanup fails, the command warns with the retained path and a manual removal command. The retained artifact can contain session JSONL with pasted secrets. Run the exact removal command from the warning, then inspect that same retained path to confirm it no longer exists. The export keeps its original success or failure result, so a cleanup warning after a successful download does not make the export fail. @@ -3771,11 +2475,7 @@ The export keeps its original success or failure result, so a cleanup warning af ### `$$nemoclaw sessions export` -Export a Hermes sandbox's session history from the running sandbox to the host. -The command invokes the in-sandbox `hermes sessions export` against a staging path under `/sandbox/.nemoclaw-staging`, then downloads the resulting single JSONL stream to the host. -Hermes stores session history in a SQLite database, so the command refuses positional keys, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. -`--agent` accepts only `hermes` as a no-op alias on a Hermes sandbox and rejects any other value. -The host destination defaults to `./sessions-.jsonl`; `--out` picks a different path. +Export a Hermes sandbox's session history from the running sandbox to the host. The command invokes the in-sandbox `hermes sessions export` against a staging path under `/sandbox/.nemoclaw-staging`, then downloads the resulting single JSONL stream to the host. Hermes stores session history in a SQLite database, so the command refuses positional keys, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. `--agent` accepts only `hermes` as a no-op alias on a Hermes sandbox and rejects any other value. The host destination defaults to `./sessions-.jsonl`; `--out` picks a different path. ```bash $$nemoclaw my-assistant sessions export @@ -3784,16 +2484,12 @@ $$nemoclaw my-assistant sessions export --json ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--agent hermes` | Optional no-op alias accepted only on a Hermes sandbox. Any other value is rejected. | | `--out ` | Host destination. Defaults to `./sessions-.jsonl`. | | `--json` | Print the export manifest as JSON instead of a status line. | -Session JSONL can contain pasted secrets, such as API keys or tokens, so exported files are written owner-only (`0600`). -The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. -If the staging cleanup fails, the command warns with the retained path and a manual removal command. -The retained artifact can contain session JSONL with pasted secrets. -Run the removal command from the warning, then inspect that same retained path to confirm it no longer exists. +Session JSONL can contain pasted secrets, such as API keys or tokens, so exported files are written owner-only (`0600`). The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. If the staging cleanup fails, the command warns with the retained path and a manual removal command. The retained artifact can contain session JSONL with pasted secrets. Run the exact removal command from the warning, then inspect that same retained path to confirm it no longer exists. The export keeps its original success or failure result, so a cleanup warning after a successful download does not make the export fail. @@ -3801,17 +2497,7 @@ The export keeps its original success or failure result, so a cleanup warning af ### `$$nemoclaw download [host-dest]` -Host-side wrapper around `openshell sandbox download` that checks the live sandbox. -The command confirms before and after transfer that the source remains a file or directory. -Symbolic links, source-type changes, and other special source types are refused. -If the command cannot confirm the source type, it exits without publishing. -The command downloads to a fresh private temporary directory on the host, verifies that OpenShell wrote an artifact, publishes the artifact to your destination, and removes the temporary directory. -An existing destination directory is resolved to its canonical path before publication. -The command refuses an existing file destination that is a symbolic link and a new destination below a symbolic-link parent. -Regular files are published through a private temporary entry and atomically replace an existing regular file. -Relative host destinations resolve against the caller's working directory. -Absolute host destinations do not use caller-working-directory resolution. -With no `host-dest` the destination defaults to the current directory. +Host-side wrapper around `openshell sandbox download` that checks the live sandbox. The command confirms before and after transfer that the source remains a file or directory. Symbolic links, source-type changes, and other special source types are refused. If the command cannot confirm the source type, it exits without publishing. The command downloads to a fresh private temporary directory on the host, verifies that OpenShell wrote an artifact, publishes the artifact to your destination, and removes the temporary directory. An existing destination directory is resolved to its canonical path before publication. The command refuses an existing file destination that is a symbolic link and a new destination below a symbolic-link parent. Regular files are published through a private temporary entry and atomically replace an existing regular file. Relative host destinations resolve against the caller's working directory. Absolute host destinations do not use caller-working-directory resolution. With no `host-dest` the destination defaults to the current directory. @@ -3840,8 +2526,7 @@ $$nemoclaw my-assistant download /sandbox/.deepagents/.state/ ./deepagents-state ### `$$nemoclaw upload [sandbox-dest]` -Host-side wrapper around `openshell sandbox upload`, symmetric to the download wrapper. -With no `sandbox-dest` the destination defaults to `/sandbox/` inside the sandbox. +Host-side wrapper around `openshell sandbox upload`, symmetric to the download wrapper. With no `sandbox-dest` the destination defaults to `/sandbox/` inside the sandbox. @@ -3870,31 +2555,14 @@ $$nemoclaw my-assistant upload ./agent-skills/ /sandbox/.deepagents/agent/skills ### `$$nemoclaw rebuild` -Upgrade a sandbox to the current agent version while preserving workspace state. -The command backs up workspace state, destroys the old sandbox (including its host-side Docker image), recreates it with the current image via `onboard --resume`, and restores workspace state into the new sandbox. -Credentials are stripped from backups before storage. -Policy presets applied to the old sandbox are reapplied to the new one so your egress rules survive the rebuild. -Before creating the replacement sandbox, NemoClaw prints the finalized create-time policy scope whenever presets are included. -The replacement uses the recorded compatible-endpoint reasoning mode, reasoning effort, and web search selection instead of ambient shell values. -When same-gateway legacy sandbox records use the selected supported provider but omit its credential environment-variable name, rebuild fills only those missing names from the provider's canonical configuration. -The target update and peer metadata migration use one registry update. -Conflicting credential environment-variable names, custom endpoints, or API families still stop the rebuild. -Incomplete routes and invalid gateway bindings also stop the rebuild. -NemoClaw checks the shared route again immediately before deleting the original sandbox. -Rebuild preserves the recorded sandbox GPU enablement mode and, for an explicitly enabled sandbox, its recorded device selector. -It re-resolves the Docker-driver GPU route from the current host and current `NEMOCLAW_DOCKER_GPU_PATCH` value, so native-only, explicitly authorized native-with-fallback, and compatibility-only routing may differ from the original onboarding run. -A rebuild preserves the recorded tool-disclosure mode unless `--tool-disclosure` explicitly changes it; it ignores an ambient `NEMOCLAW_TOOL_DISCLOSURE` value while recreating the sandbox. -A rebuild preserves the recorded Deep Agents Code observability choice and matching local OTLP policy state unless `--observability` or `--no-observability` explicitly changes them. -A rebuild preserves the recorded Deep Agents Code auto-approval capability unless `--dcode-auto-approval` explicitly changes it. -A sandbox onboarded with an explicit GPU opt-out (stored as `sandboxGpuMode: "0"`, plus legacy registry entries that only record `gpuEnabled: false`) is recreated with the same opt-out, so the inner `onboard --resume` skips the Docker CDI GPU preflight on hosts without an NVIDIA GPU. -Auto-mode sandboxes remain auto. +Upgrade a sandbox to the current agent version while preserving workspace state. The command backs up workspace state, captures the current OpenShell policy into a private temporary handoff, destroys the old sandbox, recreates it with the current image, and restores workspace state. Credentials are stripped from backups before storage. The replacement receives the captured OpenShell document directly; NemoClaw does not reconstruct policy from preset records. The replacement uses the recorded compatible-endpoint reasoning mode, reasoning effort, and web search selection instead of ambient shell values. When same-gateway legacy sandbox records use the selected supported provider but omit its credential environment-variable name, rebuild fills only those missing names from the provider's canonical configuration. The target update and peer metadata migration use one registry update. Conflicting credential environment-variable names, custom endpoints, or API families still stop the rebuild. Incomplete routes and invalid gateway bindings also stop the rebuild. NemoClaw checks the shared route again immediately before deleting the original sandbox. Rebuild preserves the recorded sandbox GPU enablement mode and, for an explicitly enabled sandbox, its recorded device selector. It re-resolves the Docker-driver GPU route from the current host and current `NEMOCLAW_DOCKER_GPU_PATCH` value, so native-only, explicitly authorized native-with-fallback, and compatibility-only routing may differ from the original onboarding run. A rebuild preserves the recorded tool-disclosure mode unless `--tool-disclosure` explicitly changes it; it ignores an ambient `NEMOCLAW_TOOL_DISCLOSURE` value while recreating the sandbox. A rebuild preserves the recorded Deep Agents Code observability choice and matching local OTLP policy state unless `--observability` or `--no-observability` explicitly changes them. A rebuild preserves the recorded Deep Agents Code auto-approval capability unless `--dcode-auto-approval` explicitly changes it. A sandbox onboarded with an explicit GPU opt-out (stored as `sandboxGpuMode: "0"`, plus legacy registry entries that only record `gpuEnabled: false`) is recreated with the same opt-out, so the inner `onboard --resume` skips the Docker CDI GPU preflight on hosts without an NVIDIA GPU. Auto-mode sandboxes remain auto. ```bash $$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclosure ] [--dcode-auto-approval ] [--observability|--no-observability] ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--yes`, `-y` | Skip the confirmation prompt. | | `--force` | Skip the confirmation prompt and continue when no state directory was preserved or a manifest-declared state file failed. NemoClaw restores any captured entries; after a total failure, it recreates from registry metadata only. If a pre-mutation no-op cannot execute in a sandbox with managed MCP servers, it may preserve the registered MCP intent through host-side recovery. | | `--verbose`, `-v` | Log SSH commands, exit codes, and session state (also enabled by `NEMOCLAW_REBUILD_VERBOSE=1`) | @@ -3902,50 +2570,9 @@ $$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclo | `--dcode-auto-approval ` | Change the managed Deep Agents Code thread auto-approval capability. `thread-opt-in` is accepted only for managed Deep Agents Code sandboxes and is rejected for other agents or custom images. Enabling prints a warning, and either value requires sandbox recreation. | | `--observability`, `--no-observability` | Enable or disable managed trace export for a LangChain Deep Agents Code sandbox during the transactional rebuild. This path preserves managed MCP providers and adapter state. | -If another terminal has an active SSH session to the sandbox, `rebuild` prints an active-session warning and requires confirmation before destroying the sandbox. -Pass `--yes`, `-y`, or `--force` to skip the prompt in scripted workflows. - -The sandbox normally must be reachable for the backup step to succeed. -If an archive command preserves at least one state directory, `rebuild` keeps the captured backup entries and reports the manifest-defined paths that could not be archived. -If a manifest-declared state file fails, `rebuild` exits before destroying the original sandbox even when it preserved state directories, unless you explicitly pass `--force`. -If every state directory fails, `rebuild` exits before destroying the original sandbox even when it captured loose files, unless you explicitly pass `--force`. -With `--force`, NemoClaw preserves any captured loose files in the partial manifest and restores them after recreation. -If the backup produced nothing usable, it continues from recorded registry metadata without restoring prior sandbox state. -Use this recovery path only when losing the state that could not be backed up is acceptable. -For a sandbox with managed MCP servers, `--force` probes sandbox execution before MCP teardown. -If that no-op cannot run, NemoClaw requires complete bridge entries and live policy and provider identities, without trying an in-sandbox adapter scrub or changing MCP ownership state. -Each bridge must record the adapter for the sandbox's recorded agent. -The registered policy must match the policy NemoClaw generates for that adapter, server name, endpoint URL, and resolved addresses. -It rechecks the registry, recorded gateway, resolved targets, live generated policies, and provider identities immediately before deletion; incomplete adds, drift, or ambiguous ownership stop before deletion. -NemoClaw sends the delete request and every deletion-confirmation lookup to the sandbox's recorded gateway. -Across every rebuild path, NemoClaw does not attempt to stop local NIM until sandbox deletion is positively confirmed, then attempts NIM cleanup on a best-effort basis. -When `openshell sandbox delete` exits nonzero, a recorded-gateway lookup distinguishes explicit absence from a confirmed `Ready` or `Running` sandbox. -Any other phase or probe failure is ambiguous. -Explicit absence continues the rebuild. -Confirmed intact state triggers an attempt to restore prepared MCP state and any shields lockdown that rebuild temporarily opened. -NemoClaw reports any MCP or shields restoration failure and does not present the operation as a successful rollback. -Ambiguous state preserves MCP ownership and recovery metadata without attempting to stop NIM or claiming the original sandbox remains intact, and the rebuild process skips its immediate shields relock. -Failures after a successful exec probe do not switch to the host-side path. -Before backup or deletion, `rebuild` also refuses an incomplete MCP destroy transaction. -It also refuses a pending baseline exclusion transaction before opening a shields-down window, starting backup, or deleting the sandbox, and prints the `policy exclude` or `policy restore` command to rerun. -For a prepared-only transaction, the redacted diagnostic points to `$$nemoclaw mcp remove --force` when the sandbox is still live. -For a pending or both-marker transaction, it points to `$$nemoclaw destroy` because the registry records that OpenShell deletion was already confirmed. -Before backup or deletion, rebuild checks the staged messaging configuration against other sandboxes in the selected OpenShell gateway's sandbox registry. -A rebuild cannot detect messaging conflicts in an independent OpenShell gateway's registry. -A conflict aborts with the original sandbox registered and intact so you can resolve the conflict before retrying. -After OpenShell accepts the sandbox deletion, `rebuild` waits until OpenShell explicitly reports that the old sandbox is absent. -Only then can NemoClaw perform any required local registry removal and begin creating the replacement. -If OpenShell does not confirm absence within the bounded wait, including when gateway transport errors block the probes, `rebuild` exits nonzero before registry removal or replacement creation and preserves both the local registry entry and the state backup. -Restore OpenShell connectivity and confirm the sandbox's live state before you retry, and keep the printed backup path for recovery. -Before deletion, rebuild records a replacement journal that binds the operation to the recorded gateway, source identity, and target settings. -Rerunning the same rebuild continues from the recorded boundary or accepts the proven replacement instead of deleting it again. -A mount-free journal written before host-mount identity binding remains resumable. -An older journal that used host mounts fails closed because it cannot prove the original host source identity, even when the visible mount settings are unchanged. -Preserve the sandbox, onboarding session, printed backup, error, and `Journaled replacement` diagnostic, then follow the legacy journal guidance in [Continue an Interrupted Replacement](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes#continue-an-interrupted-replacement). -Use `--verbose` to print the replacement identifier, gateway, and journal phase. -Refer to [Continue an Interrupted Replacement](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes#continue-an-interrupted-replacement) for the recovery procedure and fail-closed conditions. -When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down window for backup and recreation. -A detached auto-lock timer remains active until NemoClaw commits a successful shields-up state, so it can attempt to restore lockdown if the host rebuild process exits unexpectedly. +If another terminal has an active SSH session to the sandbox, `rebuild` prints an active-session warning and requires confirmation before destroying the sandbox. Pass `--yes`, `-y`, or `--force` to skip the prompt in scripted workflows. + +The sandbox normally must be reachable for the backup step to succeed. If an archive command preserves at least one state directory, `rebuild` keeps the captured backup entries and reports the manifest-defined paths that could not be archived. If a manifest-declared state file fails, `rebuild` exits before destroying the original sandbox even when it preserved state directories, unless you explicitly pass `--force`. If every state directory fails, `rebuild` exits before destroying the original sandbox even when it captured loose files, unless you explicitly pass `--force`. With `--force`, NemoClaw preserves any captured loose files in the partial manifest and restores them after recreation. If the backup produced nothing usable, it continues from recorded registry metadata without restoring prior sandbox state. Use this recovery path only when losing the state that could not be backed up is acceptable. For a sandbox with managed MCP servers, `--force` probes sandbox execution before MCP teardown. If that no-op cannot run, NemoClaw requires complete bridge entries plus exact provider and target identities, without trying an in-sandbox adapter scrub or changing MCP lifecycle state. Each bridge must record the adapter for the sandbox.s recorded agent. It rechecks the registry, recorded gateway, resolved targets, and provider identities immediately before deletion; incomplete adds, drift, or ambiguous bridge state stop before deletion. Policy is not part of that ownership proof; rebuild independently captures the complete current OpenShell policy and hands it to replacement creation. NemoClaw sends the delete request and every deletion-confirmation lookup to the sandbox's exact recorded gateway. Across every rebuild path, NemoClaw does not attempt to stop local NIM until sandbox deletion is positively confirmed, then attempts NIM cleanup on a best-effort basis. When `openshell sandbox delete` exits nonzero, an exact recorded-gateway lookup distinguishes explicit absence from a confirmed `Ready` or `Running` sandbox. Any other phase or probe failure is ambiguous. Explicit absence continues the rebuild. Confirmed intact state triggers an attempt to restore prepared MCP state and any shields lockdown that rebuild temporarily opened. NemoClaw reports any MCP or shields restoration failure and does not present the operation as a successful rollback. Ambiguous state preserves MCP ownership and recovery metadata without attempting to stop NIM or claiming the original sandbox remains intact, and the rebuild process skips its immediate shields relock. Failures after a successful exec probe do not switch to the host-side path. Before backup or deletion, `rebuild` also refuses an incomplete MCP destroy transaction. For a prepared-only transaction, the redacted diagnostic points to `$$nemoclaw mcp remove --force` when the sandbox is still live. For a pending or both-marker transaction, it points to `$$nemoclaw destroy` because the registry records that OpenShell deletion was already confirmed. Before backup or deletion, rebuild checks the staged messaging configuration against other sandboxes in the selected OpenShell gateway's sandbox registry. A rebuild cannot detect messaging conflicts in an independent OpenShell gateway's registry. A conflict aborts with the original sandbox registered and intact so you can resolve the conflict before retrying. After OpenShell accepts the sandbox deletion, `rebuild` waits until OpenShell explicitly reports that the old sandbox is absent. Only then can NemoClaw perform any required local registry removal and begin creating the replacement. If OpenShell does not confirm absence within the bounded wait, including when gateway transport errors block the probes, `rebuild` exits nonzero before registry removal or replacement creation and preserves both the local registry entry and the state backup. Restore OpenShell connectivity and confirm the sandbox's live state before you retry, and keep the printed backup path for recovery. Before deletion, rebuild records a replacement journal that binds the operation to the recorded gateway, source identity, and target settings. Rerunning the same rebuild continues from the recorded boundary or accepts the proven replacement instead of deleting it again. A mount-free journal written before host-mount identity binding remains resumable. An older journal that used host mounts fails closed because it cannot prove the original host source identity, even when the visible mount settings are unchanged. Preserve the sandbox, onboarding session, printed backup, exact error, and `Journaled replacement` diagnostic, then follow the legacy journal guidance in [Continue an Interrupted Replacement](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes#continue-an-interrupted-replacement). Use `--verbose` to print the replacement identifier, gateway, and journal phase. Refer to [Continue an Interrupted Replacement](../manage-sandboxes/operate-sandboxes/recover-and-rebuild-sandboxes#continue-an-interrupted-replacement) for the recovery procedure and fail-closed conditions. When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down window for backup and recreation. A detached auto-lock timer remains active until NemoClaw commits a successful shields-up state, so it can attempt to restore lockdown if the host rebuild process exits unexpectedly. @@ -3954,28 +2581,18 @@ After restore, the command runs `openclaw doctor --fix` for cross-version struct -After restore, the command restores Hermes manifest-defined state and starts the rebuilt Hermes gateway with the regenerated `/sandbox/.hermes` config. -A rebuild creates a new sandbox home and a new Hermes API bearer token. -After the rebuild succeeds, retrieve the replacement token with `nemohermes my-assistant gateway-token --quiet` before reconnecting API clients. -For an older Hermes image that predates sealed shields transitions, rebuild is the only workflow authorized to use the descriptor-safe compatibility transition. -The compatibility path verifies the strict root-owned hash and the in-tree hash, publishes fresh config inodes to revoke retained write descriptors, and restores the trusted lock posture if the transition cannot finish. -Ordinary `shields up` and `shields down` commands refuse the older protocol and direct you to rebuild. +After restore, the command restores Hermes manifest-defined state and starts the rebuilt Hermes gateway with the regenerated `/sandbox/.hermes` config. A rebuild creates a new sandbox home and a new Hermes API bearer token. After the rebuild succeeds, retrieve the replacement token with `nemohermes my-assistant gateway-token --quiet` before reconnecting API clients. For an older Hermes image that predates sealed shields transitions, rebuild is the only workflow authorized to use the descriptor-safe compatibility transition. The compatibility path verifies the strict root-owned hash and the in-tree hash, publishes fresh config inodes to revoke retained write descriptors, and restores the trusted lock posture if the transition cannot finish. Ordinary `shields up` and `shields down` commands refuse the older protocol and direct you to rebuild. -After restore, the command restores Deep Agents manifest-defined state, regenerates `/sandbox/.deepagents/config.toml`, and recreates the managed MCP projection from the host registry. -Before changing the sandbox, rebuild verifies that the recorded `inference.local` route is still reachable and that the target provider, model, reasoning settings, web search selection, base image, and policy inputs match the recorded context. -If those checks fail after backup, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. -Use rebuild after a failed Deep Agents version check, after enabling Tavily Search, or after upgrading from an older managed MCP runtime. +After restore, the command restores Deep Agents manifest-defined state, regenerates `/sandbox/.deepagents/config.toml`, and recreates the managed MCP projection from the host registry. Before changing the sandbox, rebuild verifies that the recorded `inference.local` route is still reachable and that the target provider, model, reasoning settings, web search selection, base image, and current live OpenShell policy can be captured. If those checks fail after backup, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. Use rebuild after a failed Deep Agents version check, after enabling Tavily Search, or after upgrading from an older managed MCP runtime. ### `$$nemoclaw update` -Check for a NemoClaw CLI update and, when requested, run the maintained installer flow. -This command is a discoverable CLI wrapper around the supported installer path. -The update request and every redirect require HTTPS: +Check for a NemoClaw CLI update and, when requested, run the maintained installer flow. This command is a discoverable CLI wrapper around the supported installer path. The update request and every redirect require HTTPS: ```bash curl -fsSL --proto '=https' --proto-redir '=https' https://www.nvidia.com/nemoclaw.sh | bash @@ -3986,112 +2603,62 @@ $$nemoclaw update [--check] [--fresh] [--allow-downgrade] [--yes|-y] ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--check` | Show the current version, latest maintained version, install type, and maintained update command without changing anything. | | `--fresh` | Reinstall the maintained build for a clean re-clone of `~/.nemoclaw/source`; useful to repair a broken install. Does not reset onboarding state. By default, runs only when the maintained build is the same version or newer than the installed version. | | `--allow-downgrade` | Allow `--fresh` to reinstall when the maintained build is older than the installed version or the versions cannot be ordered. This can downgrade the host installation. | | `--yes`, `-y` | Skip the confirmation prompt and run the maintained installer flow. | -`$$nemoclaw update` updates the host-side NemoClaw installation. -The maintained installer flow follows the admin-promoted `lkg` release tag by default, so it may trail the newest semver or `latest` tag while validation completes. -Because of that, an install can be newer than the maintained tag. -Without `--allow-downgrade`, `--fresh` runs only when the maintained build is the same version or newer than the installed version. -When the maintained tag resolves, the command passes that repository revision to the installer, so a later tag change cannot select a different build for that update. -It reports the reason and exits non-zero in these cases: +`$$nemoclaw update` updates the host-side NemoClaw installation. The maintained installer flow follows the admin-promoted `lkg` release tag by default, so it may trail the newest semver or `latest` tag while validation completes. Because of that, an install can be newer than the maintained tag. Without `--allow-downgrade`, `--fresh` runs only when the maintained build is the same version or newer than the installed version. When the maintained tag resolves, the command passes that repository revision to the installer, so a later tag change cannot select a different build for that update. It reports the reason and exits non-zero in these cases: - The installed version is newer than the maintained tag. - The versions cannot be ordered. - The maintained tag does not resolve to a version. -NemoClaw cannot order a `git describe` version against a different prerelease on the same release line. -Rerun with `--allow-downgrade` to reinstall regardless; `--yes` waives the confirmation prompt only and never accepts a downgrade on its own. -It does not replace `$$nemoclaw upgrade-sandboxes`; use that command to inspect or rebuild existing sandboxes after the CLI has been updated. -When the command is running from a source checkout, it reports that state and does not replace the checkout with a global package install. +NemoClaw cannot order a `git describe` version against a different prerelease on the same release line. Rerun with `--allow-downgrade` to reinstall regardless; `--yes` waives the confirmation prompt only and never accepts a downgrade on its own. It does not replace `$$nemoclaw upgrade-sandboxes`; use that command to inspect or rebuild existing sandboxes after the CLI has been updated. When the command is running from a source checkout, it reports that state and does not replace the checkout with a global package install. ### `$$nemoclaw upgrade-sandboxes` -Rebuild sandboxes whose base image is older than the one currently pinned by NemoClaw. -NemoClaw resolves the digest of `ghcr.io/nvidia/nemoclaw/sandbox-base:latest` from the registry, then compares it against the digest each sandbox was created with. -Sandboxes that match the current digest are left alone. -NemoClaw also checks the build fingerprint recorded on each managed sandbox image. -A sandbox needs upgrade when its agent version is stale, when its recorded NemoClaw image fingerprint differs from the running CLI, or both. -When the target version is older than the recorded one (for example after reinstalling with an older `NEMOCLAW_INSTALL_TAG`), the stale listing marks the change with a `(downgrade)` suffix instead of framing it as a routine upgrade. -Custom Dockerfile sandboxes are not classified by image drift because rebuilding them onto the default image would drop the custom image. -Legacy sandboxes without a recorded fingerprint opt into this check after their next rebuild. -A recorded sandbox that is not observed in any phase on its own recorded gateway is reported as not found there, with remediation guidance — this typically means its gateway registration or Docker image was removed (for example by `$$nemoclaw uninstall`, which preserves `sandboxes.json` but removes both). +Rebuild sandboxes whose base image is older than the one currently pinned by NemoClaw. NemoClaw resolves the digest of `ghcr.io/nvidia/nemoclaw/sandbox-base:latest` from the registry, then compares it against the digest each sandbox was created with. Sandboxes that match the current digest are left alone. NemoClaw also checks the build fingerprint recorded on each managed sandbox image. A sandbox needs upgrade when its agent version is stale, when its recorded NemoClaw image fingerprint differs from the running CLI, or both. When the target version is older than the recorded one (for example after reinstalling with an older `NEMOCLAW_INSTALL_TAG`), the stale listing marks the change with a `(downgrade)` suffix instead of framing it as a routine upgrade. Custom Dockerfile sandboxes are not classified by image drift because rebuilding them onto the default image would drop the custom image. Legacy sandboxes without a recorded fingerprint opt into this check after their next rebuild. A recorded sandbox that is not observed in any phase on its own recorded gateway is reported as not found there, with remediation guidance — this typically means its gateway registration or Docker image was removed (for example by `$$nemoclaw uninstall`, which preserves `sandboxes.json` but removes both). ```bash $$nemoclaw upgrade-sandboxes [--check] [--auto] [--yes|-y] ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--check` | Inspect sandbox state without rebuilding. Exits nonzero when it reports stale state, an unknown version, a backup recovery condition, or a sandbox missing from its recorded gateway. Inspect each diagnostic before you select a remediation. | | `--auto` | Rebuild every stale sandbox without prompting. Used by the installer to upgrade in place. | | `--yes`, `-y` | Skip the confirmation prompt for the rebuild plan. | -Before it inspects a gateway or starts a rebuild, the command validates every registered sandbox name against the NemoClaw sandbox name format. -Route-only reservations are not sandboxes and are excluded from this validation. -If the command finds incompatible names, it lists each name before any gateway inspection or rebuild. -With `--check`, the command then returns without changing state. -In a mutating mode, it exits with a nonzero status. -NemoClaw does not truncate or rename a registered sandbox identity. -Follow [Update Sandboxes](../manage-sandboxes/operate-sandboxes/update-sandboxes) to transfer state to a compatible replacement before you rerun the command. - -Each rebuild reuses the same workspace backup-and-restore flow as `$$nemoclaw rebuild`, so workspace files survive the upgrade. -If the registry or required managed-image catalog evidence is unavailable, NemoClaw fails closed instead of selecting an unpinned image. -Restore registry access, then rerun the command so NemoClaw can validate the image digest. -During installer recovery, a registered sandbox that is not Ready can also be rebuilt from its validated latest backup. -That recovery requires a NemoClaw-managed image fingerprint or the installer's explicit confirmation for a listed pre-fingerprint OpenClaw or Hermes entry. -The legacy confirmation never overrides recorded custom-image evidence. -A custom OpenClaw sandbox is recoverable only when the selected backup independently carries complete authoritative image-plugin provenance. +Before it inspects a gateway or starts a rebuild, the command validates every registered sandbox name against the NemoClaw sandbox name format. Route-only reservations are not sandboxes and are excluded from this validation. If the command finds incompatible names, it lists each name before any gateway inspection or rebuild. With `--check`, the command then returns without changing state. In a mutating mode, it exits with a nonzero status. NemoClaw does not truncate or rename a registered sandbox identity. Follow [Update Sandboxes](../manage-sandboxes/operate-sandboxes/update-sandboxes) to transfer state to a compatible replacement before you rerun the command. + +Each rebuild reuses the same workspace backup-and-restore flow as `$$nemoclaw rebuild`, so workspace files survive the upgrade. If the registry or required managed-image catalog evidence is unavailable, NemoClaw fails closed instead of selecting an unpinned image. Restore registry access, then rerun the command so NemoClaw can validate the exact image digest. During installer recovery, a registered sandbox that is not Ready can also be rebuilt from its validated latest backup. That recovery requires a NemoClaw-managed image fingerprint or the installer's explicit confirmation for a listed pre-fingerprint OpenClaw or Hermes entry. The legacy confirmation never overrides recorded custom-image evidence. A custom OpenClaw sandbox is recoverable only when the selected backup independently carries complete authoritative image-plugin provenance. ### `$$nemoclaw backup-all` -Back up registered sandboxes that are running or have an eligible stopped Docker-driver container to `~/.nemoclaw/rebuild-backups/`. -A registered docker-driver sandbox whose container is stopped is started for the duration of the backup and returned to its stopped state afterward. -If the container cannot be returned to the stopped state, the command fails and reports that the container was left running. -Sandboxes that are not running and cannot be started this way are skipped with remediation guidance. +Back up registered sandboxes that are running or have an eligible stopped Docker-driver container to `~/.nemoclaw/rebuild-backups/`. A registered docker-driver sandbox whose container is stopped is started for the duration of the backup and returned to its stopped state afterward. If the container cannot be returned to the stopped state, the command fails and reports that the container was left running. Sandboxes that are not running and cannot be started this way are skipped with remediation guidance. -For each eligible sandbox, `backup-all` holds one lifecycle transaction through the complete backup. -Within that transaction, it starts a stopped container when required, opens a 30-minute shields-down window when the sandbox starts with Shields up, copies sandbox state, restores the previous Shields state, and returns any container it started to the stopped state. -If the timer expires during the transaction, the deadline gate blocks new mutations and waits for the backup owner to finish without signaling it. -An initial lock or unlock failure marks that sandbox as failed, and `backup-all` continues with the next sandbox. -A failure to restore the previous Shields state stops `backup-all` before it processes another sandbox. +For each eligible sandbox, `backup-all` holds one lifecycle transaction through the complete backup. Within that transaction, it starts a stopped container when required, opens a 30-minute shields-down window when the sandbox starts with Shields up, copies sandbox state, restores the previous Shields state, and returns any container it started to the stopped state. If the timer expires during the transaction, the deadline gate blocks new mutations and waits for the exact backup owner to finish without signaling it. An initial lock or unlock failure marks that sandbox as failed, and `backup-all` continues with the next sandbox. A failure to restore the previous Shields state stops `backup-all` before it processes another sandbox. ```bash $$nemoclaw backup-all ``` -Before an OpenShell upgrade, the installer prepares the current release CLI and uses it to run `backup-all` in strict mode. -Strict mode requires every registered sandbox to produce a fresh backup and aborts before gateway changes if any sandbox is skipped or fails. -When strict mode reports a skipped sandbox, start that sandbox or its container and rerun the installer or `$$nemoclaw backup-all`. +Before an OpenShell upgrade, the installer prepares the current release CLI and uses it to run `backup-all` in strict mode. Strict mode requires every registered sandbox to produce a fresh backup and aborts before gateway changes if any sandbox is skipped or fails. When strict mode reports a skipped sandbox, start that sandbox or its container and rerun the installer or `$$nemoclaw backup-all`. -A running sandbox whose in-sandbox SSH endpoint does not answer fails its backup and aborts the run. -For a standalone `$$nemoclaw backup-all` run, set `NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP=1` exactly to skip such sandboxes instead of failing. -Other values such as `true`, `yes`, or `0` are not accepted. -This variable does not weaken the installer's strict pre-upgrade requirement. -A skipped sandbox's uncommitted state is not included in its last successful backup. +A running sandbox whose in-sandbox SSH endpoint does not answer fails its backup and aborts the run. For a standalone `$$nemoclaw backup-all` run, set `NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP=1` exactly to skip such sandboxes instead of failing. Other values such as `true`, `yes`, or `0` are not accepted. This variable does not weaken the installer's strict pre-upgrade requirement. A skipped sandbox's uncommitted state is not included in its last successful backup. ### `$$nemoclaw snapshot create` -Create a timestamped snapshot of sandbox state. -Snapshots are stored in `~/.nemoclaw/rebuild-backups//`. -The command requires shields to be down and keeps the shields check and backup under one per-sandbox transition. -If the timer expires during a long-running backup, the deadline gate blocks new mutations and waits for the backup owner to finish. -Auto-restore does not signal the backup process. -If ownership becomes ambiguous, NemoClaw attempts to record durable containment and reports generation recovery guidance. -If the containment commit fails, NemoClaw retains any lifecycle and deadline gates it already owns. -A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition. -Correct the reported state-directory write failure, then run `$$nemoclaw shields status` to resume recovery or receive generation recovery guidance. -When the sandbox has active baseline exclusions, successful output lists their keys and repeats that excluded egress leaves dependent agent features unsupported for that sandbox. +Create a timestamped snapshot of sandbox state. Snapshots are stored in `~/.nemoclaw/rebuild-backups//`. The command requires shields to be down and keeps the shields check and backup under one per-sandbox transition. If the timer expires during a long-running backup, the deadline gate blocks new mutations and waits for the exact backup owner to finish. Auto-restore does not signal the backup process. If ownership becomes ambiguous, NemoClaw attempts to record durable containment and reports exact-generation recovery guidance. If the containment commit fails, NemoClaw retains any exact lifecycle and deadline gates it already owns. A state-directory failure that prevented gate publication also prevents normal mutation-lock acquisition. Correct the reported state-directory write failure, then run `$$nemoclaw shields status` to resume recovery or receive exact-generation recovery guidance. Snapshot metadata does not contain policy presets or exclusions. ```bash $$nemoclaw my-assistant snapshot create ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--name ### `$$nemoclaw status` -Show the global sandbox list and the status of host auxiliary services (for example cloudflared). -This command is host-wide. It summarizes registered sandboxes, the default sandbox's live inference route, gateway health, and host services. +Show the global sandbox list and the status of host auxiliary services (for example cloudflared). This command is host-wide. It summarizes registered sandboxes, the default sandbox's live inference route, gateway health, and host services. + For gateway-based messaging agents, it also reports messaging overlap warnings within the selected OpenShell gateway's sandbox registry. @@ -4384,21 +2886,11 @@ $$nemoclaw status $$nemoclaw status --json ``` -When at least one sandbox is registered and the named NemoClaw gateway is unreachable, unhealthy, or attached to a different sandbox, the command prints a `gateway: down [state] (reason)` line between the sandbox list and the host-service list. -The command classifies the failing layer when possible: the named gateway port is not accepting connections, the named gateway is running but not Connected, the active OpenShell gateway points at a different name, or the named gateway is not configured at all. -It then prints the gateway recovery guidance for your host. -That guidance names `$$nemoclaw onboard` when NemoClaw starts the gateway process. -When another deployment owns that process, the guidance directs you to start it with the owning deployment and run `openshell gateway select `. -It exits with code `1` so shell scripts and CI can detect the degraded state from `$?`. -For `--json`, the structured output includes `gatewayHealth`, and the exit code is set after the report is generated. -A clean machine with no registered sandboxes keeps the legacy `0` exit because no gateway is expected to be configured yet. -If cloudflared is installed but not running, the host-service section reports whether the PID file is missing, invalid, or points at a dead process, then suggests `$$nemoclaw tunnel start` as the recovery command. +When at least one sandbox is registered and the named NemoClaw gateway is unreachable, unhealthy, or attached to a different sandbox, the command prints a `gateway: down [state] (reason)` line between the sandbox list and the host-service list. The command classifies the failing layer when possible: the named gateway port is not accepting connections, the named gateway is running but not Connected, the active OpenShell gateway points at a different name, or the named gateway is not configured at all. It then prints the gateway recovery guidance for your host. That guidance names `$$nemoclaw onboard` when NemoClaw starts the gateway process. When another deployment owns that process, the guidance directs you to start it with the owning deployment and run `openshell gateway select `. It exits with code `1` so shell scripts and CI can detect the degraded state from `$?`. For `--json`, the structured output includes `gatewayHealth`, and the exit code is set after the report is generated. A clean machine with no registered sandboxes keeps the legacy `0` exit because no gateway is expected to be configured yet. If cloudflared is installed but not running, the host-service section reports whether the PID file is missing, invalid, or points at a dead process, then suggests `$$nemoclaw tunnel start` as the recovery command. ### `$$nemoclaw inference get` -Show the active live inference provider and model from the NemoClaw-managed OpenShell gateway. -Use this command when you want the direct runtime route without the rest of the sandbox status output. -It is also available in sandbox-first form as `$$nemoclaw inference get`. +Show the active live inference provider and model from the NemoClaw-managed OpenShell gateway. Use this command when you want the direct runtime route without the rest of the sandbox status output. It is also available in sandbox-first form as `$$nemoclaw inference get`. ```bash $$nemoclaw inference get @@ -4415,66 +2907,34 @@ $$nemoclaw my-assistant inference get -Switch the active inference provider or model for a NemoClaw-managed OpenClaw sandbox. -The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry. -It is also available in sandbox-first form as `$$nemoclaw inference set --provider --model `. -For OpenClaw, the patch updates the OpenClaw config provider namespace and selected model. -Same-API-family changes hot-reload without replacing the gateway process. -When the API family changes, NemoClaw commits the config and integrity hash, then uses the managed supervisor to restart only the OpenClaw gateway and verify its health and forwards. -The sandbox remains running, but agent requests are briefly interrupted. -If the restart fails, the route and config remain committed; run `$$nemoclaw gateway restart` to finish applying the switch. -After every changed synchronized route, NemoClaw also verifies that the local CLI device has the managed gateway's required pairing scopes before it reports success. -If pairing does not converge, the route and config remain committed. -Run `$$nemoclaw doctor --fix`, then retry the agent turn. +Switch the active inference provider or model for a NemoClaw-managed OpenClaw sandbox. The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry. It is also available in sandbox-first form as `$$nemoclaw inference set --provider --model `. For OpenClaw, the patch updates the OpenClaw config provider namespace and selected model. Same-API-family changes hot-reload without replacing the gateway process. When the API family changes, NemoClaw commits the config and integrity hash, then uses the managed supervisor to restart only the OpenClaw gateway and verify its health and forwards. The sandbox remains running, but agent requests are briefly interrupted. If the restart fails, the route and config remain committed; run `$$nemoclaw gateway restart` to finish applying the switch. After every changed synchronized route, NemoClaw also verifies that the local CLI device has the managed gateway's required pairing scopes before it reports success. If pairing does not converge, the route and config remain committed. Run `$$nemoclaw doctor --fix`, then retry the agent turn. -Switch the active inference provider or model for a NemoClaw-managed Hermes sandbox. -The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry. -It is also available in sandbox-first form as `$$nemoclaw inference set --provider --model `. -For Hermes, the patch updates `/sandbox/.hermes/config.yaml` (`model.default`, `model.base_url`, `model.provider: custom`, API-family mode when needed, and the OpenShell proxy API-key placeholder) and does not rebuild or restart the gateway. -When the Hermes dashboard profile exists, the command also mirrors the model route into `/sandbox/.hermes/profiles/dashboard-home/config.yaml` for Dashboard Chat. -Keeping the placeholder preserves dashboard and API authentication after provider switches. -If NemoClaw cannot confirm that the dashboard config was updated, the route, registry, and main Hermes config remain committed, but the command exits nonzero without printing `Inference route synced`. -Restart the sandbox with `nemohermes stop` followed by `nemohermes start`, then verify Dashboard Chat before relying on it. -A missing dashboard profile is treated as disabled and does not fail the switch. -Under the `nemohermes` alias, it uses the registered Hermes sandbox when exactly one exists; otherwise pass `--sandbox ` to target one explicitly. +Switch the active inference provider or model for a NemoClaw-managed Hermes sandbox. The command updates the OpenShell gateway route, patches the selected running agent config so it matches the route, recomputes the config hash, and updates the NemoClaw registry. It is also available in sandbox-first form as `$$nemoclaw inference set --provider --model `. For Hermes, the patch updates `/sandbox/.hermes/config.yaml` (`model.default`, `model.base_url`, `model.provider: custom`, API-family mode when needed, and the OpenShell proxy API-key placeholder) and does not rebuild or restart the gateway. When the Hermes dashboard profile exists, the command also mirrors the model route into `/sandbox/.hermes/profiles/dashboard-home/config.yaml` for Dashboard Chat. Keeping the placeholder preserves dashboard and API authentication after provider switches. If NemoClaw cannot confirm that the dashboard config was updated, the route, registry, and main Hermes config remain committed, but the command exits nonzero without printing `Inference route synced`. Restart the sandbox with `nemohermes stop` followed by `nemohermes start`, then verify Dashboard Chat before relying on it. A missing dashboard profile is treated as disabled and does not fail the switch. Under the `nemohermes` alias, it uses the registered Hermes sandbox when exactly one exists; otherwise pass `--sandbox ` to target one explicitly. -By default, the command syncs the default registered sandbox. -The command refuses before changing the OpenShell route when the selected sandbox has shields up. -Run `$$nemoclaw shields down`, apply the inference change, then run `$$nemoclaw shields up` again. +By default, the command syncs the default registered sandbox. The command refuses before changing the OpenShell route when the selected sandbox has shields up. Run `$$nemoclaw shields down`, apply the inference change, then run `$$nemoclaw shields up` again. -Each OpenShell gateway exposes one inference route to every sandbox registered on that gateway. -Before changing the route, NemoClaw compares the requested provider and model with every same-gateway registry entry, including stopped sandboxes. -Custom compatible routes must also have matching normalized endpoint URLs and API families. -Provider-global credential environment-variable names must also match for the same provider name. -If a route conflicts or a legacy custom route lacks enough endpoint or API-family metadata to prove compatibility, the command exits non-zero before changing the OpenShell route, agent config, or host registry and names the conflicting sandboxes. -Align those sandboxes to the same route or remove a conflicting sandbox that you no longer need. +Each OpenShell gateway exposes one inference route to every sandbox registered on that gateway. Before changing the route, NemoClaw compares the requested provider and model with every same-gateway registry entry, including stopped sandboxes. Custom compatible routes must also have matching normalized endpoint URLs and API families. Provider-global credential environment-variable names must also match for the same provider name. If a route conflicts or a legacy custom route lacks enough endpoint or API-family metadata to prove compatibility, the command exits non-zero before changing the OpenShell route, agent config, or host registry and names the conflicting sandboxes. Align those sandboxes to the same route or remove a conflicting sandbox that you no longer need. -Onboarding and `connect` can time-share compatible provider and model routes without replacing provider-global configuration. -Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for the onboarding warnings, compatibility fields, and status drift report. +Onboarding and `connect` can time-share compatible provider and model routes without replacing provider-global configuration. Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for the onboarding warnings, compatibility fields, and status drift report. ```bash $$nemoclaw inference set --provider --model [--sandbox ] [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ] ``` -You can also name the sandbox in sandbox-first position instead of passing `--sandbox`. -`$$nemoclaw inference set --provider --model ` targets `` directly and is equivalent to `$$nemoclaw inference set --provider --model --sandbox `. +You can also name the sandbox in sandbox-first position instead of passing `--sandbox`. `$$nemoclaw inference set --provider --model ` targets `` directly and is equivalent to `$$nemoclaw inference set --provider --model --sandbox `. ```bash $$nemoclaw my-assistant inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b ``` -Pass both `--provider` and `--model` when you want NemoClaw to update the OpenShell inference route and sync the selected sandbox's agent config. -NemoClaw resolves the OpenShell gateway from the target sandbox's recorded gateway binding, including non-default `NEMOCLAW_GATEWAY_PORT` deployments. -Do not run `openshell inference set` directly on a shared NemoClaw gateway because that bypasses registry compatibility checks and can break other sandboxes. -When either flag is missing, `$$nemoclaw inference set` reports both required flags without suggesting a raw OpenShell command. -The command updates the host registry immediately after the gateway route changes. +Pass both `--provider` and `--model` when you want NemoClaw to update the OpenShell inference route and sync the selected sandbox's agent config. NemoClaw resolves the OpenShell gateway from the target sandbox's recorded gateway binding, including non-default `NEMOCLAW_GATEWAY_PORT` deployments. Do not run `openshell inference set` directly on a shared NemoClaw gateway because that bypasses registry compatibility checks and can break other sandboxes. When either flag is missing, `$$nemoclaw inference set` reports both required flags without suggesting a raw OpenShell command. The command updates the host registry immediately after the gateway route changes. @@ -4492,26 +2952,7 @@ If the in-sandbox config write or integrity hash update fails, the OpenShell rou -Supported provider names are `nvidia-prod`, `nvidia-nim`, `nvidia-router`, `openai-api`, `anthropic-prod`, `compatible-anthropic-endpoint`, `gemini-api`, `compatible-endpoint`, `hermes-provider`, `ollama-local`, and `vllm-local`. -Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential. -When you explicitly supply a direct compatible endpoint at `http://host.openshell.internal:`, NemoClaw skips OpenShell's host-side provider probe because that hostname resolves only inside the sandbox network. -Before it persists the route in the NemoClaw registry or agent config, the command sends a validation request from the target sandbox with a 16-token output limit. -When the switch changes the API family and that request returns HTTP `400` or `404`, NemoClaw retries up to two times after delays of one and two seconds. -Each retry has the same 16-token output limit. -Other failures are not retried. -If that request fails, the command attempts to restore the previous OpenShell selection and remove a provider that this switch created. -If the error reports that rollback could not complete, rerun onboarding before using the route or retrying the switch. -Endpoint-shape and shared-gateway compatibility checks still apply. -When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL and, except for the Hermes case below, `--inference-api` with its API family so NemoClaw can persist a complete route identity for rebuild and shared-gateway checks. -For a Hermes `compatible-anthropic-endpoint` target, `--inference-api` may be omitted because NemoClaw deterministically selects `openai-completions`; an explicit different API family is rejected. -NemoClaw rejects loopback, link-local, private, and internal endpoint addresses, including public hostnames that resolve to a private address. -For a same-provider model change, pass `--endpoint-url` with the endpoint URL recorded during onboarding for the target sandbox. -Missing or `inference set` provenance and every different URL remain subject to the full address validation above. -For public HTTP URLs, NemoClaw stores the validated IP address to prevent DNS rebinding. -For a DNS-backed HTTPS URL, NemoClaw routes the endpoint through a local HTTPS Pin Runtime adapter that terminates a pinned, SNI-correct outbound connection to the real upstream hostname; the sandbox and the persisted registry only ever see a local `host.openshell.internal` route, never the real hostname. -HTTPS IP-literal URLs remain supported and do not need the adapter. -NemoClaw accepts `http://host.openshell.internal:` only with an explicit port from `1024` through `65535`; this narrow exception supports NemoClaw's sandbox-to-host inference routes and is not a general private-endpoint bypass. -`--credential-env` may also be supplied for compatible provider metadata; supported `--inference-api` values are `openai-completions`, `anthropic-messages`, and `openai-responses`. +Supported provider names are `nvidia-prod`, `nvidia-nim`, `nvidia-router`, `openai-api`, `anthropic-prod`, `compatible-anthropic-endpoint`, `gemini-api`, `compatible-endpoint`, `hermes-provider`, `ollama-local`, and `vllm-local`. Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential. When you explicitly supply a direct compatible endpoint at `http://host.openshell.internal:`, NemoClaw skips OpenShell's host-side provider probe because that hostname resolves only inside the sandbox network. Before it persists the route in the NemoClaw registry or agent config, the command sends a validation request from the target sandbox with a 16-token output limit. When the switch changes the API family and that request returns HTTP `400` or `404`, NemoClaw retries up to two times after delays of one and two seconds. Each retry has the same 16-token output limit. Other failures are not retried. If that request fails, the command attempts to restore the previous OpenShell selection and remove a provider that this switch created. If the error reports that rollback could not complete, rerun onboarding before using the route or retrying the switch. Endpoint-shape and shared-gateway compatibility checks still apply. When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL and, except for the Hermes case below, `--inference-api` with its API family so NemoClaw can persist a complete route identity for rebuild and shared-gateway checks. For a Hermes `compatible-anthropic-endpoint` target, `--inference-api` may be omitted because NemoClaw deterministically selects `openai-completions`; an explicit different API family is rejected. NemoClaw rejects loopback, link-local, private, and internal endpoint addresses, including public hostnames that resolve to a private address. For a same-provider model change, pass `--endpoint-url` with the endpoint URL recorded during onboarding for the target sandbox. Missing or `inference set` provenance and every different URL remain subject to the full address validation above. For public HTTP URLs, NemoClaw stores the validated IP address to prevent DNS rebinding. For a DNS-backed HTTPS URL, NemoClaw routes the endpoint through a local HTTPS Pin Runtime adapter that terminates a pinned, SNI-correct outbound connection to the real upstream hostname; the sandbox and the persisted registry only ever see a local `host.openshell.internal` route, never the real hostname. HTTPS IP-literal URLs remain supported and do not need the adapter. NemoClaw accepts `http://host.openshell.internal:` only with an explicit port from `1024` through `65535`; this narrow exception supports NemoClaw's sandbox-to-host inference routes and is not a general private-endpoint bypass. `--credential-env` may also be supplied for compatible provider metadata; supported `--inference-api` values are `openai-completions`, `anthropic-messages`, and `openai-responses`. @@ -4520,33 +2961,18 @@ NemoClaw accepts `http://host.openshell.internal:` only with an explicit p $$nemoclaw inference set --provider --model [--sandbox ] [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ] [--reasoning-effort ] ``` -`--reasoning-effort` accepts `low`, `medium`, `high`, or `default`. -An explicit flag or `NEMOCLAW_REASONING_EFFORT` value applies only to a `compatible-endpoint` route that resolves to `openai-completions`. -NemoClaw validates the explicit value, provider, and API before it changes the OpenShell route, the agent config, or the host registry. -It rejects an unsupported route for every explicit value, including `default`. -For a `low`, `medium`, or `high` value on an `openai-completions` route, NemoClaw writes `params.extra_body.reasoning_effort` on the model entry, and OpenClaw merges it into the request body. -`--reasoning-effort` overrides `NEMOCLAW_REASONING_EFFORT`. -When you omit the flag, `$$nemoclaw inference set` applies an exported `NEMOCLAW_REASONING_EFFORT`. -When neither is set, the sandbox keeps the recorded effort only while the resulting route uses `compatible-endpoint` and `openai-completions`. -Without an explicit effort input, switching to another provider or API family removes an inherited `reasoning_effort` field and records the endpoint-default state. -Pass `default` on a `compatible-endpoint` route that uses `openai-completions` to clear a recorded effort and return the endpoint to its own default. -An ordinary sandbox restart preserves the resulting effort or endpoint-default state; it does not replay the image's original onboarding value. +`--reasoning-effort` accepts `low`, `medium`, `high`, or `default`. An explicit flag or `NEMOCLAW_REASONING_EFFORT` value applies only to a `compatible-endpoint` route that resolves to `openai-completions`. NemoClaw validates the explicit value, provider, and API before it changes the OpenShell route, the agent config, or the host registry. It rejects an unsupported route for every explicit value, including `default`. For a `low`, `medium`, or `high` value on an `openai-completions` route, NemoClaw writes `params.extra_body.reasoning_effort` on the model entry, and OpenClaw merges it into the request body. `--reasoning-effort` overrides `NEMOCLAW_REASONING_EFFORT`. When you omit the flag, `$$nemoclaw inference set` applies an exported `NEMOCLAW_REASONING_EFFORT`. When neither is set, the sandbox keeps the recorded effort only while the resulting route uses `compatible-endpoint` and `openai-completions`. Without an explicit effort input, switching to another provider or API family removes an inherited `reasoning_effort` field and records the endpoint-default state. Pass `default` on a `compatible-endpoint` route that uses `openai-completions` to clear a recorded effort and return the endpoint to its own default. An ordinary sandbox restart preserves the resulting effort or endpoint-default state; it does not replay the image's original onboarding value. -For Deep Agents sandboxes, run `$$nemoclaw onboard --fresh --name --recreate-sandbox` when you need to change the provider or model. -The managed `dcode` configuration is written under `/sandbox/.deepagents` during onboarding, so the recreate path keeps the OpenShell route and the sandbox config aligned. -Use `$$nemoclaw inference get` and `$$nemoclaw status` to inspect the current route. +For Deep Agents sandboxes, run `$$nemoclaw onboard --fresh --name --recreate-sandbox` when you need to change the provider or model. The managed `dcode` configuration is written under `/sandbox/.deepagents` during onboarding, so the recreate path keeps the OpenShell route and the sandbox config aligned. Use `$$nemoclaw inference get` and `$$nemoclaw status` to inspect the current route. ### `$$nemoclaw setup` - -The `$$nemoclaw setup` command is deprecated. -Use `$$nemoclaw onboard` instead. - +The `$$nemoclaw setup` command is deprecated. Use `$$nemoclaw onboard` instead. This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--profile `, `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--apf-interceptor`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--host-mount`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--vllm-gpu-device`, `--agent`, `--agents `, `--tool-disclosure `, `--observability` / `--no-observability`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. @@ -4557,8 +2983,8 @@ $$nemoclaw setup ### `$$nemoclaw setup-spark` -The `$$nemoclaw setup-spark` command is deprecated. -Use the standard installer and run `$$nemoclaw onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. + The `$$nemoclaw setup-spark` command is deprecated. Use the standard installer and run `$$nemoclaw + onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--profile `, `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--apf-interceptor`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--host-mount`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--vllm-gpu-device`, `--agent`, `--agents `, `--tool-disclosure `, `--observability` / `--no-observability`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. @@ -4569,33 +2995,25 @@ $$nemoclaw setup-spark ### `$$nemoclaw debug` -Collect diagnostics for bug reports. -Gathers system info, Docker state, gateway logs, and sandbox status into a summary or tarball. -Use `--sandbox ` to target a specific sandbox, `--quick` for a smaller snapshot, or `--output ` to save a tarball that you can attach to an issue. +Collect diagnostics for bug reports. Gathers system info, Docker state, gateway logs, and sandbox status into a summary or tarball. Use `--sandbox ` to target a specific sandbox, `--quick` for a smaller snapshot, or `--output ` to save a tarball that you can attach to an issue. ```bash $$nemoclaw debug [--quick|-q] [--sandbox NAME] [--output PATH|-o PATH] ``` -| Flag | Description | -|------|-------------| -| `--quick`, `-q` | Collect minimal diagnostics only | -| `--sandbox NAME` | Target a specific sandbox (default: auto-detect) | -| `--output PATH`, `-o PATH` | Write diagnostics tarball to the given path | +| Flag | Description | +| -------------------------- | ------------------------------------------------ | +| `--quick`, `-q` | Collect minimal diagnostics only | +| `--sandbox NAME` | Target a specific sandbox (default: auto-detect) | +| `--output PATH`, `-o PATH` | Write diagnostics tarball to the given path | -If `--output` is set and the tarball cannot be written (for example, the destination directory is missing or read-only), the command exits non-zero so scripts can detect the failure. -The tarball is written to a temporary sibling and renamed on success, so a pre-existing file at `--output` is preserved when `tar` fails. +If `--output` is set and the tarball cannot be written (for example, the destination directory is missing or read-only), the command exits non-zero so scripts can detect the failure. The tarball is written to a temporary sibling and renamed on success, so a pre-existing file at `--output` is preserved when `tar` fails. -When `--sandbox` is supplied explicitly through the flag or one of `NEMOCLAW_SANDBOX_NAME`, `NEMOCLAW_SANDBOX`, or `SANDBOX_NAME`, the name must match a registered sandbox. -The flag wins, then the env vars in that order. -If `openshell sandbox list` succeeds, the sandbox must also appear in the live gateway. -An unknown or stale name exits non-zero with an actionable error that names the sandbox and reports the source env var when applicable, and no tarball is written. -Without an explicit name, `$$nemoclaw debug` falls back to the registry's default sandbox and warns if that default is stale. +When `--sandbox` is supplied explicitly through the flag or one of `NEMOCLAW_SANDBOX_NAME`, `NEMOCLAW_SANDBOX`, or `SANDBOX_NAME`, the name must match a registered sandbox. The flag wins, then the env vars in that order. If `openshell sandbox list` succeeds, the sandbox must also appear in the live gateway. An unknown or stale name exits non-zero with an actionable error that names the sandbox and reports the source env var when applicable, and no tarball is written. Without an explicit name, `$$nemoclaw debug` falls back to the registry's default sandbox and warns if that default is stale. ### `$$nemoclaw credentials list` -List the provider credentials registered with the OpenShell gateway. -Values are not printed. +List the provider credentials registered with the OpenShell gateway. Values are not printed. ```bash $$nemoclaw credentials list @@ -4603,23 +3021,16 @@ $$nemoclaw credentials list ### `$$nemoclaw credentials add ` -Register a provider credential with the OpenShell gateway by name and type. -Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. -Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. -`--from-existing` is available only when no managed MCP server reserves credential keys. -The command fails before gateway work when a reservation exists because `--from-existing` does not expose credential keys before provider creation. -Rerun with explicit `--credential ` input, or remove every managed MCP server that reserves credential keys before retrying. -After the gateway accepts the provider, rebuild the target sandbox so the new provider is attached. +Register a provider credential with the OpenShell gateway by name and type. Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. `--from-existing` is available only when no managed MCP server reserves credential keys. The command fails before gateway work when a reservation exists because `--from-existing` does not expose credential keys before provider creation. Rerun with explicit `--credential ` input, or remove every managed MCP server that reserves credential keys before retrying. After the gateway accepts the provider, rebuild the target sandbox so the new provider is attached. -Registered providers attach to every sandbox you build or rebuild after the call (the gateway is one process serving all sandboxes). -If you want a provider available to only some sandboxes, scope it with `nemoclaw credentials reset ` once those sandboxes finish using it. +Registered providers attach to every sandbox you build or rebuild after the call (the gateway is one process serving all sandboxes). If you want a provider available to only some sandboxes, scope it with `nemoclaw credentials reset ` once those sandboxes finish using it. ```bash $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_KEY ``` | Flag | Description | -|------|-------------| +| --- | --- | | `--type ` | Provider type (e.g. `tavily`, `nvidia`, `openai`, `anthropic`, `generic`) | | `--credential ` | Env variable name whose value holds the credential. Repeatable | | `--config ` | Provider configuration pair. Repeatable | @@ -4627,79 +3038,46 @@ $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_K ### `$$nemoclaw credentials reset ` -Remove a provider credential from the OpenShell gateway by provider name. -After removal, re-running `$$nemoclaw onboard` re-prompts for that provider's credential. -Run `$$nemoclaw credentials list` first if you are not sure of the provider name. +Remove a provider credential from the OpenShell gateway by provider name. After removal, re-running `$$nemoclaw onboard` re-prompts for that provider's credential. Run `$$nemoclaw credentials list` first if you are not sure of the provider name. ```bash $$nemoclaw credentials reset nvidia-prod ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| ------------- | ---------------------------- | | `--yes`, `-y` | Skip the confirmation prompt | ### `$$nemoclaw gc` -Remove orphaned sandbox Docker images from the host. -Sandbox creation can build images in the gateway-managed `openshell/sandbox-from` repository or the locally prebuilt `nemoclaw-sandbox-local` repository. -The `destroy` and `rebuild` commands clean up the image automatically, but images from older NemoClaw versions or interrupted operations may remain. -This command lists images from both repositories, cross-references the sandbox registry, and removes any that are no longer associated with a registered sandbox. +Remove orphaned sandbox Docker images from the host. Sandbox creation can build images in the gateway-managed `openshell/sandbox-from` repository or the locally prebuilt `nemoclaw-sandbox-local` repository. The `destroy` and `rebuild` commands clean up the image automatically, but images from older NemoClaw versions or interrupted operations may remain. This command lists images from both repositories, cross-references the sandbox registry, and removes any that are no longer associated with a registered sandbox. ```bash $$nemoclaw gc [--dry-run] [--yes|-y|--force] ``` -| Flag | Description | -|------|-------------| -| `--dry-run` | List orphaned images without removing them | -| `--yes`, `-y`, `--force` | Skip the confirmation prompt | +| Flag | Description | +| ------------------------ | ------------------------------------------ | +| `--dry-run` | List orphaned images without removing them | +| `--yes`, `-y`, `--force` | Skip the confirmation prompt | ### `$$nemoclaw uninstall` -Run `uninstall.sh` to uninstall NemoClaw. Unless this section explicitly describes portable cleanup, its resource-removal statements apply outside portable cleanup. -The CLI runs the local `uninstall.sh` shipped with the installed npm package. -If that local script is missing, the CLI does not auto-fetch a remote copy. -It prints the versioned URL of the matching `uninstall.sh` so you can download, review, and run it manually. - -When the gateway is externally supervised, uninstall preserves its process, Docker resources, and OpenShell binaries. -It still deletes the selected sandboxes and attempts to remove the modern local gateway registration. -When uninstall confirms that no sibling gateways remain, it also deletes NemoClaw provider registrations. -For a managed dual-Station vLLM runtime, full uninstall revalidates the recorded pair and removes both managed containers before starting the remaining uninstall steps. -If that cleanup fails, uninstall exits nonzero, preserves its owner-only cleanup receipt, and tells you to resolve the reported peer error before retrying. -Pair cleanup can partially complete before an error; verify both Stations before the retry. -For an authenticated host-local vLLM runtime, full uninstall verifies the named container, NemoClaw ownership label, persisted API key, and authentication fingerprint before removing the container by its inspected ID. -When that ownership state is missing, full uninstall removes the reserved `nemoclaw-vllm` container only when Docker reports its NemoClaw managed label and a valid container ID. -An unlabeled container or malformed inspection remains in place and stops the remaining uninstall steps. -For managed llama.cpp, full uninstall verifies the named container and network ownership before removing both resources by their inspected IDs. -These host-local checks run before NemoClaw deletes their state. -If Docker is unavailable or a resource does not match its persisted ownership state, uninstall exits nonzero before the remaining uninstall steps and preserves that state for recovery. -Host-local cleanup can partially complete before an error. -Restore Docker access or resolve the named ownership conflict, inspect the remaining container and network, and retry uninstall. -Managed llama.cpp and vLLM model files remain in the shared Hugging Face cache by default. -Outside portable cleanup, `--delete-models` deletes every model in the local Ollama inventory and all non-credential data in the current user's shared `~/.cache/huggingface/` cache. -This opt-in can delete cached files that other applications installed or use. -It preserves the Hugging Face `token` and `stored_tokens` authentication files. -NemoClaw stops and verifies its managed local and distributed model runtimes before it deletes non-credential data from the local Hugging Face cache. -It does not scan arbitrary directories or delete model caches on remote peers. -When sibling gateway environments remain, uninstall preserves both model stores even if you pass `--delete-models`. -An Ollama inventory error, model deletion error, unsafe cache path, or cache-data deletion error makes uninstall exit nonzero. -Cleanup can partially complete before an error, so resolve the reported error and rerun uninstall. -It does not use the legacy `gateway destroy` command for that gateway. +Run `uninstall.sh` to uninstall NemoClaw. Unless this section explicitly describes portable cleanup, its resource-removal statements apply outside portable cleanup. The CLI runs the local `uninstall.sh` shipped with the installed npm package. If that local script is missing, the CLI does not auto-fetch a remote copy. It prints the versioned URL of the matching `uninstall.sh` so you can download, review, and run it manually. + +When the gateway is externally supervised, uninstall preserves its process, Docker resources, and OpenShell binaries. It still deletes the selected sandboxes and attempts to remove the modern local gateway registration. When uninstall confirms that no sibling gateways remain, it also deletes NemoClaw provider registrations. For a managed dual-Station vLLM runtime, full uninstall revalidates the exact recorded pair and removes both managed containers before starting the remaining uninstall steps. If that cleanup fails, uninstall exits nonzero, preserves its owner-only cleanup receipt, and tells you to resolve the reported peer error before retrying. Pair cleanup can partially complete before an error; verify both Stations before the retry. For an authenticated host-local vLLM runtime, full uninstall verifies the exact named container, NemoClaw ownership label, persisted API key, and authentication fingerprint before removing the container by its inspected ID. When that ownership state is missing, full uninstall removes the reserved `nemoclaw-vllm` container only when Docker reports its NemoClaw managed label and a valid container ID. An unlabeled container or malformed inspection remains in place and stops the remaining uninstall steps. For managed llama.cpp, full uninstall verifies the exact named container and network ownership before removing both resources by their inspected IDs. These host-local checks run before NemoClaw deletes their state. If Docker is unavailable or a resource does not match its persisted ownership state, uninstall exits nonzero before the remaining uninstall steps and preserves that state for recovery. Host-local cleanup can partially complete before an error. Restore Docker access or resolve the named ownership conflict, inspect the remaining container and network, and retry uninstall. Managed llama.cpp and vLLM model files remain in the shared Hugging Face cache by default. Outside portable cleanup, `--delete-models` deletes every model in the local Ollama inventory and all non-credential data in the current user's shared `~/.cache/huggingface/` cache. This opt-in can delete cached files that other applications installed or use. It preserves the Hugging Face `token` and `stored_tokens` authentication files. NemoClaw stops and verifies its managed local and distributed model runtimes before it deletes non-credential data from the local Hugging Face cache. It does not scan arbitrary directories or delete model caches on remote peers. When sibling gateway environments remain, uninstall preserves both model stores even if you pass `--delete-models`. An Ollama inventory error, model deletion error, unsafe cache path, or cache-data deletion error makes uninstall exit nonzero. Cleanup can partially complete before an error, so resolve the reported error and rerun uninstall. It does not use the legacy `gateway destroy` command for that gateway. + Refer to [Declare the OpenShell Gateway Lifecycle Authority](../deployment/gateway-lifecycle-authority). -Outside portable cleanup, uninstall also stops any orphaned `openshell` host processes left behind by previous onboard or destroy cycles, including `openshell sandbox create`, `openshell ssh-proxy`, and SSH sessions spawned by OpenShell. -Earlier releases only stopped `openshell forward` processes, so those orphans accumulated across runs. +Outside portable cleanup, uninstall also stops any orphaned `openshell` host processes left behind by previous onboard or destroy cycles, including `openshell sandbox create`, `openshell ssh-proxy`, and SSH sessions spawned by OpenShell. Earlier releases only stopped `openshell forward` processes, so those orphans accumulated across runs. -Outside portable cleanup, uninstall also stops matching Ollama auth proxy processes before deleting `~/.nemoclaw` state so stale proxy listeners do not block a later reinstall. -When sibling gateways remain, uninstall leaves the shared proxy running for them. +Outside portable cleanup, uninstall also stops matching Ollama auth proxy processes before deleting `~/.nemoclaw` state so stale proxy listeners do not block a later reinstall. When sibling gateways remain, uninstall leaves the shared proxy running for them. -For Hermes setups, uninstall inspects the selected gateway's managed port-forward watcher state, stops each verified watcher process and its sandbox-scoped forward, and leaves sibling gateway state untouched. -If any watcher or forward cleanup cannot be confirmed, uninstall exits nonzero and preserves the selected gateway's watcher state so you can retry cleanup. +For Hermes setups, uninstall inspects the selected gateway's managed port-forward watcher state, stops each verified watcher process and its sandbox-scoped forward, and leaves sibling gateway state untouched. If any watcher or forward cleanup cannot be confirmed, uninstall exits nonzero and preserves the selected gateway's watcher state so you can retry cleanup. Outside portable cleanup, Linux uninstall removes `~/.local/state/nemoclaw` unless you pass `--keep-openshell`, the gateway is externally supervised, or another gateway-port environment remains on the host. That directory contains NemoClaw-owned Docker-driver gateway configuration and SQLite data, audit logs, VM-driver state, and standalone-fallback gateway PID files. @@ -4711,8 +3089,8 @@ Uninstall preserves that externally supervised directory. This differs from a managed `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` override, which successful managed cleanup removes unless `--keep-openshell` or portable cleanup applies. | Flag | Effect | -|---|---| -| `--yes` | Skip the confirmation prompt. Backups for eligible registered non-portable sandboxes still run before deletion. | +| --- | --- | +| `--yes` | Skip the confirmation prompt | | `--keep-openshell` | Leave OpenShell binaries, NemoClaw-managed gateway service files, and local gateway state in place, and do not stop the host gateway process | | `--delete-models` | Outside portable cleanup, delete every model reported by the host's local Ollama inventory and all non-credential data in the current user's shared `~/.cache/huggingface/` cache after managed model runtimes stop. Hugging Face authentication files remain. Portable cleanup preserves both model stores and every Podman image. | | `--destroy-user-data` | Skip eligible fresh sandbox backups and remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`). Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. Portable cleanup still retains its recovery record until later completed onboarding durably supersedes it. | @@ -4720,12 +3098,12 @@ This differs from a managed `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` override, whi | `--gateway ` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT` | - -For a non-portable Docker Hermes sandbox that uses the NemoClaw-managed image, uninstall removes its managed state volume even without `--destroy-user-data`. -Default uninstall snapshots required Hermes state first. -When using `--destroy-user-data`, back up required state separately before uninstall. -NemoClaw removes only a volume with exact ownership labels; inspection or removal failure exits nonzero and preserves registry state for retry. - + + For a non-portable Docker Hermes sandbox that uses the NemoClaw-managed image, uninstall removes + its managed state volume even without `--destroy-user-data`. Back up required Hermes state + first. NemoClaw removes only a volume with exact ownership labels; inspection or removal failure + exits nonzero and preserves registry state for retry. + ```bash @@ -4836,64 +3214,42 @@ That port still counts as a live sibling, so the final pass falls back to gatewa Cleanup that completed before a port failure is not rolled back. Resolve the reported error, inspect the remaining gateways with `openshell gateway list`, and rerun the sweep or the named per-port command. -##### User-data preservation under `~/.nemoclaw/` +`--all-gateway-ports`, or `NEMOCLAW_UNINSTALL_ALL_GATEWAY_PORTS=1`, uninstalls all of them in one run. The sweep enumerates the default state root and the non-default roots under `~/.nemoclaw/gateways/`. When the sweep finds more than one port, it confirms once against the resulting port list, then uninstalls each other port before the port `NEMOCLAW_GATEWAY_PORT` selects. When it finds only the selected port, it uses the standard uninstall confirmation without a port list and runs that port once. Each port runs as its own uninstall so that every port-scoped value, including the state root, registry file, gateway name, and Docker resource names, resolves from that port rather than from the calling environment. Outside portable cleanup, the selected port runs last so its pass can remove the shared host resources once no other environment remains. `--delete-models`, `--destroy-user-data`, and `--keep-openshell` apply to every port, subject to the portable preservation contract; `--gateway` remains a check against the selected port only. A failure to enumerate the gateway state roots safely stops the sweep before any port uninstall begins. The sweep cannot select an unidentified environment until its gateway port can be determined. A port that fails to uninstall is reported, the sweep continues, and the exit code is nonzero. That port still counts as a live sibling, so the final pass falls back to gateway-scoped cleanup and preserves the shared host resources. Cleanup that completed before a port failure is not rolled back. Resolve the reported error, inspect the remaining gateways with `openshell gateway list`, and rerun the sweep or the named per-port command. -Within each gateway pass, uninstall creates fresh snapshots for eligible registered non-portable sandboxes with the same state capture used by `$$nemoclaw backup-all`. -If a current sandbox cannot be backed up, that gateway pass exits nonzero before OpenShell sandbox deletion. -During `--all-gateway-ports`, the sweep continues with later ports and reports the aggregate failure. -A registry entry confirmed absent from both the selected gateway and Docker is exempt because no sandbox state remains to capture. -`--yes` does not bypass this gate. +##### User-data preservation under `~/.nemoclaw/` -Uninstall preserves the following entries in the selected gateway's state root by default. -The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw/gateways//`. +To avoid uninstall destroying host-side user data, uninstall preserves the following entries in the selected gateway's state root by default. The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw/gateways//`. | Entry | What it holds | -|---|---| +| --- | --- | | `rebuild-backups/` | Host-side snapshots that `$$nemoclaw snapshot create` and `$$nemoclaw backup-all` write. `$$nemoclaw snapshot restore` reads them back after you reinstall. | | `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes. Refer to [Transfer State Manually](../manage-sandboxes/state-and-backups/transfer-state-manually). | | `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. | -Outside portable cleanup, when uninstall confirms that no sibling gateways remain, it also removes shared host resources such as the gateway source clone, runtime state, and the Ollama auth proxy PID file. -When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving those shared host resources. -With `--destroy-user-data`, that scoped path removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified (for example odd `~/.nemoclaw/gateways/` entries or an unreadable gateway list). When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package and the other shared host resources. -If the OpenShell command is unavailable or its gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped path and preserves the shared resources. -When the command itself is unavailable, uninstall exits nonzero before OpenShell cleanup so you can restore the command and retry. +Outside portable cleanup, when uninstall confirms that no sibling gateways remain, it also removes shared host resources such as the gateway source clone, runtime state, and the Ollama auth proxy PID file. When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving those shared host resources. With `--destroy-user-data`, that scoped path removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified (for example odd `~/.nemoclaw/gateways/` entries or an unreadable gateway list). When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package and the other shared host resources. If the OpenShell command is unavailable or its gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped path and preserves the shared resources. When the command itself is unavailable, uninstall exits nonzero before OpenShell cleanup so you can restore the command and retry. -When used alone, `--yes` only acknowledges the global `Proceed?` confirmation prompt, creates fresh backups for eligible registered non-portable sandboxes, and preserves the listed host-side entries. -Removing the preserved entries requires the explicit opt-in flag (`--destroy-user-data`) or the matching env var (`NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`). -Existing automation that uses `--yes` without either data-removal option retains those entries. +When used alone, `--yes` only acknowledges the global `Proceed?` confirmation prompt and preserves the listed host-side entries. Removing the preserved entries requires the explicit opt-in flag (`--destroy-user-data`) or the matching env var (`NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`). Existing automation that uses `--yes` without either data-removal option retains those entries. Decision matrix: | Context | Behaviour | -|---|---| -| Interactive TTY, preserved entries present, no env override | Prompts `Also remove them and skip eligible fresh sandbox backups? [y/N]`. Default `N` creates eligible backups and keeps the entries. | -| Interactive TTY, user answers `y` | Skips eligible fresh sandbox backups and removes the preserved entries in the selected gateway's state root; outside portable cleanup, a single-gateway uninstall also removes the remaining shared state. Portable cleanup preserves shared OpenShell resources and its retirement record. | -| Non-interactive with `--yes` | Creates fresh backups for eligible registered non-portable sandboxes, preserves the entries, and prints their destination. | -| Non-TTY shell without `--yes` | Cannot acknowledge the global `Proceed?` confirmation and aborts before backup or cleanup; use `--yes`. `NEMOCLAW_NON_INTERACTIVE=1` suppresses only the secondary user-data prompt and does not replace `--yes`. | -| `--destroy-user-data` | Skips eligible fresh sandbox backups and the secondary user-data prompt, then removes the preserved entries in the selected gateway's state root. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | -| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips eligible fresh sandbox backups and the secondary user-data prompt, then removes the preserved entries. It does not select the explicit `--destroy-user-data` CLI-shim removal path; shim handling follows the ordinary selected-gateway scope. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | - -The preserved entries survive uninstall as inert files on disk. -Reinstall NemoClaw and re-onboard the sandbox before `$$nemoclaw snapshot restore` can use them. - -The preserved `sandboxes.json` file does not make the recorded sandboxes recoverable on its own. -Uninstall deletes the selected sandboxes and attempts to remove the local gateway registration. -Outside portable cleanup, after uninstall confirms that no sibling gateways remain, it also deletes provider registrations. -Outside portable cleanup, a NemoClaw-managed gateway also removes the Docker image. -For an externally supervised gateway, it preserves Docker resources, but the registry still cannot recover deleted sandbox and provider resources. -Uninstall warns about this at preserve time. -After reinstalling, the installer reports such records as not found on their recorded gateway instead of claiming they were recovered; run `$$nemoclaw destroy` to clear a stranded record, then `$$nemoclaw onboard` to rebuild it. -Pass `--destroy-user-data` at uninstall time if you prefer to purge the registry along with its dependencies. +| --- | --- | +| Interactive TTY, preserved entries present, no env override | Prompts `Also remove them? [y/N]`. Default `N` keeps the entries. | +| Interactive TTY, user answers `y` | Removes the preserved entries in the selected gateway's state root; outside portable cleanup, a single-gateway uninstall also removes the remaining shared state. Portable cleanup preserves shared OpenShell resources and its retirement record. | +| Non-interactive (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or non-TTY shell) | Preserves the entries and prints a one-line notice. | +| `--destroy-user-data` | Skips the secondary user-data prompt and removes the preserved entries in the selected gateway's state root. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | +| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the secondary user-data prompt and removes the preserved entries. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | + +The preserved entries survive uninstall as inert files on disk. Reinstall NemoClaw and re-onboard the sandbox before `$$nemoclaw snapshot restore` can use them. + +The preserved `sandboxes.json` file does not make the recorded sandboxes recoverable on its own. Uninstall deletes the selected sandboxes and attempts to remove the local gateway registration. Outside portable cleanup, after uninstall confirms that no sibling gateways remain, it also deletes provider registrations. Outside portable cleanup, a NemoClaw-managed gateway also removes the Docker image. For an externally supervised gateway, it preserves Docker resources, but the registry still cannot recover deleted sandbox and provider resources. Uninstall warns about this at preserve time. After reinstalling, the installer reports such records as not found on their recorded gateway instead of claiming they were recovered; run `$$nemoclaw destroy` to clear a stranded record, then `$$nemoclaw onboard` to rebuild it. Pass `--destroy-user-data` at uninstall time if you prefer to purge the registry along with its dependencies. #### `$$nemoclaw uninstall` vs. the hosted `uninstall.sh` -Both forms execute the same `uninstall.sh` with the same flags, but differ in where the script comes from and how much they trust the network. -Use `$$nemoclaw uninstall` by default. -Use the hosted `curl … | bash` form only when the CLI is broken or already partially removed. +Both forms execute the same `uninstall.sh` with the same flags, but differ in where the script comes from and how much they trust the network. Use `$$nemoclaw uninstall` by default. Use the hosted `curl … | bash` form only when the CLI is broken or already partially removed. | | `$$nemoclaw uninstall` | `curl … \| bash` (Quickstart) | -|---|---|---| +| --- | --- | --- | | **Source of the script** | Local `uninstall.sh` shipped with the installed npm package. | Pulled live from `refs/heads/main` on GitHub. | | **Version pinning** | Pinned to the version of NemoClaw you installed. | Whatever is on `main` right now; may be newer than your installed CLI. | | **Network trust** | No network fetch at uninstall time; runs a vetted local file via `bash`. | Pipes a remote script straight to `bash` with no review step. | @@ -4902,23 +3258,14 @@ Use the hosted `curl … | bash` form only when the CLI is broken or already par ## Internal Commands -NemoClaw registers a hidden `internal` command namespace. These commands are -compatibility entrypoints for repo-owned scripts, such as the installer, the -uninstaller, DNS setup, and developer tooling. They are not part of the -supported public CLI surface. +NemoClaw registers a hidden `internal` command namespace. These commands are compatibility entrypoints for repo-owned scripts, such as the installer, the uninstaller, DNS setup, and developer tooling. They are not part of the supported public CLI surface. -Each command class sets `hidden = true`, so the commands stay out of -`$$nemoclaw --help`. They remain registered and routable, which is why they are -listed here for reference. Treat their names, flags, and output as -implementation details. They exist to back `install.sh`, `uninstall.sh`, and -related automation, and they may change or be removed without notice. Most run -indirectly through those scripts rather than being typed by hand. +Each command class sets `hidden = true`, so the commands stay out of `$$nemoclaw --help`. They remain registered and routable, which is why they are listed here for reference. Treat their names, flags, and output as implementation details. They exist to back `install.sh`, `uninstall.sh`, and related automation, and they may change or be removed without notice. Most run indirectly through those scripts rather than being typed by hand. -For contributor guidance on how these command files are structured, refer to -`src/commands/internal/README.md`. +For contributor guidance on how these command files are structured, refer to `src/commands/internal/README.md`. | Command | Owning script context | Purpose | -|---------|-----------------------|---------| +| --- | --- | --- | | `$$nemoclaw internal installer plan` | `install.sh` | Build a deterministic installer plan from environment and probe inputs without applying it. | | `$$nemoclaw internal installer normalize-env` | `install.sh` | Normalize installer ref and provider environment values without applying installation changes. | | `$$nemoclaw internal installer resolve-release-tag` | `install.sh` | Resolve the installer ref using the same precedence as `install.sh`. | @@ -4929,14 +3276,9 @@ For contributor guidance on how these command files are structured, refer to | `$$nemoclaw internal dns fix-coredns` | onboarding / sandbox setup | Patch CoreDNS to use a non-loopback upstream resolver. | | `$$nemoclaw internal dev npm-link-or-shim` | `scripts/npm-link-or-shim.sh` (development) | Run `npm link`, falling back to a user-local NemoClaw development shim. | -These commands do not appear in the command-level parity check, which compares -`$$nemoclaw --help` against the public command headings in this reference; hidden -commands are excluded from both. The table above is the canonical reference for -the script-backed family. -The experimental adapter is documented separately because it has no owning script. +These commands do not appear in the command-level parity check, which compares `$$nemoclaw --help` against the public command headings in this reference; hidden commands are excluded from both. The table above is the canonical reference for the script-backed family. The experimental adapter is documented separately because it has no owning script. -`$$nemoclaw internal voice-gateway serve` is registered for the OpenClaw-only experimental adapter described below. -Hermes and Deep Agents Code do not have an equivalent adapter. +`$$nemoclaw internal voice-gateway serve` is registered for the OpenClaw-only experimental adapter described below. Hermes and Deep Agents Code do not have an equivalent adapter. @@ -4944,46 +3286,22 @@ Hermes and Deep Agents Code do not have an equivalent adapter. -This hidden command is an experimental implementation detail, not a supported NemoClaw product surface or public protocol. -Do not expose its listener through a port forward, proxy, or public ingress. +This hidden command is an experimental implementation detail, not a supported NemoClaw product surface or public protocol. Do not expose its listener through a port forward, proxy, or public ingress. -This foreground command runs a private HTTP adapter that streams newline-delimited JSON (NDJSON) responses. -It accepts one authenticated runtime deployment and at most one active voice session. -Each voice session accepts one committed text turn. -NemoClaw selects the runtime profile, sandbox, and OpenClaw agent from command-line configuration. -The runtime cannot select an agent, OpenClaw session key, upstream URL, or forwarding destination. +This foreground command runs a private HTTP adapter that streams newline-delimited JSON (NDJSON) responses. It accepts one authenticated runtime deployment and at most one active voice session. Each voice session accepts one committed text turn. NemoClaw selects the runtime profile, sandbox, and OpenClaw agent from command-line configuration. The runtime cannot select an agent, OpenClaw session key, upstream URL, or forwarding destination. -The command requires the feature gate `NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY=1`. -Any other value stops the command before argument parsing, credential reads, or listener creation. -Other experimental feature gates do not enable this command. +The command requires the exact feature gate `NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY=1`. Any other value stops the command before argument parsing, credential reads, or listener creation. Other experimental feature gates do not enable this command. Before starting the adapter, the bounded voice-gateway launcher opens two owner-only regular files without following symbolic links and maps only these inherited file descriptors into the child process: - Descriptor `3` supplies the deployment credential used during session admission. - Descriptor `4` supplies the OpenClaw credential with `operator.read` and `operator.write` scopes. -The descriptor numbers are fixed and are not configurable flags. -The command rejects missing, duplicate, non-regular, wrong-owner, group-accessible, malformed, and oversized inputs. -It reads each descriptor once and closes both before accepting traffic. -Both credential values remain in process memory until the voice-gateway process stops. -The launcher does not place credential source paths or values in arguments or environment variables. -It intentionally inherits only credential descriptors `3` and `4` across this exec, then closes its parent copies. -To rotate either credential, stop the command and restart it with newly opened descriptors. -NemoClaw does not send the OpenClaw credential to the runtime. -The trusted caller selects each credential source path and invokes the launcher. -That caller must create, replace, and remove each source file and revoke old credential values. -The launcher opens each source file, maps the inherited child descriptors, and closes its parent copies. -The child validates, reads, and closes descriptors `3` and `4` before it accepts traffic. -Closing the descriptors or stopping the gateway does not remove a source file or revoke its credential. - -The launcher is an internal library boundary for trusted external integrations, not another CLI command and not part of normal NemoClaw managed startup. -Callers use the shipped `runVoiceGatewayLaunch()` action with trusted source paths and runtime fields; package-contract coverage launches the real internal command through that production entry point and verifies descriptor cleanup and restart-based credential rotation. -If parent descriptor cleanup fails and bounded termination does not observe child exit, the action throws `VoiceGatewayTerminationUnconfirmedError` with the retained child handle and original cleanup failure. -The trusted caller must recognize that error, terminate and reap its `child`, and confirm exit before starting another gateway. -The launcher emits the following child-process contract with no credential paths or values in its arguments or environment. -Do not run this child command directly because it requires the launcher's descriptor mapping. +The descriptor numbers are fixed and are not configurable flags. The command rejects missing, duplicate, non-regular, wrong-owner, group-accessible, malformed, and oversized inputs. It reads each descriptor once and closes both before accepting traffic. Both credential values remain in process memory until the voice-gateway process stops. The launcher does not place credential source paths or values in arguments or environment variables. It intentionally inherits only credential descriptors `3` and `4` across this exec, then closes its parent copies. To rotate either credential, stop the command and restart it with newly opened descriptors. NemoClaw does not send the OpenClaw credential to the runtime. The trusted caller selects each credential source path and invokes the launcher. That caller must create, replace, and remove each source file and revoke old credential values. The launcher opens each source file, maps the inherited child descriptors, and closes its parent copies. The child validates, reads, and closes descriptors `3` and `4` before it accepts traffic. Closing the descriptors or stopping the gateway does not remove a source file or revoke its credential. + +The launcher is an internal library boundary for trusted external integrations, not another CLI command and not part of normal NemoClaw managed startup. Callers use the shipped `runVoiceGatewayLaunch()` action with trusted source paths and runtime fields; package-contract coverage launches the real internal command through that production entry point and verifies descriptor cleanup and restart-based credential rotation. If parent descriptor cleanup fails and bounded termination does not observe child exit, the action throws `VoiceGatewayTerminationUnconfirmedError` with the retained child handle and original cleanup failure. The trusted caller must recognize that error, terminate and reap its `child`, and confirm exit before starting another gateway. The launcher emits the following child-process contract with no credential paths or values in its arguments or environment. Do not run this child command directly because it requires the launcher's descriptor mapping. ```text NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY=1 $$nemoclaw internal voice-gateway serve \ @@ -4995,24 +3313,11 @@ NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY=1 $$nemoclaw internal voice-gateway serve \ --listen-port 18800 ``` -The `--gateway-url` value must use `ws://`, an explicit port, the `/ws` path, and a loopback IP address literal. -The URL must not contain credentials, a query string, or a fragment. -The adapter binds only to `127.0.0.1` and uses port `18800` when you omit `--listen-port`. -The JSON diagnostic with `"event":"voice_gateway"` and `"state":"listening"` confirms that the adapter acquired the configured loopback listener. +The `--gateway-url` value must use `ws://`, an explicit port, the `/ws` path, and a loopback IP address literal. The URL must not contain credentials, a query string, or a fragment. The adapter binds only to `127.0.0.1` and uses port `18800` when you omit `--listen-port`. The JSON diagnostic with `"event":"voice_gateway"` and `"state":"listening"` confirms that the adapter acquired the configured loopback listener. -The runtime must keep the raw session grant in process memory. -NemoClaw keeps only the digest needed for constant-time grant validation. -Sequential sessions for the same runtime conversation reuse the same internal OpenClaw context. -A different runtime conversation, runtime identity, runtime profile, sandbox, or agent uses a separate context. -NemoClaw derives the internal context key without exposing the raw runtime conversation ID. -When the session closes or expires, the runtime must discard the raw grant, and NemoClaw removes its validation digest from active session state. -The process owns the OpenClaw session binding, turn state, and response correlation. -It clears that state when the session closes or expires and when the foreground process stops. -Normal onboarding and managed startup do not start or supervise this command. +The runtime must keep the raw session grant in process memory. NemoClaw keeps only the digest needed for constant-time grant validation. Sequential sessions for the same runtime conversation reuse the same internal OpenClaw context. A different runtime conversation, runtime identity, runtime profile, sandbox, or agent uses a separate context. NemoClaw derives the internal context key without exposing the raw runtime conversation ID. When the session closes or expires, the runtime must discard the raw grant, and NemoClaw removes its validation digest from active session state. The process owns the OpenClaw session binding, turn state, and response correlation. It clears that state when the session closes or expires and when the foreground process stops. Normal onboarding and managed startup do not start or supervise this command. -The adapter exchanges committed text and normalized response events only. -It does not implement audio, WebRTC, RTVI, voice activity detection, speech recognition, speech synthesis, playback, or runtime-specific UI behavior. -It also does not establish VoiceClaw, ElevenLabs, Hermes, or general voice support. +The adapter exchanges committed text and normalized response events only. It does not implement audio, WebRTC, RTVI, voice activity detection, speech recognition, speech synthesis, playback, or runtime-specific UI behavior. It also does not establish VoiceClaw, ElevenLabs, Hermes, or general voice support. @@ -5023,73 +3328,56 @@ The experimental voice gateway has no Hermes or Deep Agents Code equivalent. ## Environment Variables -NemoClaw reads the following environment variables to configure service ports, onboarding behavior, and lifecycle defaults. -Set them before running `$$nemoclaw onboard` or any command that starts services. -All ports must be non-privileged integers between 1024 and 65535, unless a variable's own description gives a narrower range. +NemoClaw reads the following environment variables to configure service ports, onboarding behavior, and lifecycle defaults. Set them before running `$$nemoclaw onboard` or any command that starts services. All ports must be non-privileged integers between 1024 and 65535, unless a variable's own description gives a narrower range. ### CLI Logging -The centralized CLI logger writes its output to `stderr` and uses `info` verbosity by default. -These controls affect leveled logger output; they do not suppress command results or command-specific output that has not migrated to the centralized logger. +The centralized CLI logger writes its output to `stderr` and uses `info` verbosity by default. These controls affect leveled logger output; they do not suppress command results or command-specific output that has not migrated to the centralized logger. | Variable | Accepted values | Effect | -|----------|-----------------|--------| +| --- | --- | --- | | `NEMOCLAW_LOG_LEVEL` | `error`, `warn`, `info`, or `debug` (case-insensitive; surrounding whitespace is ignored) | Sets the logging threshold. A valid value takes precedence over `NEMOCLAW_DEBUG`. An invalid, blank, or unset value falls through to `NEMOCLAW_DEBUG`. | | `NEMOCLAW_DEBUG` | `1`, `true`, `y`, or `yes` (case-insensitive) | Enables `debug` logging when `NEMOCLAW_LOG_LEVEL` does not contain a valid value. | -The environment precedence is `NEMOCLAW_LOG_LEVEL`, then `NEMOCLAW_DEBUG`, followed by the default `info` level. -The `error` level prints errors only, `warn` also prints warnings, `info` also prints informational messages, and `debug` prints all levels with timestamps. -Use these NemoClaw-specific variables instead of the generic `DEBUG` variable. `DEBUG` is not a NemoClaw logger control and can enable dependency diagnostics that include raw command arguments. +The environment precedence is `NEMOCLAW_LOG_LEVEL`, then `NEMOCLAW_DEBUG`, followed by the default `info` level. The `error` level prints errors only, `warn` also prints warnings, `info` also prints informational messages, and `debug` prints all levels with timestamps. Use these NemoClaw-specific variables instead of the generic `DEBUG` variable. `DEBUG` is not a NemoClaw logger control and can enable dependency diagnostics that include raw command arguments. -Commands whose parser owns the base logging options also accept the hidden long-form `--debug` and `--quiet` flags, even though these options do not appear in command help. -The flags are mutually exclusive. -`--debug` overrides the environment-derived threshold and selects `debug`, while `--quiet` caps verbosity at `warn` without increasing an environment-derived `error` threshold. -There is no global `-q` logging shorthand. -Passthrough commands do not consume flags intended for the downstream command as host logging options, so use the environment variables when you need unambiguous host logging around a passthrough invocation. +Commands whose parser owns the base logging options also accept the hidden long-form `--debug` and `--quiet` flags, even though these options do not appear in command help. The flags are mutually exclusive. `--debug` overrides the environment-derived threshold and selects `debug`, while `--quiet` caps verbosity at `warn` without increasing an environment-derived `error` threshold. There is no global `-q` logging shorthand. Passthrough commands do not consume flags intended for the downstream command as host logging options, so use the environment variables when you need unambiguous host logging around a passthrough invocation. | Variable | Default | Service | -|----------|---------|---------| +| --- | --- | --- | | `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port | | `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | The OpenShell gateway uses this bind address. NemoClaw keeps Docker-driver gateways on loopback while gateway JWT auth is active. | + -| `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard or API forward | - -| `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference | -| `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference | -| `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy | -| `NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT` | 11436 | Host-side Bedrock Runtime adapter | -| `NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` | 11437 | Host-side OpenRouter runtime adapter | -| `NEMOCLAW_HTTPS_PIN_RUNTIME_ADAPTER_PORT` | 11438 | Host-side HTTPS Pin Runtime adapter | + | `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard or + API forward | + +| `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference | | `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama +inference | | `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy | | +`NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT` | 11436 | Host-side Bedrock Runtime adapter | | +`NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` | 11437 | Host-side OpenRouter runtime adapter | | +`NEMOCLAW_HTTPS_PIN_RUNTIME_ADAPTER_PORT` | 11438 | Host-side HTTPS Pin Runtime adapter | -| `NEMOCLAW_DASHBOARD_BIND` | *unset* (loopback outside WSL) | Dashboard or API forward bind address. WSL uses an all-interface forward for Windows-host reachability. Set to `0.0.0.0` to opt in to remote bind on other SSH-deployed hosts. | + | `NEMOCLAW_DASHBOARD_BIND` | *unset* (loopback outside WSL) | Dashboard or API forward bind + address. WSL uses an all-interface forward for Windows-host reachability. Set to `0.0.0.0` to opt + in to remote bind on other SSH-deployed hosts. | -| `NEMOCLAW_GATEWAY_WS_HOST` | *unset* (auto-derived inside the sandbox; loopback elsewhere) | Host used for the in-sandbox `OPENCLAW_GATEWAY_URL`; inside the sandbox it defaults to the primary interface address so `sessions_spawn` sub-agents can dial the gateway through the enforced network path. | + | `NEMOCLAW_GATEWAY_WS_HOST` | *unset* (auto-derived inside the sandbox; loopback elsewhere) | + Host used for the in-sandbox `OPENCLAW_GATEWAY_URL`; inside the sandbox it defaults to the primary + interface address so `sessions_spawn` sub-agents can dial the gateway through the enforced network + path. | -If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error. -`NEMOCLAW_GATEWAY_PORT` also cannot overlap configured service, vLLM, Ollama, Ollama proxy, Bedrock Runtime adapter, OpenRouter runtime adapter, or HTTPS Pin Runtime adapter ports, and cannot use reserved auto-allocation ranges or the default inference/proxy ports `8000`, `8081`, `11434`, `11435`, `11436`, `11437`, and `11438`. -Port `8081` is reserved for authenticated existing-server attachment and the managed llama.cpp runtime. -It cannot be assigned to another configurable NemoClaw service port. -Each runtime adapter port must be distinct from the gateway, vLLM, Ollama, Ollama proxy, dashboard allocation range, and other runtime adapter ports. -When you run multiple NemoClaw gateways with different `NEMOCLAW_GATEWAY_PORT` values, NemoClaw derives a separate gateway name, state directory, and compatibility container name from the port so one gateway does not tear down another. -Only port `8080` uses a NemoClaw-managed Linux systemd user service or macOS Homebrew service. -NemoClaw-managed gateways on custom ports run as detached processes and do not change the default gateway service. -An externally supervised gateway can use any matching configured port and must be recovered through its declared supervisor. -On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different. -If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding. +If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error. `NEMOCLAW_GATEWAY_PORT` also cannot overlap configured service, vLLM, Ollama, Ollama proxy, Bedrock Runtime adapter, OpenRouter runtime adapter, or HTTPS Pin Runtime adapter ports, and cannot use reserved auto-allocation ranges or the default inference/proxy ports `8000`, `8081`, `11434`, `11435`, `11436`, `11437`, and `11438`. Port `8081` is reserved for authenticated existing-server attachment and the managed llama.cpp runtime. It cannot be assigned to another configurable NemoClaw service port. Each runtime adapter port must be distinct from the gateway, vLLM, Ollama, Ollama proxy, dashboard allocation range, and other runtime adapter ports. When you run multiple NemoClaw gateways with different `NEMOCLAW_GATEWAY_PORT` values, NemoClaw derives a separate gateway name, state directory, and compatibility container name from the port so one gateway does not tear down another. Only port `8080` uses a NemoClaw-managed Linux systemd user service or macOS Homebrew service. NemoClaw-managed gateways on custom ports run as detached processes and do not change the default gateway service. An externally supervised gateway can use any matching configured port and must be recovered through its declared supervisor. On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different. If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding. `NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`, but NemoClaw rejects `0.0.0.0` for Docker-driver gateways while gateway JWT auth is active. + Keep the OpenShell gateway on loopback and use `NEMOCLAW_DASHBOARD_BIND` when you need remote browser/API access. -`NEMOCLAW_DASHBOARD_BIND` controls the dashboard or API port forward bind address. -Outside WSL, the forward stays on `127.0.0.1` (loopback only) by default. -On WSL, NemoClaw binds the host-side forward on all interfaces so the Windows host can reach it, while the ready summary continues to print a loopback dashboard URL. -On non-WSL SSH-deployed hosts, set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` before `$$nemoclaw onboard` to prepare the sandbox for remote exposure and bind the forward on all interfaces. -Use the same setting for later `$$nemoclaw connect` calls. -A sandbox created without this opt-in must be recreated with `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw onboard --recreate-sandbox` before a remote-bind connect is allowed. -Only `0.0.0.0` enables the remote bind; onboarding rejects any other non-empty value. +`NEMOCLAW_DASHBOARD_BIND` controls the dashboard or API port forward bind address. Outside WSL, the forward stays on `127.0.0.1` (loopback only) by default. On WSL, NemoClaw binds the host-side forward on all interfaces so the Windows host can reach it, while the ready summary continues to print a loopback dashboard URL. On non-WSL SSH-deployed hosts, set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` before `$$nemoclaw onboard` to prepare the sandbox for remote exposure and bind the forward on all interfaces. Use the same setting for later `$$nemoclaw connect` calls. A sandbox created without this opt-in must be recreated with `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw onboard --recreate-sandbox` before a remote-bind connect is allowed. Only `0.0.0.0` enables the remote bind; onboarding rejects any other non-empty value. + @@ -5103,8 +3391,8 @@ export NEMOCLAW_DASHBOARD_PORT=19000 $$nemoclaw onboard ``` -These overrides apply to onboarding, status checks, health probes, and the uninstaller. -Defaults are unchanged when no variable is set. +These overrides apply to onboarding, status checks, health probes, and the uninstaller. Defaults are unchanged when no variable is set. + If `NEMOCLAW_DASHBOARD_PORT` or the port from `CHAT_UI_URL` is already occupied by another sandbox, onboarding scans `18789` through `18799` and uses the next free dashboard port. Pass `--control-ui-port ` to require a specific port. @@ -5117,30 +3405,22 @@ For OpenClaw, `NEMOCLAW_DASHBOARD_PORT` controls the OpenClaw dashboard forward. -For Hermes, `NEMOCLAW_DASHBOARD_PORT` controls the built-in dashboard forward, which defaults to `18789`. -The OpenAI-compatible API is separate and serves `/v1` on a per-sandbox port that defaults to `8642`. -If `8642` is already held by another sandbox or by a non-OpenShell host listener, onboarding scans `8642` through `8652` and uses the next free API port. -Set `NEMOCLAW_HERMES_API_PORT=` before you onboard a new sandbox to pin a port from `8642` through `8652`. -If another sandbox or host listener holds the pinned port, onboarding exits instead of selecting a different port. -The sandbox relay binds the port when the sandbox starts, so set this variable for an existing sandbox only together with `--recreate-sandbox`. -For an existing sandbox, a different value without `--recreate-sandbox` exits before the host forward changes. -Set `NEMOCLAW_HERMES_DASHBOARD_TUI=1` only when you want Hermes' optional in-browser TUI tab. +For Hermes, `NEMOCLAW_DASHBOARD_PORT` controls the built-in dashboard forward, which defaults to `18789`. The OpenAI-compatible API is separate and serves `/v1` on a per-sandbox port that defaults to `8642`. If `8642` is already held by another sandbox or by a non-OpenShell host listener, onboarding scans `8642` through `8652` and uses the next free API port. Set `NEMOCLAW_HERMES_API_PORT=` before you onboard a new sandbox to pin a port from `8642` through `8652`. If another sandbox or host listener holds the pinned port, onboarding exits instead of selecting a different port. The sandbox relay binds the port when the sandbox starts, so set this variable for an existing sandbox only together with `--recreate-sandbox`. For an existing sandbox, a different value without `--recreate-sandbox` exits before the host forward changes. Set `NEMOCLAW_HERMES_DASHBOARD_TUI=1` only when you want Hermes' optional in-browser TUI tab. -| Variable | Default | Service | -|----------|---------|---------| -| `NEMOCLAW_DASHBOARD_PORT` | 18789 | Hermes built-in dashboard forward port | -| `NEMOCLAW_HERMES_API_PORT` | 8642 | Hermes OpenAI-compatible API forward port | -| `NEMOCLAW_HERMES_DASHBOARD_TUI` | 0 | Optional Hermes in-browser TUI tab | +| Variable | Default | Service | +| ------------------------------- | ------- | ----------------------------------------- | +| `NEMOCLAW_DASHBOARD_PORT` | 18789 | Hermes built-in dashboard forward port | +| `NEMOCLAW_HERMES_API_PORT` | 8642 | Hermes OpenAI-compatible API forward port | +| `NEMOCLAW_HERMES_DASHBOARD_TUI` | 0 | Optional Hermes in-browser TUI tab | ### Onboarding Configuration -The following variables let you tune onboarding without editing the Dockerfile or passing repeated flags. -Set them before running `$$nemoclaw onboard`. +The following variables let you tune onboarding without editing the Dockerfile or passing repeated flags. Set them before running `$$nemoclaw onboard`. | Variable | Format | Effect | -|----------|--------|--------| +| --- | --- | --- | | `NEMOCLAW_PROVIDER` | provider key (e.g. `build`, `openrouter`, `openai`, `anthropic`, `anthropicCompatible`, `gemini`, `ollama`, `custom`, `vllm`, `nim-local`, `routed`, `hermes-provider`, `llama-cpp`, `install-llama-cpp`, `install-vllm`, `install-ollama`, `install-windows-ollama`, `start-windows-ollama`) | Selects the inference provider during onboarding. The wizard skips the provider menu in both interactive and non-interactive runs when this is set. On a qualified N1x host, only `install-vllm` supplies the explicit Deferred preview intent; another provider stops installation before onboarding. `llama-cpp` selects attachment of an authenticated, operator-managed llama.cpp server on loopback port `8081`. Set `NEMOCLAW_LLAMACPP_LOCAL_TOKEN`; set `NEMOCLAW_MODEL` to the served alias when the server exposes multiple models. `install-llama-cpp` selects the experimental DGX Spark managed path and rejects `NEMOCLAW_MODEL`; use `NEMOCLAW_LLAMACPP_RECIPE` for its declarative selection. If an operator-managed server does not provide consistent native llama.cpp evidence, select `custom`. Aliases: `cloud` → `build`, `open-router` / `openrouterai` → `openrouter`, `nim` → `nim-local`, `hermes` / `nous` / `nous-portal` → `hermes-provider`, `anthropiccompatible` → `anthropicCompatible`. Invalid values fail fast with the list of accepted keys. | | `NEMOCLAW_LLAMACPP_RECIPE` | repository-owned managed-inference recipe ID | Selects the managed llama.cpp recipe when `NEMOCLAW_PROVIDER=install-llama-cpp`, including a compatible lower-priority profile. When unset, NemoClaw selects the unique highest-priority compatible automatic profile. An unknown recipe, an ambiguous selection, or a stale or incompatible readiness report fails before image, model, or runtime effects. | | `NEMOCLAW_MODEL` | model ID | Selects an explicit model for a non-interactive onboarding run. NemoClaw preserves it across a detected provider switch, even when it matches the recorded provider's default. When this variable is unset during such a switch, NemoClaw ignores the `NEMOCLAW_PROVIDER_MODEL` compatibility fallback and uses normal provider model selection. | @@ -5154,36 +3434,83 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location. Refer to the Linux Ollama install mode details below. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | + -| `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | OpenClaw only. Declares model input modalities. Unsupported or duplicate values are rejected. | -| `NEMOCLAW_OPENCLAW_OTEL` | `1` to enable | Enables OpenClaw conversation diagnostics export through the `diagnostics-otel` plugin. Disabled by default. | -| `NEMOCLAW_OPENCLAW_OTEL_ENDPOINT` | OTLP/HTTP URL | Sets the OpenTelemetry collector endpoint for OpenClaw diagnostics. Defaults to `http://host.openshell.internal:4318` when `NEMOCLAW_OPENCLAW_OTEL=1`. | -| `NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME` | service name | Sets the OTEL `service.name` for OpenClaw gateway spans. Defaults to `openclaw-gateway`. | -| `NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE` | `0.0` to `1.0` | Sets OpenClaw's root-span sample rate for conversation diagnostics. Defaults to `1.0`. | - -| `NEMOCLAW_OPENSHELL_BIN` | path | Overrides the `openshell` binary the CLI invokes. Defaults to `openshell` (resolved via `PATH`). | -| `NEMOCLAW_SANDBOX_NAME` | sandbox name | Preferred environment override for the default sandbox. Used by onboarding defaults and host-level commands such as `list`, `status`, `tunnel`, `services`, and `debug`. | -| `NEMOCLAW_SANDBOX` | sandbox name | Alternate spelling of `NEMOCLAW_SANDBOX_NAME`; used when neither a flag nor `NEMOCLAW_SANDBOX_NAME` is set. | -| `SANDBOX_NAME` | sandbox name | Compatibility spelling used after `NEMOCLAW_SANDBOX_NAME` and `NEMOCLAW_SANDBOX`. | -| `NEMOCLAW_INSTALL_REF` | git ref | For internal installer commands: the git ref to install from. A nonempty value takes precedence over `NEMOCLAW_INSTALL_TAG`. Overridden by the `--install-ref` flag. | -| `NEMOCLAW_INSTALL_TAG` | release tag | For internal installer commands: the release tag to install when `NEMOCLAW_INSTALL_REF` is unset or empty. Defaults to the admin-promoted `lkg` tag when unset. Overridden by the `--install-tag` flag. | -| `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE` | `1` to enable | Enables the fixed vLLM local model profile. Requires `NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm`. Direct `$$nemoclaw onboard` use also requires `NEMOCLAW_NON_INTERACTIVE=1`. The hosted installer makes onboarding non-interactive, disables Express selection, and sets this value automatically when it receives `--local-model-runtime=vllm`. | -| `NEMOCLAW_LOCAL_MODEL_RUNTIME` | `vllm` | Selects the fixed vLLM local model profile. Requires `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1`; direct onboarding also requires `NEMOCLAW_NON_INTERACTIVE=1`. The hosted installer sets this value from `--local-model-runtime=vllm`. | -| `NEMOCLAW_VLLM_MODEL` | registry slug or Hugging Face model ID | Selects the model the managed-vLLM install path serves and remains authoritative during DGX Station installer setup. Slugs and full model IDs are case-insensitive. Recognized slugs: `qwen3.6-27b`, `qwen3.6-35b-a3b-nvfp4`, `muse-glimmer-30b`, `nemotron-3.5-lightning-30b`, `nemotron-3-nano-4b`, `deepseek-v4-flash`, `nemotron-3-ultra-550b-a55b`, `deepseek-r1-distill-70b`. The `muse-glimmer-30b` and `nemotron-3.5-lightning-30b` profiles are Experimental on DGX Spark and Linux x86_64 with a qualifying NVIDIA GPU. NemoClaw does not enable vision or DFlash speculative decoding for Muse Glimmer. Station Express selects `nemotron-3-ultra-550b-a55b`; a qualified reciprocal pair uses the distributed topology, while no qualifying pair retains the single-Station Ultra topology. Outside Station Express, unset uses the per-platform profile default. Gated models (for example, `deepseek-r1-distill-70b`) require `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN`. | -| `NEMOCLAW_DGX_STATION_PEER` | SSH host or `user@host` | Selects one already-trusted DGX Station peer for Nemotron 3 Ultra pair qualification. The peer must match the reciprocal private `/30` rail and hardware checks; an explicit peer failure stops setup instead of falling back. NemoClaw does not enroll SSH trust or accept a port or SSH option in this value. When unset, DGX Station installer discovery checks only the two deterministic `/30` counterpart addresses. A peer cannot be combined with an explicit non-Ultra model; conflicting explicit selections fail before pair preparation. | -| `NEMOCLAW_DGX_STATION_SSH_BINDING` | opaque installer-managed token | Carries the qualified peer endpoint and host-key binding from DGX Station pair preparation into the current managed-vLLM install. The installer creates and clears this token; operators should not set or persist it. Missing, changed, or mismatched binding state fails before peer SSH or Docker work. | -| `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` | JSON array of non-blank strings | Appends advanced operator-owned tokens to the managed `vllm serve` command after NemoClaw's registry defaults. Example: `["--max-num-seqs","2"]`. Malformed JSON, non-string tokens, blank tokens, or an invalid `--gpu-memory-utilization` override fail before Docker work starts. The last memory-utilization override also controls the early and immediate pre-launch GPU-memory checks. | + | `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | OpenClaw only. + Declares model input modalities. Unsupported or duplicate values are rejected. | | + `NEMOCLAW_OPENCLAW_OTEL` | `1` to enable | Enables OpenClaw conversation diagnostics export + through the `diagnostics-otel` plugin. Disabled by default. | | `NEMOCLAW_OPENCLAW_OTEL_ENDPOINT` + | OTLP/HTTP URL | Sets the OpenTelemetry collector endpoint for OpenClaw diagnostics. Defaults to + `http://host.openshell.internal:4318` when `NEMOCLAW_OPENCLAW_OTEL=1`. | | + `NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME` | service name | Sets the OTEL `service.name` for OpenClaw + gateway spans. Defaults to `openclaw-gateway`. | | `NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE` | `0.0` to + `1.0` | Sets OpenClaw's root-span sample rate for conversation diagnostics. Defaults to `1.0`. | + +| `NEMOCLAW_OPENSHELL_BIN` | path | Overrides the `openshell` binary the CLI invokes. Defaults to +`openshell` (resolved via `PATH`). | | `NEMOCLAW_SANDBOX_NAME` | sandbox name | Preferred +environment override for the default sandbox. Used by onboarding defaults and host-level commands +such as `list`, `status`, `tunnel`, `services`, and `debug`. | | `NEMOCLAW_SANDBOX` | sandbox name | +Alternate spelling of `NEMOCLAW_SANDBOX_NAME`; used when neither a flag nor `NEMOCLAW_SANDBOX_NAME` +is set. | | `SANDBOX_NAME` | sandbox name | Compatibility spelling used after +`NEMOCLAW_SANDBOX_NAME` and `NEMOCLAW_SANDBOX`. | | `NEMOCLAW_INSTALL_REF` | git ref | For internal +installer commands: the git ref to install from. A nonempty value takes precedence over +`NEMOCLAW_INSTALL_TAG`. Overridden by the `--install-ref` flag. | | `NEMOCLAW_INSTALL_TAG` | release +tag | For internal installer commands: the release tag to install when `NEMOCLAW_INSTALL_REF` is +unset or empty. Defaults to the admin-promoted `lkg` tag when unset. Overridden by the +`--install-tag` flag. | | `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE` | `1` to enable | Enables the fixed +vLLM local model profile. Requires `NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm`. Direct `$$nemoclaw onboard` +use also requires `NEMOCLAW_NON_INTERACTIVE=1`. The hosted installer makes onboarding +non-interactive, disables Express selection, and sets this value automatically when it receives +`--local-model-runtime=vllm`. | | `NEMOCLAW_LOCAL_MODEL_RUNTIME` | `vllm` | Selects the fixed vLLM +local model profile. Requires `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1`; direct onboarding also +requires `NEMOCLAW_NON_INTERACTIVE=1`. The hosted installer sets this value from +`--local-model-runtime=vllm`. | | `NEMOCLAW_VLLM_MODEL` | registry slug or Hugging Face model ID | +Selects the model the managed-vLLM install path serves and remains authoritative during DGX Station +installer setup. Slugs and full model IDs are case-insensitive. Recognized slugs: `qwen3.6-27b`, +`qwen3.6-35b-a3b-nvfp4`, `muse-glimmer-30b`, `nemotron-3.5-lightning-30b`, `nemotron-3-nano-4b`, +`deepseek-v4-flash`, `nemotron-3-ultra-550b-a55b`, `deepseek-r1-distill-70b`. The `muse-glimmer-30b` +and `nemotron-3.5-lightning-30b` profiles are Experimental on DGX Spark and Linux x86_64 with a +qualifying NVIDIA GPU. NemoClaw does not enable vision or DFlash speculative decoding for Muse +Glimmer. Station Express selects `nemotron-3-ultra-550b-a55b`; a qualified reciprocal pair uses the +distributed topology, while no qualifying pair retains the single-Station Ultra topology. Outside +Station Express, unset uses the per-platform profile default. Gated models (for example, +`deepseek-r1-distill-70b`) require `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN`. | | +`NEMOCLAW_DGX_STATION_PEER` | SSH host or `user@host` | Selects one exact, already-trusted DGX +Station peer for Nemotron 3 Ultra pair qualification. The peer must match the reciprocal private +`/30` rail and hardware checks; an explicit peer failure stops setup instead of falling back. +NemoClaw does not enroll SSH trust or accept a port or SSH option in this value. When unset, DGX +Station installer discovery checks only the two deterministic `/30` counterpart addresses. A peer +cannot be combined with an explicit non-Ultra model; conflicting explicit selections fail before +pair preparation. | | `NEMOCLAW_DGX_STATION_SSH_BINDING` | opaque installer-managed token | Carries +the qualified peer endpoint and host-key binding from DGX Station pair preparation into the current +managed-vLLM install. The installer creates and clears this token; operators should not set or +persist it. Missing, changed, or mismatched binding state fails before peer SSH or Docker work. | | +`NEMOCLAW_VLLM_EXTRA_ARGS_JSON` | JSON array of non-blank strings | Appends advanced operator-owned +tokens to the managed `vllm serve` command after NemoClaw's registry defaults. Example: +`["--max-num-seqs","2"]`. Malformed JSON, non-string tokens, blank tokens, or an invalid +`--gpu-memory-utilization` override fail before Docker work starts. The last memory-utilization +override also controls the early and immediate pre-launch GPU-memory checks. | -| `NEMOCLAW_MINIMAL_BOOTSTRAP` | `1` to enable | Skips default OpenClaw workspace-template seeding for new pristine workspaces. Existing files are not deleted; refer to [Understand Runtime Changes](../manage-sandboxes/configure-sandboxes/understand-runtime-changes). | - -| `NEMOCLAW_MODEL_ROUTER_PYTHON` | absolute path | Pins the host Python interpreter used to create the Model Router virtual environment. Strict. NemoClaw probes only that interpreter and aborts with the failure reason if it does not qualify, rather than silently falling back to another python. Relative command names such as `python3.12` are rejected. When unset, NemoClaw probes `python3.13`, `python3.12`, `python3.11`, `python3.10`, and bare `python3`, retains every interpreter whose version is in `[3.10, 3.14)` and whose `ensurepip`, `pyexpat`, `ssl`, and `venv` stdlib modules import cleanly, and tries `python -m venv` on each in priority order until one succeeds. Set the pin when the auto-discovered interpreter is broken (for example, Homebrew `python@3.14` with a `pyexpat` dlopen mismatch on macOS). | + | `NEMOCLAW_MINIMAL_BOOTSTRAP` | `1` to enable | Skips default OpenClaw workspace-template seeding + for new pristine workspaces. Existing files are not deleted; refer to [Understand Runtime + Changes](../manage-sandboxes/configure-sandboxes/understand-runtime-changes). | + +| `NEMOCLAW_MODEL_ROUTER_PYTHON` | absolute path | Pins the host Python interpreter used to create +the Model Router virtual environment. Strict. NemoClaw probes only that interpreter and aborts with +the failure reason if it does not qualify, rather than silently falling back to another python. +Relative command names such as `python3.12` are rejected. When unset, NemoClaw probes `python3.13`, +`python3.12`, `python3.11`, `python3.10`, and bare `python3`, retains every interpreter whose +version is in `[3.10, 3.14)` and whose `ensurepip`, `pyexpat`, `ssl`, and `venv` stdlib modules +import cleanly, and tries `python -m venv` on each in priority order until one succeeds. Set the pin +when the auto-discovered interpreter is broken (for example, Homebrew `python@3.14` with a `pyexpat` +dlopen mismatch on macOS). | OpenClaw-specific onboarding configuration: | Variable | Format | Effect | -|----------|--------|--------| +| --- | --- | --- | | `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects Brave Search or Tavily Search in non-interactive onboarding, or disables web search explicitly. When unset, `BRAVE_API_KEY` implicitly selects Brave before `TAVILY_API_KEY` can implicitly select Tavily. | | `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no provider is set and no Brave key is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | @@ -5205,7 +3532,7 @@ OpenClaw-specific onboarding configuration: Hermes-specific onboarding configuration: | Variable | Format | Effect | -|----------|--------|--------| +| --- | --- | --- | | `NEMOCLAW_WEB_SEARCH_PROVIDER` | `tavily` or `none` | Selects Tavily Search in non-interactive onboarding or disables web search explicitly. When unset, `TAVILY_API_KEY` implicitly selects Tavily. | | `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_HERMES_AUTH_METHOD` | `oauth` | Selects Hermes Provider authentication in non-interactive onboarding. Valid values: `oauth`, `nous-portal-oauth`, `api-key`, `nous-api-key`. | @@ -5231,20 +3558,11 @@ export TELEGRAM_BOT_TOKEN_AGENT_B= $$nemoclaw onboard --agent hermes ``` -For each entry, NemoClaw registers an OpenShell provider with the endpointless `nemoclaw-mcp-v1` profile. -OpenShell resolves the named credential placeholder to the operator-supplied value at egress. -The Hermes profile `.env` files are operator-owned: write `${TELEGRAM_BOT_TOKEN_AGENT_A}` (or the matching placeholder for each entry) into the per-profile `.env` so the in-sandbox Hermes process inherits the OpenShell placeholder instead of a raw token. -NemoClaw never reads, writes, or rewrites these `.env` files; verify after onboarding that each profile's `.env` references the placeholder and that no raw bot token value sits on disk. +For each entry, NemoClaw registers an OpenShell provider with the endpointless `nemoclaw-mcp-v1` profile. OpenShell resolves the named credential placeholder to the operator-supplied value at egress. The Hermes profile `.env` files are operator-owned: write `${TELEGRAM_BOT_TOKEN_AGENT_A}` (or the matching placeholder for each entry) into the per-profile `.env` so the in-sandbox Hermes process inherits the OpenShell placeholder instead of a raw token. NemoClaw never reads, writes, or rewrites these `.env` files; verify after onboarding that each profile's `.env` references the placeholder and that no raw bot token value sits on disk. -Entries are split on whitespace and commas and must match `^[A-Z][A-Z0-9_]{0,127}$`. -Each entry must extend a canonical channel envKey with a non-empty `_` (for example `TELEGRAM_BOT_TOKEN_AGENT_A`); the canonical envKeys are `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, `BRAVE_API_KEY`, and `TAVILY_API_KEY`. -Bare canonical envKeys, the control env itself, and arbitrary host secret names (`GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `KUBECONFIG`, and similar) are refused so they cannot leak into the sandbox provider gateway. -Duplicates are dropped silently. -The list is capped at 32 entries per sandbox. -Offending tokens emit one warning each and are skipped. +Entries are split on whitespace and commas and must match `^[A-Z][A-Z0-9_]{0,127}$`. Each entry must extend a canonical channel envKey with a non-empty `_` (for example `TELEGRAM_BOT_TOKEN_AGENT_A`); the canonical envKeys are `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, `BRAVE_API_KEY`, and `TAVILY_API_KEY`. Bare canonical envKeys, the control env itself, and arbitrary host secret names (`GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `KUBECONFIG`, and similar) are refused so they cannot leak into the sandbox provider gateway. Duplicates are dropped silently. The list is capped at 32 entries per sandbox. Offending tokens emit one warning each and are skipped. -If a referenced env is unset at onboard time, the matching provider row is registered with a null token; the `upsertMessagingProviders` helper then skips the row, so no placeholder is attached to the OpenShell gateway and no Hermes profile can resolve it. -Export the credential before running `$$nemoclaw onboard` for that profile. +If a referenced env is unset at onboard time, the matching provider row is registered with a null token; the `upsertMessagingProviders` helper then skips the row, so no placeholder is attached to the OpenShell gateway and no Hermes profile can resolve it. Export the credential before running `$$nemoclaw onboard` for that profile. @@ -5252,10 +3570,7 @@ Export the credential before running `$$nemoclaw onboard` for that profile. #### Extra OpenClaw agents -Set `NEMOCLAW_EXTRA_AGENTS_JSON` to either a JSON array of secondary-agent entries, or an object payload of the form `{"agents": [...], "defaults": {...}, "main": {...}}`, to include them in `agents.list[]` during managed startup or an explicit custom image build. -Each entry must declare `id` and `tools`; `workspace`, `agentDir`, `subagents`, `description`, and `model` are optional. -The generator always writes the canonical `main` entry first with `default: true`, so secondary agents cannot displace the primary agent. -Malformed JSON or invalid entries fail onboarding with a structured error. +Set `NEMOCLAW_EXTRA_AGENTS_JSON` to either a JSON array of secondary-agent entries, or an object payload of the form `{"agents": [...], "defaults": {...}, "main": {...}}`, to include them in `agents.list[]` during managed startup or an explicit custom image build. Each entry must declare `id` and `tools`; `workspace`, `agentDir`, `subagents`, `description`, and `model` are optional. The generator always writes the canonical `main` entry first with `default: true`, so secondary agents cannot displace the primary agent. Malformed JSON or invalid entries fail onboarding with a structured error. Field rules: @@ -5268,8 +3583,7 @@ Field rules: - Allowed entry fields: `id`, `workspace`, `agentDir`, `tools`, `subagents`, `description`, `model`. Any other key fails onboarding (no implicit credential or env pass-through). - Allowed `tools` fields: `profile`, `allow`, `deny`. Allowed per-agent `subagents` fields: `delegationMode`, `allowAgents`, `model`, `thinking`, `requireAgentId`. Any other nested key fails onboarding. -OpenClaw accepts `subagents.maxSpawnDepth` only on `agents.defaults.subagents`, never inside a per-agent `subagents` object. -The value must be an integer between `1` and `5` (OpenClaw's accepted range); to set it, use the object payload shape and pass it under `defaults`: +OpenClaw accepts `subagents.maxSpawnDepth` only on `agents.defaults.subagents`, never inside a per-agent `subagents` object. The value must be an integer between `1` and `5` (OpenClaw's accepted range); to set it, use the object payload shape and pass it under `defaults`: ```json { @@ -5308,33 +3622,23 @@ Array-shape example (paths defaulted): #### Linux Ollama install mode details -Set `NEMOCLAW_OLLAMA_INSTALL_MODE=system` to run the official `https://ollama.com/install.sh` installer, which uses sudo, writes to `/usr/local`, and configures systemd. -Set `NEMOCLAW_OLLAMA_INSTALL_MODE=user` to extract the release tarball to `${HOME}/.local` without sudo and launch the daemon manually without systemd persistence. -Leave `NEMOCLAW_OLLAMA_INSTALL_MODE` empty or unset to let NemoClaw auto-detect the mode. -Auto-detection selects `system` when the current user is root or passwordless `sudo` works. -Auto-detection selects `user` in non-interactive runs without passwordless `sudo`. -An interactive shell falls back to `system` so the official installer can prompt for the password. -NemoClaw rejects any other value. -On upgrades, NemoClaw rejects `user` because a user-local install cannot replace the system daemon on `:11434`. -On upgrades, NemoClaw also rejects `system` under `NEMOCLAW_NON_INTERACTIVE=1` when passwordless `sudo` is unavailable because the installer would hang on a hidden sudo prompt. -The run exits with an actionable diagnostic instead. +Set `NEMOCLAW_OLLAMA_INSTALL_MODE=system` to run the official `https://ollama.com/install.sh` installer, which uses sudo, writes to `/usr/local`, and configures systemd. Set `NEMOCLAW_OLLAMA_INSTALL_MODE=user` to extract the release tarball to `${HOME}/.local` without sudo and launch the daemon manually without systemd persistence. Leave `NEMOCLAW_OLLAMA_INSTALL_MODE` empty or unset to let NemoClaw auto-detect the mode. Auto-detection selects `system` when the current user is root or passwordless `sudo` works. Auto-detection selects `user` in non-interactive runs without passwordless `sudo`. An interactive shell falls back to `system` so the official installer can prompt for the password. NemoClaw rejects any other value. On upgrades, NemoClaw rejects `user` because a user-local install cannot replace the system daemon on `:11434`. On upgrades, NemoClaw also rejects `system` under `NEMOCLAW_NON_INTERACTIVE=1` when passwordless `sudo` is unavailable because the installer would hang on a hidden sudo prompt. The run exits with an actionable diagnostic instead. ### Experimental NemoCUA Keep `NEMOCLAW_CUA_ENABLED=1` set whenever NemoClaw uses the experimental `nemocua` agent, including discovery, launch, agent commands, sandbox creation, and rebuild. | Variable | Format | Effect | -|----------|--------|--------| +| --- | --- | --- | | `NEMOCLAW_CUA_ENABLED` | exactly `1` to enable | Exposes the experimental `nemocua` agent. Other values keep it absent from agent discovery and prevent its manifest from loading. | | `NEMOCLAW_CUA_SANDBOX_IMAGE_REF` | image reference | Selects the caller-prepared NemoCUA sandbox image when `NEMOCLAW_CUA_ENABLED=1`. NemoClaw uses this reference directly and does not resolve a private package source. The value is required whenever NemoClaw creates or rebuilds the NemoCUA sandbox; whitespace and shell syntax are rejected. | ### Onboarding Behavior Flags -The following flags toggle optional behaviors during onboarding. -Set them before running `$$nemoclaw onboard`. +The following flags toggle optional behaviors during onboarding. Set them before running `$$nemoclaw onboard`. | Variable | Format | Effect | -|----------|--------|--------| +| --- | --- | --- | | `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup, but does not change managed-vLLM storage-warning handling. Express and other non-interactive setup stop after a verified insufficient-capacity warning, interactive setup still requires an explicit `y` or `yes`, and an inconclusive model-cache check stops non-interactive setup with guidance to rerun interactively. | | `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Skips the wizard's eager Ollama auto-start during inference-provider selection (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, an agent that uses the legacy `16384`-token context floor, currently OpenClaw, prints a warning and selects the default fallback model instead of spawning `ollama serve`. An agent that requires a larger verified runtime context, currently Hermes at `64000` tokens, returns to interactive provider selection or exits when the Ollama provider is pinned or onboarding is non-interactive. The flag covers only the provider-selection step; later setup steps (auth proxy, validation, model warm) still expect a reachable Ollama. On Linux hosts with a systemd Ollama unit, the loopback-override path may still restart the daemon before this gate runs. | | `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. | @@ -5343,6 +3647,7 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_IGNORE_RUNTIME_RESOURCES` | `1` to enable | Suppresses the under-provisioned runtime warning during preflight. Use only when you know the sandbox host meets the minimums. | | `NEMOCLAW_DISABLE_OVERLAY_FIX` | `1` to enable | Skips the Docker overlay-fix step during sandbox build. For environments where the fix is incompatible. | | `NEMOCLAW_OVERLAY_SNAPSHOTTER` | snapshotter name | Selects the containerd overlay snapshotter for sandbox builds. Empty (default) preserves containerd's choice. | + | `NEMOCLAW_SKIP_TELEGRAM_REACHABILITY` | `1` to enable | Skips the Telegram bot reachability probe during onboard (useful in restricted networks). | | `NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION` | `1`, `true`, `yes`, or `on` to enable | Skips the live Slack `auth.test` and `apps.connections.open` credential probes during onboard and `channels add slack`. Use only in restricted networks or hermetic test environments; Slack token format checks still apply. | @@ -5366,33 +3671,25 @@ Set them before running `$$nemoclaw onboard`. -Set `NEMOCLAW_SANDBOX_BASE_IMAGE_REF` to an OpenClaw sandbox-base tag or digest to override base-image resolution during onboarding. -Remote overrides must use the official NVIDIA sandbox-base repository and resolve to a repository digest. -NemoClaw accepts local bases only when it builds and pins them during onboarding; a local image reference supplied through this environment variable is rejected because it has no trusted build capability. +Set `NEMOCLAW_SANDBOX_BASE_IMAGE_REF` to an OpenClaw sandbox-base tag or digest to override base-image resolution during onboarding. Remote overrides must use the official NVIDIA sandbox-base repository and resolve to a repository digest. NemoClaw accepts local bases only when it builds and pins them during onboarding; a local image reference supplied through this environment variable is rejected because it has no trusted build capability. -Set `NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF` to a LangChain Deep Agents Code sandbox-base tag or digest to override base-image resolution during onboarding. -NemoClaw requires environment overrides to use the official remote repository and resolve to a repository digest, then validates the requested image against the manifest-required `deepagents-code` package version before using it. -NemoClaw accepts local bases only when it builds and pins them during onboarding. +Set `NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF` to a LangChain Deep Agents Code sandbox-base tag or digest to override base-image resolution during onboarding. NemoClaw requires environment overrides to use the official remote repository and resolve to a repository digest, then validates the requested image against the manifest-required `deepagents-code` package version before using it. NemoClaw accepts local bases only when it builds and pins them during onboarding. -Set `NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF` to a Hermes sandbox-base tag or digest to override base-image resolution during onboarding. -NemoClaw requires environment overrides to use the official remote repository and resolve to a repository digest, validates the requested image for the required MCP runtime, and keeps the final image bound to that trusted base. -NemoClaw accepts local bases only when it builds and pins them during onboarding. +Set `NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF` to a Hermes sandbox-base tag or digest to override base-image resolution during onboarding. NemoClaw requires environment overrides to use the official remote repository and resolve to a repository digest, validates the requested image for the required MCP runtime, and keeps the final image bound to that trusted base. NemoClaw accepts local bases only when it builds and pins them during onboarding. ### Onboard Profiling Traces -Set `NEMOCLAW_TRACE=1` before `$$nemoclaw onboard` to write an OpenTelemetry-style JSON trace for the run. -If you do not set a trace path, NemoClaw writes a timestamped file under `.e2e/traces/` in the current working directory. -Use `NEMOCLAW_TRACE_DIR` to choose the output directory, or `NEMOCLAW_TRACE_FILE` to choose the output file. +Set `NEMOCLAW_TRACE=1` before `$$nemoclaw onboard` to write an OpenTelemetry-style JSON trace for the run. If you do not set a trace path, NemoClaw writes a timestamped file under `.e2e/traces/` in the current working directory. Use `NEMOCLAW_TRACE_DIR` to choose the output directory, or `NEMOCLAW_TRACE_FILE` to choose the output file. ```bash NEMOCLAW_TRACE=1 $$nemoclaw onboard @@ -5400,27 +3697,17 @@ NEMOCLAW_TRACE_DIR=/tmp/nemoclaw-traces $$nemoclaw onboard NEMOCLAW_TRACE_FILE=/tmp/nemoclaw-onboard-trace.json $$nemoclaw onboard ``` -Trace artifacts include onboard phase timing, sandbox and service readiness waits, policy application, inference validation probes, curl probe results, and sandbox build progress events. -Secret-like metadata such as API keys, bearer tokens, cookies, and credentials is redacted before the file is written. +Trace artifacts include onboard phase timing, sandbox and service readiness waits, policy application, inference validation probes, curl probe results, and sandbox build progress events. Secret-like metadata such as API keys, bearer tokens, cookies, and credentials is redacted before the file is written. ### Deep Agents Code OTLP Traces -Pass `--observability` during Deep Agents onboarding to enable backend-neutral runtime traces for Deep Agents Code. -This feature is separate from `NEMOCLAW_TRACE`, which records NemoClaw onboarding phases, and from the OpenClaw diagnostics plugin. +Pass `--observability` during Deep Agents onboarding to enable backend-neutral runtime traces for Deep Agents Code. This feature is separate from `NEMOCLAW_TRACE`, which records NemoClaw onboarding phases, and from the OpenClaw diagnostics plugin. -The sandbox sends OTLP/HTTP protobuf requests only to `http://host.openshell.internal:4318/v1/traces`. -The managed exporter uses standard OTLP transport headers but does not accept operator-supplied custom or authentication headers. -A host operator must run the receiver on port `4318` and configure any Jaeger, Phoenix, LangSmith, or other backend exporter on the collector side. -Changing the host collector's exporter does not require a sandbox rebuild or policy change. -Collector and exporter failures are non-fatal to agent work. +The sandbox sends OTLP/HTTP protobuf requests only to `http://host.openshell.internal:4318/v1/traces`. The managed exporter uses standard OTLP transport headers but does not accept operator-supplied custom or authentication headers. A host operator must run the receiver on port `4318` and configure any Jaeger, Phoenix, LangSmith, or other backend exporter on the collector side. Changing the host collector's exporter does not require a sandbox rebuild or policy change. Collector and exporter failures are non-fatal to agent work. -Native LangSmith tracing and ambient OTLP configuration remain disabled in the sandbox. -The explicit opt-in can export bounded prompts, responses, tool arguments, tool results, and operational metadata, so operators must treat trace payloads as sensitive application data. -The collector must enforce the operator's filtering and redaction requirements before remote forwarding because the local policy applies to the managed Python interpreter and does not provide authenticated tenant identity. -For a runnable LangSmith collector setup, refer to [Set Up Deep Agents Trace Export](/user-guide/deepagents/monitoring/set-up-deepagents-trace-export). -For the receiver trust contract, refer to [Understand Deep Agents Trace Export](/user-guide/deepagents/monitoring/understand-deepagents-trace-export). +Native LangSmith tracing and ambient OTLP configuration remain disabled in the sandbox. The explicit opt-in can export bounded prompts, responses, tool arguments, tool results, and operational metadata, so operators must treat trace payloads as sensitive application data. The collector must enforce the operator's filtering and redaction requirements before remote forwarding because the local policy applies to the managed Python interpreter and does not provide authenticated tenant identity. For a runnable LangSmith collector setup, refer to [Set Up Deep Agents Trace Export](/user-guide/deepagents/monitoring/set-up-deepagents-trace-export). For the receiver trust contract, refer to [Understand Deep Agents Trace Export](/user-guide/deepagents/monitoring/understand-deepagents-trace-export). @@ -5428,9 +3715,7 @@ For the receiver trust contract, refer to [Understand Deep Agents Trace Export]( ### OpenClaw Conversation OTEL Diagnostics -Set `NEMOCLAW_OPENCLAW_OTEL=1` before onboarding or rebuilding an OpenClaw sandbox to enable runtime conversation traces through OpenClaw's `diagnostics-otel` plugin. -This is separate from `NEMOCLAW_TRACE`, which records NemoClaw onboarding phases to a local JSON file. -NemoClaw configures OpenClaw for OTLP/HTTP protobuf traces only by default: metrics and logs are disabled, and prompt/tool content capture is not enabled. +Set `NEMOCLAW_OPENCLAW_OTEL=1` before onboarding or rebuilding an OpenClaw sandbox to enable runtime conversation traces through OpenClaw's `diagnostics-otel` plugin. This is separate from `NEMOCLAW_TRACE`, which records NemoClaw onboarding phases to a local JSON file. NemoClaw configures OpenClaw for OTLP/HTTP protobuf traces only by default: metrics and logs are disabled, and prompt/tool content capture is not enabled. For a local Jaeger collector: @@ -5443,21 +3728,13 @@ docker run --rm --name nemoclaw-jaeger \ NEMOCLAW_OPENCLAW_OTEL=1 $$nemoclaw onboard ``` -Onboarding automatically applies the `openclaw-diagnostics-otel-local` preset at sandbox create and again during the policy step when `NEMOCLAW_OPENCLAW_OTEL=1`, so OTLP export is allowed before the gateway's first trace flush. -If you enabled OTEL after an existing sandbox was created, run `$$nemoclaw policy add openclaw-diagnostics-otel-local --yes` or recreate the sandbox with OTEL enabled at build time. +Onboarding automatically applies the `openclaw-diagnostics-otel-local` preset at sandbox create and again during the policy step when `NEMOCLAW_OPENCLAW_OTEL=1`, so OTLP export is allowed before the gateway's first trace flush. If you enabled OTEL after an existing sandbox was created, run `$$nemoclaw policy add openclaw-diagnostics-otel-local --yes` or recreate the sandbox with OTEL enabled at build time. -Then open `http://localhost:16686` and select the `openclaw-gateway` service. -The built-in `openclaw-diagnostics-otel-local` preset allows only `POST /v1/traces` (and subpaths) to `host.openshell.internal:4318` from `openclaw` and `node`. -For a remote collector, create a custom preset for the collector host and port instead of using the local host-gateway preset. +Then open `http://localhost:16686` and select the `openclaw-gateway` service. The built-in `openclaw-diagnostics-otel-local` preset allows only `POST /v1/traces` (and subpaths) to `host.openshell.internal:4318` from `openclaw` and `node`. For a remote collector, create a custom preset for the collector host and port instead of using the local host-gateway preset. ### OpenClaw MCP Tool Discovery Timeout -Set `NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS` before onboarding or rebuilding an OpenClaw sandbox to override its `tools/list` timeout. -The setting accepts an integer from `1500` through `10000` milliseconds. -When unset or blank, NemoClaw adds no override; OpenClaw uses a server-specific request timeout when configured and otherwise uses its 1,500 ms fallback. -The override applies to catalog `tools/list` requests for every MCP server in that sandbox and takes precedence over a server-specific request timeout. -It does not change connection timeouts or the separate MCP tool-call request timeout, which defaults to 60,000 ms. -NemoClaw rejects an invalid value before the sandbox create step, including the replacement create step during rebuild. +Set `NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS` before onboarding or rebuilding an OpenClaw sandbox to override its `tools/list` timeout. The setting accepts an integer from `1500` through `10000` milliseconds. When unset or blank, NemoClaw adds no override; OpenClaw uses a server-specific request timeout when configured and otherwise uses its 1,500 ms fallback. The override applies to catalog `tools/list` requests for every MCP server in that sandbox and takes precedence over a server-specific request timeout. It does not change connection timeouts or the separate MCP tool-call request timeout, which defaults to 60,000 ms. NemoClaw rejects an invalid value before the sandbox create step, including the replacement create step during rebuild. For an existing sandbox, apply a 3,000 ms timeout: @@ -5466,18 +3743,16 @@ export NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS=3000 $$nemoclaw rebuild ``` -OpenClaw writes `mcp_tools_list_timeout_override_ms=3000` when the MCP runtime loads. -Refer to [Troubleshoot MCP Servers](troubleshoot-mcp-servers#adjust-the-tool-discovery-timeout) for the incremental test and rollback procedure. +OpenClaw writes `mcp_tools_list_timeout_override_ms=3000` when the MCP runtime loads. Refer to [Troubleshoot MCP Servers](troubleshoot-mcp-servers#adjust-the-tool-discovery-timeout) for the incremental test and rollback procedure. ### Probe Timeouts -The following variables tune how long internal probes wait before giving up. -Defaults are sized for typical hardware; override only if you see false-positive timeouts. +The following variables tune how long internal probes wait before giving up. Defaults are sized for typical hardware; override only if you see false-positive timeouts. | Variable | Default | Effect | -|----------|---------|--------| +| --- | --- | --- | | `NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS` | `30` | Maximum time to wait for an OpenShell MCP provider credential revision to become active or fully revoked inside the sandbox. Integer seconds; raise only when provider synchronization is unusually slow. | | `NEMOCLAW_PORTABLE_PODMAN_STARTUP_TIMEOUT_MS` | `60000` | Maximum cold-start time for portable rootless Podman socket activation and the first real API response. Set an integer from `15000` through `300000` milliseconds. This setting does not change the fixed 10,000 ms steady-state API deadline. | | `NEMOCLAW_SANDBOX_EXEC_TIMEOUT_MS` | per call site (typically `15000`) | Overrides the default timeout for `openshell sandbox exec` calls issued by recovery and lifecycle helpers. Integer milliseconds; non-positive or non-numeric values fall back to the per-call-site default. | @@ -5486,11 +3761,10 @@ Defaults are sized for typical hardware; override only if you see false-positive ### Onboard and Sandbox Readiness Timeouts -The following environment variables tune onboard-time and recovery wall-clock limits. -Set the onboarding variables before running `$$nemoclaw onboard` if a slow connection or large model pull risks tripping the default. +The following environment variables tune onboard-time and recovery wall-clock limits. Set the onboarding variables before running `$$nemoclaw onboard` if a slow connection or large model pull risks tripping the default. | Variable | Default | Purpose | -|----------|---------|---------| +| --- | --- | --- | | `NEMOCLAW_OLLAMA_PULL_TIMEOUT` | `1800` (30 minutes) | Wall-clock timeout for `ollama pull` during onboard, in seconds. Accepts integer or float values. Already-downloaded layers are kept; re-running the pull resumes them. | | `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` | `180` | Wall-clock timeout for the inference-server validation probe during onboard, in seconds. Raise on slow networks or for very large prompts. | | `NEMOCLAW_SANDBOX_READY_TIMEOUT` | `180` | Wall-clock timeout for post-create readiness, in seconds. Raise the timeout when the managed-image pull, explicit custom image build, gateway upload, or in-sandbox boot exceeds the default (typical on 70B+ models, first-time gateway uploads over slow links, or DGX Station / remote-VM first runs). Ordinary onboarding deletes the partially created sandbox when the deadline expires and prints the retry hint. Portable OpenClaw onboarding instead preserves the sandbox when NemoClaw cannot verify its runtime identity. | @@ -5503,13 +3777,11 @@ For newly created OpenClaw and Hermes sandboxes, `NEMOCLAW_SANDBOX_READY_TIMEOUT -An unset, blank, invalid, or negative `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` value uses 30 seconds for OpenClaw gateway health and 90 seconds for Hermes gateway health. -Recreated-sandbox OpenShell registration uses 120 seconds when the recovery path does not supply another budget. +An unset, blank, invalid, or negative `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` value uses 30 seconds for OpenClaw gateway health and 90 seconds for Hermes gateway health. Recreated-sandbox OpenShell registration uses 120 seconds when the recovery path does not supply another budget. -For managed recovery, `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` covers OpenShell re-registration before state restoration and replacement commit. -When the deadline expires, NemoClaw attempts to roll back the replacement and leaves the primary dashboard or API host forward stopped. +For managed recovery, `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` covers OpenShell re-registration before state restoration and replacement commit. When the deadline expires, NemoClaw attempts to roll back the replacement and leaves the primary dashboard or API host forward stopped. @@ -5530,14 +3802,11 @@ $$nemoclaw recover -If the Ollama pull or post-create readiness timeout fires, onboarding emits the elapsed budget plus a hint to raise the relevant variable. -The Ollama pull preserves its partial download for the next attempt. -The ordinary post-create readiness wait deletes the orphaned sandbox first so the next `$$nemoclaw onboard` starts without that partially created sandbox. +If the Ollama pull or post-create readiness timeout fires, onboarding emits the elapsed budget plus a hint to raise the relevant variable. The Ollama pull preserves its partial download for the next attempt. The ordinary post-create readiness wait deletes the orphaned sandbox first so the next `$$nemoclaw onboard` starts without that partially created sandbox. -For portable OpenClaw onboarding, NemoClaw instead leaves the sandbox in place when it cannot verify the runtime identity. -Inspect it with `openshell sandbox list` and `$$nemoclaw status`, then follow the recovery guidance from `status`. +For portable OpenClaw onboarding, NemoClaw instead leaves the sandbox in place when it cannot verify the exact runtime identity. Inspect it with `openshell sandbox list` and `$$nemoclaw status`, then follow the recovery guidance from `status`. @@ -5548,8 +3817,9 @@ A post-policy re-registration failure leaves the sandbox in place and reports th The following flags change defaults for commands that manage existing sandboxes. | Variable | Format | Effect | -|----------|--------|--------| +| --- | --- | --- | | `NEMOCLAW_CLEANUP_GATEWAY` | `1`, `true`, or `yes` to enable; `0`, `false`, or `no` to disable | Overrides the platform default (macOS unattended: cleanup; Linux/Windows: preserve) for whether `$$nemoclaw destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. | + | `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | Exactly `"1"` to opt in (`true`, `yes`, `on` are not accepted) | Allows `$$nemoclaw config set` to write a dotpath that does not already exist in the sandbox config, without the interactive confirmation. Equivalent to passing `--config-accept-new-path`, and it takes precedence over `NEMOCLAW_NON_INTERACTIVE=1`. Without it, a run without a TTY refuses the write instead. | @@ -5568,12 +3838,10 @@ The following flags change defaults for commands that manage existing sandboxes. ### Deprecated Brev Deployment -The following variables configure the deprecated `$$nemoclaw deploy` compatibility command. -The maintained remote-server path does not use these variables. -For the current remote-server deployment path, refer to [Deploy to a Headless Server](../deployment/deploy-to-headless-server). +The following variables configure the deprecated `$$nemoclaw deploy` compatibility command. The maintained remote-server path does not use these variables. For the current remote-server deployment path, refer to [Deploy to a Headless Server](../deployment/deploy-to-headless-server). | Variable | Default | Effect | -|----------|---------|--------| +| --- | --- | --- | | `NEMOCLAW_BREV_PROVIDER` | `gcp` | Cloud provider for Brev instance creation. | | `NEMOCLAW_GPU` | `a2-highgpu-1g:nvidia-tesla-a100:1` | GPU specification (instance type and GPU model) for the Brev instance. | | `NEMOCLAW_DEPLOY_NO_CONNECT` | unset | When set to `1`, skips the automatic `connect` step after the remote deploy completes. | @@ -5583,8 +3851,7 @@ For the current remote-server deployment path, refer to [Deploy to a Headless Se ### Legacy `$$nemoclaw setup` -Deprecated. Use `$$nemoclaw onboard` instead. -Running `$$nemoclaw setup` now delegates directly to `$$nemoclaw onboard`. +Deprecated. Use `$$nemoclaw onboard` instead. Running `$$nemoclaw setup` now delegates directly to `$$nemoclaw onboard`. ```bash $$nemoclaw setup diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 4e4bf512564..a3fdfd78d42 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -9,9 +9,8 @@ keywords: ["nemoclaw network policy", "sandbox egress control operator approval" content: type: "reference" --- -NemoClaw runs with a deny-by-default network policy. -The sandbox can only reach endpoints that are explicitly allowed. -OpenShell intercepts any request to an unlisted destination and prompts the operator to approve or deny it in real time through the TUI. + +NemoClaw runs with a deny-by-default network policy. The sandbox can only reach endpoints that are explicitly allowed. OpenShell intercepts any request to an unlisted destination and prompts the operator to approve or deny it in real time through the TUI. ## Baseline Policy @@ -27,26 +26,23 @@ Hermes sandboxes use an agent-specific baseline policy in `agents/hermes/policy- -Deep Agents sandboxes use an agent-specific baseline policy in `agents/langchain-deepagents-code/policy-additions.yaml` so the managed `dcode` runtime can reach inference, GitHub, and package endpoints while keeping the same deny-by-default model. -Deep Agents uses strict Landlock compatibility, so sandbox startup fails when OpenShell cannot enforce the managed filesystem policy. +Deep Agents sandboxes use an agent-specific baseline policy in `agents/langchain-deepagents-code/policy-additions.yaml` so the managed `dcode` runtime can reach inference, GitHub, and package endpoints while keeping the same deny-by-default model. Deep Agents uses strict Landlock compatibility, so sandbox startup fails when OpenShell cannot enforce the managed filesystem policy. ### Filesystem | Path | Access | -|---|---| +| --- | --- | | `/sandbox`, `/tmp`, `/dev/null`, `/dev/pts` | Read-write | | `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log`, `/var/lib/dpkg` | Read-only | -`/dev/pts` is the pseudo-terminal (devpts) directory. -It is writable so PTY-based tools (`tmux`, `script`, and interactive shells) can allocate a terminal. -Without it, those tools fail with `fork failed: Permission denied`. +`/dev/pts` is the pseudo-terminal (devpts) directory. It is writable so PTY-based tools (`tmux`, `script`, and interactive shells) can allocate a terminal. Without it, those tools fail with `fork failed: Permission denied`. -Read-only access to `/var/lib/dpkg` lets `dpkg-query` inspect installed package metadata. -The filesystem policy does not grant write access to the package database. +Read-only access to `/var/lib/dpkg` lets `dpkg-query` inspect installed package metadata. The filesystem policy does not grant write access to the package database. The sandbox process runs as a dedicated `sandbox` user and group. + Landlock LSM enforcement applies on a best-effort basis. @@ -54,8 +50,8 @@ Landlock LSM enforcement applies on a best-effort basis. Landlock LSM enforcement applies on a best-effort basis. -For Deep Agents, Landlock enforcement is strict. -If the kernel or runtime cannot enforce the managed filesystem policy, sandbox startup fails closed. + For Deep Agents, Landlock enforcement is strict. If the kernel or runtime cannot enforce the + managed filesystem policy, sandbox startup fails closed. ### Network Policies @@ -75,8 +71,7 @@ The following endpoint groups are allowed by default: -Hermes baseline endpoint groups are declared by the Hermes agent policy additions. -Use `$$nemoclaw policy list` or `openshell policy get --base ` on a live sandbox to inspect the applied baseline. +Hermes baseline endpoint groups are declared by the Hermes agent policy additions. Use `$$nemoclaw policy list` or `openshell policy get --base ` on a live sandbox to inspect the exact applied baseline. @@ -87,8 +82,7 @@ Use `$$nemoclaw policy list` or `openshell policy get --base | `github` | `github.com:443`, `api.github.com:443`, `raw.githubusercontent.com:443` | `/usr/bin/git`, `/usr/local/bin/dcode`, `/opt/venv/bin/python3*` | Full access to `github.com` and `api.github.com`; GET and HEAD only to `raw.githubusercontent.com` | | `pypi` | `pypi.org:443`, `files.pythonhosted.org:443` | `/opt/venv/bin/python3*`, `/opt/venv/bin/pip3*` | GET for package installation | -The separate `raw.githubusercontent.com` route lets Deep Agents Code follow GitHub file links and read repository source through its managed `fetch_url` tool. -Repository, ref, and file path segments vary by task, so the route covers the host while limiting requests to read-only GET and HEAD methods and the listed managed binaries. +The separate `raw.githubusercontent.com` route lets Deep Agents Code follow GitHub file links and read repository source through its managed `fetch_url` tool. Repository, ref, and file path segments vary by task, so the route covers the host while limiting requests to read-only GET and HEAD methods and the listed managed binaries. @@ -101,27 +95,26 @@ GitHub access (`github.com`, `api.github.com`) is not included in the baseline p Apply the `github` preset during onboarding if your agent needs GitHub access. Refer to [Customize the Network Policy](../network-policy/customize-network-policy). -The baseline policy does not include messaging endpoints for Telegram, Discord, Slack, WeChat, or WhatsApp. -Enable the channel during onboarding or apply the matching messaging preset so the sandbox can reach that platform. -WeChat and WhatsApp are experimental. -Review [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them. +The baseline policy does not include messaging endpoints for Telegram, Discord, Slack, WeChat, or WhatsApp. Enable the channel during onboarding or apply the matching messaging preset so the sandbox can reach that platform. WeChat and WhatsApp are experimental. Review [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them. + -Messaging endpoints are not part of the common baseline policy. -Enable the channel during onboarding or apply the matching preset so the sandbox can reach that platform. + Messaging endpoints are not part of the common baseline policy. Enable the channel during + onboarding or apply the matching preset so the sandbox can reach that platform. -Deep Agents is a terminal-oriented harness. -NemoClaw does not configure messaging channel bridges for Deep Agents today, and the baseline policy does not include Tavily, LangSmith, MCP hosts, or arbitrary web endpoints. -Apply maintained presets such as `github`, `pypi`, or `tavily` only when the sandbox needs that access. + Deep Agents is a terminal-oriented harness. NemoClaw does not configure messaging channel bridges + for Deep Agents today, and the baseline policy does not include Tavily, LangSmith, MCP hosts, or + arbitrary web endpoints. Apply maintained presets such as `github`, `pypi`, or `tavily` only when + the sandbox needs that access. @@ -129,20 +122,16 @@ Apply maintained presets such as `github`, `pypi`, or `tavily` only when the san ## Policy Tiers -During onboarding, the wizard prompts for a policy tier that determines the default set of presets applied on top of the baseline policy. -The baseline policy is always applied regardless of the selected tier. -An operator can persistently exclude a specific baseline entry with [`policy exclude`](#excluding-a-baseline-entry) when they accept reduced, unsupported functionality. -This is the supported registry-backed way to replay a live key removal during rebuild; raw `openshell policy set` edits are not replayed, while edits made to the source baseline itself are applied when the sandbox is recreated. +During onboarding, the wizard prompts for a policy tier that determines the default set of presets applied on top of the baseline policy. The baseline policy is always applied regardless of the selected tier. An operator can exclude a specific baseline entry from the current OpenShell policy with [`policy exclude`](#excluding-a-baseline-entry) when they accept reduced, unsupported functionality. NemoClaw stores no separate exclusion record. Rebuild and clone carry the current OpenShell policy forward, so changes made through NemoClaw, the OpenShell TUI, or direct host-side policy editing have the same lifecycle. | Tier | Presets included | Description | -|------|------------------|-------------| +| --- | --- | --- | | Restricted | No tier defaults | Starts from the baseline policy. Web search or messaging integrations selected earlier can still suggest their required presets; deselect them during policy review for baseline-only access. Restricted suppresses other agent-required additions; reapply them later with `policy add` only after reviewing the additional egress. | | Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave`; selected `tavily` web search access when supported | Full dev tooling and tier-default Brave egress. Selecting Tavily adds its preset when the active agent supports it; the `brave` tier default remains applied. No messaging platform access. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. | | Open | `npm`, `pypi`, `huggingface`, `brew`, `brave`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `teams` (experimental), `jira`, `outlook`; selected `tavily` web search access when supported | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. Selecting Tavily adds its preset; the `brave` tier default remains applied. | | Personal | `personal-open-internet` (mandatory) | Lets every sandbox binary open TCP connections to public and private address ranges on destination ports `80` and `443`. The broad route replaces overlapping web endpoints while preserving non-web policy. Unspecified, loopback, and link-local ranges remain blocked. | -When Personal is selected or carried forward, the `personal-open-internet` preset is mandatory for every agent and every onboarding entry point. -Interactive choices, `NEMOCLAW_POLICY_MODE=custom`, and `NEMOCLAW_POLICY_MODE=skip` control only additional presets; they cannot deselect, skip, or replace Personal's required web authority. +When Personal is selected or carried forward, the `personal-open-internet` preset is mandatory for every agent and every onboarding entry point. Interactive choices, `NEMOCLAW_POLICY_MODE=custom`, and `NEMOCLAW_POLICY_MODE=skip` control only additional presets; they cannot deselect, skip, or replace Personal's required web authority. The Personal tier applies the `personal-open-internet` policy preset with a hostless L4 endpoint on destination ports `80` and `443`. @@ -151,23 +140,14 @@ The rule does not inspect the application protocol or payload, so traffic on the OpenShell does not restrict the hostname, HTTP method, path, or body after the rule permits the connection. An agent can send workspace data or sandbox-visible credentials to an arbitrary reachable service on either port without an operator approval prompt. -The preset excludes unspecified, loopback, and link-local address ranges, including the common cloud metadata range. -OpenShell also keeps its hard blocks for those destinations. -Other destination ports remain denied unless another policy entry permits them. -The sandbox's filesystem, process, gateway authentication, and managed credential controls remain active. -Use this tier only for trusted personal workloads with trusted prompts and data. +The preset excludes unspecified, loopback, and link-local address ranges, including the common cloud metadata range. OpenShell also keeps its hard blocks for those destinations. Other destination ports remain denied unless another policy entry permits them. The sandbox's filesystem, process, gateway authentication, and managed credential controls remain active. Use this tier only for trusted personal workloads with trusted prompts and data. + -Every fresh onboarding run through the experimental Portable profile selects the Personal tier. -When `NEMOCLAW_POLICY_PRESETS` is unset, blank, or contains only whitespace, Portable uses `suggested` mode with no optional preset override. -If `NEMOCLAW_POLICY_PRESETS` contains a non-blank list, Portable treats that list as authoritative for additional presets while still applying mandatory `personal-open-internet`. -`$$nemoclaw onboard --resume` does not override a recorded non-Personal tier; a resumed Personal tier retains or repairs its mandatory preset. +Every fresh onboarding run through the experimental Portable profile selects the Personal tier. When `NEMOCLAW_POLICY_PRESETS` is unset, blank, or contains only whitespace, Portable uses `suggested` mode with no optional preset override. If `NEMOCLAW_POLICY_PRESETS` contains a non-blank list, Portable treats that list as authoritative for additional presets while still applying mandatory `personal-open-internet`. `$$nemoclaw onboard --resume` does not override a recorded non-Personal tier; a resumed Personal tier retains or repairs its mandatory preset. + +After selecting a tier, a combined preset and access-mode screen lets you include or exclude optional presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. On Personal, NemoClaw restores `personal-open-internet` if it is deselected in the screen. 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. The `personal-open-internet` preset uses L4 passthrough, so its read-write label does not add HTTP method or path inspection. -After selecting a tier, a combined preset and access-mode screen lets you include or exclude optional presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. -On Personal, NemoClaw restores `personal-open-internet` if it is deselected in the screen. -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. -The `personal-open-internet` preset uses L4 passthrough, so its read-write label does not add HTTP method or path inspection. OpenClaw can select `brave` or `tavily`, while Hermes can select `tavily` only. @@ -222,46 +202,31 @@ In non-interactive mode, set the tier with `NEMOCLAW_POLICY_TIER`: NEMOCLAW_POLICY_TIER=open $$nemoclaw onboard --non-interactive --yes-i-accept-third-party-software ``` -Unset, blank, or whitespace-only `NEMOCLAW_POLICY_TIER` values use the `balanced` default. -In non-interactive onboarding, a non-blank value that does not match a known tier exits before preflight, gateway, or inference side effects and lists the valid options. -Interactive onboarding ignores an invalid environment value and shows the normal tier prompt. +Unset, blank, or whitespace-only `NEMOCLAW_POLICY_TIER` values use the `balanced` default. In non-interactive onboarding, a non-blank value that does not match a known tier exits before preflight, gateway, or inference side effects and lists the valid options. Interactive onboarding ignores an invalid environment value and shows the normal tier prompt. ### Inference -The baseline policy allows only the `local` inference route. -External inference providers are reached through the OpenShell gateway, not by direct sandbox egress. +The baseline policy allows only the `local` inference route. External inference providers are reached through the OpenShell gateway, not by direct sandbox egress. ### Local OTLP Trace Export -The `observability-otlp-local` preset supports the opt-in LangChain Deep Agents Code trace path. -It is not a general remote observability policy. +The `observability-otlp-local` preset supports the opt-in LangChain Deep Agents Code trace path. It is not a general remote observability policy. | Preset | Destination | Binary | Rules | -|---|---|---|---| -| `observability-otlp-local` | `host.openshell.internal:4318` | `/opt/venv/bin/python3*` | `POST /v1/traces` only | - -The sandbox sends OTLP/HTTP protobuf traces to a collector that the operator runs on the host. -The managed exporter uses standard OTLP transport headers but does not accept operator-supplied custom or authentication headers. -Remote backend endpoints and credentials stay in that collector. -The policy does not allow direct LangSmith, Jaeger, Phoenix, or other backend egress from Deep Agents Code. -Changing the collector's downstream exporter requires no sandbox policy change. - -OpenShell observes Deep Agents Code export as the managed Python interpreter, so this permission is process-wide for `/opt/venv/bin/python3*` rather than limited to the `dcode` launcher. -Sandbox Python can forge spans and resource attributes. -The explicit `--observability` opt-in can export bounded prompts, responses, tool arguments, tool results, and operational metadata. -Managed size and recognized-key redaction do not detect secrets embedded in ordinary content values. -The collector must enforce the operator's filtering and redaction requirements before forwarding traces, and it must not treat span fields such as `service.name` as authenticated tenant identity. -For a safe host binding, policy recovery commands, and a runnable collector, refer to [Set Up Deep Agents Trace Export](../monitoring/set-up-deepagents-trace-export). +| --- | --- | --- | --- | +| `observability-otlp-local` | `host.openshell.internal:4318` | `/opt/venv/bin/python3*` | Exact `POST /v1/traces` only | + +The sandbox sends OTLP/HTTP protobuf traces to a collector that the operator runs on the host. The managed exporter uses standard OTLP transport headers but does not accept operator-supplied custom or authentication headers. Remote backend endpoints and credentials stay in that collector. The policy does not allow direct LangSmith, Jaeger, Phoenix, or other backend egress from Deep Agents Code. Changing the collector's downstream exporter requires no sandbox policy change. + +OpenShell observes Deep Agents Code export as the managed Python interpreter, so this permission is process-wide for `/opt/venv/bin/python3*` rather than limited to the `dcode` launcher. Sandbox Python can forge spans and resource attributes. The explicit `--observability` opt-in can export bounded prompts, responses, tool arguments, tool results, and operational metadata. Managed size and recognized-key redaction do not detect secrets embedded in ordinary content values. The collector must enforce the operator's filtering and redaction requirements before forwarding traces, and it must not treat span fields such as `service.name` as authenticated tenant identity. For a safe host binding, policy recovery commands, and a runnable collector, refer to [Set Up Deep Agents Trace Export](../monitoring/set-up-deepagents-trace-export). ## Operator Approval Flow -When the agent attempts to reach an endpoint not listed in the policy, OpenShell intercepts the request and presents it in the TUI for operator review. -The Personal tier does not prompt for matching TCP connections on destination ports `80` or `443` because `personal-open-internet` already permits them. -The flow has these steps: +When the agent attempts to reach an endpoint not listed in the policy, OpenShell intercepts the request and presents it in the TUI for operator review. The Personal tier does not prompt for matching TCP connections on destination ports `80` or `443` because `personal-open-internet` already permits them. The flow has these steps: 1. The agent makes a network request to an unlisted host. 2. OpenShell blocks the connection and logs the attempt. @@ -309,15 +274,13 @@ 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 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. +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 $$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. +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: @@ -327,70 +290,26 @@ openshell policy set --policy current-policy.yaml --wait ### Excluding a baseline entry -The baseline policy applies to every sandbox, but an operator can persistently exclude a specific baseline entry when they accept reduced, unsupported functionality in exchange for tighter egress. - -Preview the scope, then exclude a baseline key: - -```bash -$$nemoclaw policy exclude --dry-run -$$nemoclaw policy exclude --force -``` +The baseline applies to every sandbox, but an operator can remove one exact baseline entry from the current OpenShell policy after accepting the reduced-support impact. -The command prints every endpoint, method/path rule, and binary the exclusion removes. -It also names the supported features that may stop working before it requires explicit acknowledgement (`--force` in non-interactive use). -NemoClaw refuses to exclude an entry that does not have a reviewed feature-impact disclosure, so a new baseline entry cannot bypass this review. -The versioned exclusion record is bound to the reviewed entry content and active agent, recorded in the sandbox registry, and replayed on rebuild. -If the sandbox's active agent changes, NemoClaw requires you to clear or review the exclusion again instead of applying an approval from another agent baseline. -NemoClaw journals the cross-system update before changing the live OpenShell policy and verifies that the live policy equals the intended result before publishing the durable intent. -If the process or persistence layer fails between those steps, `policy list`, `policy explain`, and `status` report `repair required`. -Sandbox creation or recreation, rebuild, and cross-sandbox snapshot cloning stop before destructive work, and rerunning the same `policy exclude` or `policy restore` command reconciles only when the state equals the recorded source or intended target. -An unreadable live policy, or one that matches neither the pre-mutation source nor the intended target, remains fail-closed for manual inspection instead of being guessed or overwritten. -An interrupted restore finalizes only when the durable exclusion still exactly matches the staged exclusion and the current release baseline still exactly matches the journaled target; if either changes or becomes unreadable, the journal remains pending for inspection and re-review. -If a later release changes that entry, create and rebuild fail closed until re-review; if the release removes it, they fail closed until the stale record is cleared. -The critical `managed_inference` entry cannot be excluded because it carries the required route to managed inference. -NemoClaw enforces this both when the command runs and whenever durable exclusion state is replayed. - -An excluded baseline key remains reserved while the exclusion is active. -NemoClaw refuses onboarding, rebuild, built-in and custom presets, messaging channel policies, and generated MCP policies that would define the same key and silently restore its egress. -It also refuses to exclude a key that an applied preset already owns. -Restore the baseline entry before applying a preset that intentionally owns the key, or rename a custom entry whose key represents different access. - -To recover from baseline drift, first check whether the release changed the entry's content or removed it entirely (`$$nemoclaw policy explain` or `doctor` reports which). - -If the entry still exists with different content, `policy restore --force` restores the current baseline entry and clears the stale exclusion record. -This allows the entry's listed egress before you can exclude it again. -Preview the restore and exclusion scopes before applying either change: +Preview and apply the live change: ```bash -$$nemoclaw policy restore --dry-run -$$nemoclaw policy restore --force $$nemoclaw policy exclude --dry-run $$nemoclaw policy exclude --force ``` -If the release removed the entry entirely, `policy exclude ` fails with `Unknown baseline entry ''.` because there is nothing left to exclude. -Preview the stale-record cleanup, then clear the record without changing live egress: +The command prints the endpoints, method and path rules, binaries, and supported features affected by the removal. It refuses entries without a reviewed feature-impact disclosure and refuses keys required by an active preset. The critical `managed_inference` entry cannot be excluded because it carries the required managed-inference route. -```bash -$$nemoclaw policy restore --dry-run -$$nemoclaw policy restore --force -``` +NemoClaw reads the current OpenShell policy, removes the selected key, writes the complete modified document, and verifies the live result. It does not create an exclusion record, journal, or replay state. Rebuild and clone preserve the current OpenShell policy as a whole, so a change made with this command has the same lifecycle as one made through the OpenShell TUI or another trusted host process. -List active exclusions with `policy list`. -`policy explain`, `status`, `doctor`, and snapshot and rebuild summaries also disclose active exclusions and their reduced-support impact. -Status and doctor compare each approval with the active agent baseline and verify that the excluded key is absent from the live OpenShell policy. -An unreadable live policy is unverified, while a live policy that contains the key is a mismatch that requires repair before you rely on the exclusion. -When the current baseline still defines the entry, `policy restore --force` allows the entry's listed egress again and clears its exclusion. -When the baseline no longer defines the entry, the command clears only the stale exclusion record. -Preview the applicable result before you apply it: +Restore an entry from the current agent baseline with: ```bash $$nemoclaw policy restore --dry-run $$nemoclaw policy restore --force ``` -Both restore paths require acknowledgement unless you use `--dry-run`. -A run with a terminal on stdin prompts for confirmation when no acknowledgement flag is present. -A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, requires `--force`, `--yes`, or `-y`. +If the current baseline still defines the key, restore previews and then adds that current entry to the live OpenShell policy. If the baseline no longer defines it, the command reports that there is nothing to restore and leaves the live policy unchanged. Both mutation commands require acknowledgement unless `--dry-run` is used. A non-interactive run requires `--force`, `--yes`, or `-y`. Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 3f42a6eee84..d6005691cd6 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -5,55 +5,56 @@ title: "Troubleshooting" sidebar-title: "Troubleshooting" description: "Diagnose and resolve common NemoClaw installation, onboarding, and runtime issues." description-agent: "Lists fixes for common installation, onboarding, and runtime issues. Use when diagnosing a reported NemoClaw error, a failed onboard, or unexpected sandbox behavior." -keywords: ["nemoclaw troubleshooting", "nemoclaw debug sandbox issues", "openclaw tool calling", "raw tool call json"] +keywords: + [ + "nemoclaw troubleshooting", + "nemoclaw debug sandbox issues", + "openclaw tool calling", + "raw tool call json", + ] content: type: "reference" --- + {/* markdownlint-disable MD014 */} This page covers common installation, onboarding, and runtime issues, along with resolution steps. -The diagnostic commands on this page assume `$$nemoclaw` is on your `PATH` (re-source your shell profile after an nvm- or fnm-managed install) and that your user can reach the Docker socket — either as a member of the `docker` group or by running the Docker commands with `sudo`. + The diagnostic commands on this page assume `$$nemoclaw` is on your `PATH` (re-source your shell + profile after an nvm- or fnm-managed install) and that your user can reach the Docker socket — + either as a member of the `docker` group or by running the Docker commands with `sudo`. -If your issue is not listed here, join the [NemoClaw Discord channel](https://discord.gg/XFpfPv9Uvx) to ask questions and get help from the community. -You can also [file an issue on GitHub](https://github.com/NVIDIA/NemoClaw/issues/new). +If your issue is not listed here, join the [NemoClaw Discord channel](https://discord.gg/XFpfPv9Uvx) to ask questions and get help from the community. You can also [file an issue on GitHub](https://github.com/NVIDIA/NemoClaw/issues/new). + ## Installation ### `$$nemoclaw` not found after install -If you use nvm or fnm to manage Node.js, the installer may not update your current shell's PATH. -The `$$nemoclaw` binary is installed but the shell session does not know where to find it. +If you use nvm or fnm to manage Node.js, the installer may not update your current shell's PATH. The `$$nemoclaw` binary is installed but the shell session does not know where to find it. Run `source ~/.bashrc` (or `source ~/.zshrc` for zsh), or open a new terminal window. -When installing from a source checkout with `npm install`, NemoClaw first tries `npm link`. -If the global npm prefix is not writable, it writes a managed shim to `~/.local/bin/nemoclaw` instead. -Add `~/.local/bin` to your `PATH` if the command is still not found. -Source-checkout installs also bootstrap OpenShell when it is missing before running preflight. -If a source install still reports that `openshell` is not available, re-run the installer from the repository root and check that `~/.local/bin` is on your `PATH`. +When installing from a source checkout with `npm install`, NemoClaw first tries `npm link`. If the global npm prefix is not writable, it writes a managed shim to `~/.local/bin/nemoclaw` instead. Add `~/.local/bin` to your `PATH` if the command is still not found. Source-checkout installs also bootstrap OpenShell when it is missing before running preflight. If a source install still reports that `openshell` is not available, re-run the installer from the repository root and check that `~/.local/bin` is on your `PATH`. ### Installer fails on unsupported platform -The installer checks for a supported OS and architecture before proceeding. -If you see an unsupported platform error, verify that you are running on a tested platform listed in the Container Runtimes table in the quickstart guide. +The installer checks for a supported OS and architecture before proceeding. If you see an unsupported platform error, verify that you are running on a tested platform listed in the Container Runtimes table in the quickstart guide. ### Node.js version is too old -NemoClaw requires Node.js 22.19 or later. -If the installer exits with a Node.js version error, check your current version: +NemoClaw requires Node.js 22.19 or later. If the installer exits with a Node.js version error, check your current version: ```bash node --version ``` -If the version is below 22.19, install a supported release. -If you use nvm, run: +If the version is below 22.19, install a supported release. If you use nvm, run: ```bash nvm install 22 @@ -64,8 +65,7 @@ Then re-run the installer. ### Installer Reports `No SHA-256 tool available (sha256sum/shasum)` -The installer must verify the nvm installer before it installs or upgrades Node.js. -It exits before running the downloaded script when neither `sha256sum` nor `shasum` is available. +The installer must verify the nvm installer before it installs or upgrades Node.js. It exits before running the downloaded script when neither `sha256sum` nor `shasum` is available. On Debian or Ubuntu, install `sha256sum` through `coreutils`: @@ -74,8 +74,7 @@ sudo apt-get update sudo apt-get install -y coreutils ``` -On another Linux distribution, install its `coreutils` package. -On macOS, `/usr/bin/shasum` is normally present; restore it through the operating system if it is missing. +On another Linux distribution, install its `coreutils` package. On macOS, `/usr/bin/shasum` is normally present; restore it through the operating system if it is missing. Verify that one supported tool is available, then rerun the installer: @@ -87,9 +86,7 @@ command -v sha256sum || command -v shasum This applies to a source checkout, not to an installed release. -Node.js derives its default old-space limit from host memory. -On a host with 8 GB of RAM, that limit is about 2.2 GB. -The CLI type check needs more heap than that limit, so `./scripts/dev-setup.sh` stops at the type-check step and Node.js reports `JavaScript heap out of memory`. +Node.js derives its default old-space limit from host memory. On a host with 8 GB of RAM, that limit is about 2.2 GB. The CLI type check needs more heap than that limit, so `./scripts/dev-setup.sh` stops at the type-check step and Node.js reports `JavaScript heap out of memory`. Raise the limit, then run setup again: @@ -102,9 +99,7 @@ Keep that variable set for later type-check, build, and test commands. ### Image push fails with out-of-memory errors -The sandbox image is approximately 2.4 GB compressed. -During image push, the Docker daemon, k3s, and the OpenShell gateway run alongside the export pipeline, which buffers decompressed layers in memory. -On machines with less than 8 GB of RAM, this combined usage can trigger the OOM killer. +The sandbox image is approximately 2.4 GB compressed. During image push, the Docker daemon, k3s, and the OpenShell gateway run alongside the export pipeline, which buffers decompressed layers in memory. On machines with less than 8 GB of RAM, this combined usage can trigger the OOM killer. If you cannot add memory, configure at least 8 GB of swap to work around the issue at the cost of slower performance. @@ -116,12 +111,9 @@ Check the host before onboarding: $$nemoclaw host probe ``` -The command does not start Docker or apply a repair. -A `host.docker.daemon_unreachable` finding means Docker is installed but NemoClaw cannot reach the daemon. -For JSON output and exit-code details, refer to [System Readiness](system-readiness). +The command does not start Docker or apply a repair. A `host.docker.daemon_unreachable` finding means Docker is installed but NemoClaw cannot reach the daemon. For JSON output and exit-code details, refer to [System Readiness](system-readiness). -The installer and onboard wizard require Docker to be running. -If you see a Docker connection error, start the Docker daemon: +The installer and onboard wizard require Docker to be running. If you see a Docker connection error, start the Docker daemon: ```bash sudo systemctl start docker @@ -131,15 +123,15 @@ On macOS with Docker Desktop, open the Docker Desktop application and wait for i ### Docker permission denied on Linux -On Linux, if the Docker daemon is running but you see "permission denied" errors, your user may not be in the `docker` group. -The installer can add your user to the group, but Linux does not activate that membership in the current shell automatically. -Add your user and activate the group in the current shell: +On Linux, if the Docker daemon is running but you see "permission denied" errors, your user may not be in the `docker` group. The installer can add your user to the group, but Linux does not activate that membership in the current shell automatically. Add your user and activate the group in the current shell: -NemoClaw needs Docker access. -On personal Linux development machines, adding your user to the `docker` group is the standard way to run Docker without sudo. -Members of the `docker` group can control the daemon with root-level impact, so grant this access only to trusted local accounts; on shared or managed systems, use your organization's approved Docker access path. -For background, review Docker's [daemon attack surface guidance](https://docs.docker.com/engine/security/#docker-daemon-attack-surface). + NemoClaw needs Docker access. On personal Linux development machines, adding your user to the + `docker` group is the standard way to run Docker without sudo. Members of the `docker` group can + control the daemon with root-level impact, so grant this access only to trusted local accounts; on + shared or managed systems, use your organization's approved Docker access path. For background, + review Docker's [daemon attack surface + guidance](https://docs.docker.com/engine/security/#docker-daemon-attack-surface). ```bash @@ -147,8 +139,7 @@ sudo usermod -aG docker $USER newgrp docker ``` -Then retry `$$nemoclaw onboard`. -If the installer stopped after printing `newgrp docker`, run that command and then re-run the installer: +Then retry `$$nemoclaw onboard`. If the installer stopped after printing `newgrp docker`, run that command and then re-run the installer: ```bash newgrp docker @@ -157,9 +148,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ### Installer reports Docker access outside the docker group -On Linux, the installer may report that Docker is reachable even though your user is not in the `docker` group. -This means the host grants Docker daemon access through another path, such as a custom `DOCKER_HOST`, socket ACL, or managed runtime policy. -NemoClaw can continue when `docker info` works, but the diagnostic explains why a negative Docker-permission test will not reproduce on that host. +On Linux, the installer may report that Docker is reachable even though your user is not in the `docker` group. This means the host grants Docker daemon access through another path, such as a custom `DOCKER_HOST`, socket ACL, or managed runtime policy. NemoClaw can continue when `docker info` works, but the diagnostic explains why a negative Docker-permission test will not reproduce on that host. Check the Docker access path before relying on the host as a clean permission baseline: @@ -169,16 +158,11 @@ echo "${DOCKER_HOST:-}" docker info ``` -The managed default gateway service accepts `DOCKER_HOST` only as an absolute local `unix://` socket path. -It rejects remote endpoints and relative socket paths before service startup. +The managed default gateway service accepts `DOCKER_HOST` only as an absolute local `unix://` socket path. It rejects remote endpoints and relative socket paths before service startup. ### Onboarding Reports an Invalid Docker Host -The `invalid_docker_host` advisory means that `DOCKER_HOST` is not an absolute local `unix://` socket path that NemoClaw can write to the managed OpenShell gateway service environment. -NemoClaw does not use the standalone gateway fallback when this validation fails. -Onboarding prints the advisory identifier in parentheses after each action title in the `Suggested fix` list. -The terminal output names `invalid_docker_host` when this validation fails, so you can match the message to this section. -Remove the override to use Docker's default local socket: +The `invalid_docker_host` advisory means that `DOCKER_HOST` is not an absolute local `unix://` socket path that NemoClaw can write to the managed OpenShell gateway service environment. NemoClaw does not use the standalone gateway fallback when this validation fails. Onboarding prints the advisory identifier in parentheses after each action title in the `Suggested fix` list. The terminal output names `invalid_docker_host` when this validation fails, so you can match the message to this section. Remove the override to use Docker's default local socket: ```bash unset DOCKER_HOST @@ -192,19 +176,13 @@ export DOCKER_HOST=unix:///var/run/docker.sock $$nemoclaw onboard ``` -NemoClaw rejects TCP and SSH endpoints, relative socket paths, values that contain single quotes, and values that contain line breaks. -Do not wrap the socket path in single quotes inside the variable value. +NemoClaw rejects TCP and SSH endpoints, relative socket paths, values that contain single quotes, and values that contain line breaks. Do not wrap the socket path in single quotes inside the variable value. ### Onboarding Warns About the Docker Desktop Credential Store in a Headless Session -The `docker_desktop_credential_store_headless` advisory means that the Docker client config sets `credsStore` to `desktop` (macOS) or `desktop.exe` (WSL2) and the session looks headless, for example an SSH session without a GUI. -The Docker Desktop credential helper needs an interactive GUI session. -Without one, the helper can fail and block every image pull, even for public images. -A common failure message from Docker in this state is `A specified logon session does not exist`. +The `docker_desktop_credential_store_headless` advisory means that the Docker client config sets `credsStore` to `desktop` (macOS) or `desktop.exe` (WSL2) and the session looks headless, for example an SSH session without a GUI. The Docker Desktop credential helper needs an interactive GUI session. Without one, the helper can fail and block every image pull, even for public images. A common failure message from Docker in this state is `A specified logon session does not exist`. -Onboarding preflight prints this warning before the first image pull and then continues, on both fresh and resumed onboarding. -NemoClaw reads the config from `$DOCKER_CONFIG/config.json` when `DOCKER_CONFIG` is set, and from `~/.docker/config.json` otherwise. -On WSL, NemoClaw probes the credential helper with a read-only `list` call instead of relying on session markers, because WSLg can set `DISPLAY` in every WSL shell. +Onboarding preflight prints this warning before the first image pull and then continues, on both fresh and resumed onboarding. NemoClaw reads the config from `$DOCKER_CONFIG/config.json` when `DOCKER_CONFIG` is set, and from `~/.docker/config.json` otherwise. On WSL, NemoClaw probes the credential helper with a read-only `list` call instead of relying on session markers, because WSLg can set `DISPLAY` in every WSL shell. To work around a failed pull, resume onboarding with an isolated Docker config so every remaining image pull bypasses the unavailable helper: @@ -212,8 +190,7 @@ To work around a failed pull, resume onboarding with an isolated Docker config s DOCKER_CONFIG=$(mktemp -d) $$nemoclaw onboard --resume ``` -Alternatively, temporarily remove the `credsStore` entry from the Docker client config named above, then rerun `$$nemoclaw onboard`. -Restore the entry afterward if you use registries that need stored credentials in GUI sessions. +Alternatively, temporarily remove the `credsStore` entry from the Docker client config named above, then rerun `$$nemoclaw onboard`. Restore the entry afterward if you use registries that need stored credentials in GUI sessions. ### macOS first-run failures @@ -226,8 +203,7 @@ To avoid these issues, install the prerequisites in the following order before r ### `docker` is missing after installing Colima -Homebrew Colima does not install the Docker CLI binary. -If you install only Colima, `colima start` can succeed while later `docker` commands fail with `command not found`. +Homebrew Colima does not install the Docker CLI binary. If you install only Colima, `colima start` can succeed while later `docker` commands fail with `command not found`. Install both packages, start Colima with enough resources for the sandbox image build, and verify Docker before onboarding: @@ -239,17 +215,13 @@ docker info ### Permission errors during installation -The NemoClaw installer does not require `sudo` or root. -It installs Node.js via nvm and NemoClaw via npm, both into user-local directories. -The installer also handles OpenShell installation automatically using a pinned release. +The NemoClaw installer does not require `sudo` or root. It installs Node.js via nvm and NemoClaw via npm, both into user-local directories. The installer also handles OpenShell installation automatically using a pinned release. -If you see permission errors during installation, they typically come from Docker, not the NemoClaw installer itself. -Docker must be installed and running before you run the installer, and installing Docker may require elevated privileges on Linux. +If you see permission errors during installation, they typically come from Docker, not the NemoClaw installer itself. Docker must be installed and running before you run the installer, and installing Docker may require elevated privileges on Linux. ### npm install fails with permission errors -If `npm install` fails with an `EACCES` permission error, do not run npm with `sudo`. -Instead, configure npm to use a directory you own: +If `npm install` fails with an `EACCES` permission error, do not run npm with `sudo`. Instead, configure npm to use a directory you own: ```bash mkdir -p ~/.npm-global @@ -261,15 +233,9 @@ Add the `export` line to your `~/.bashrc` or `~/.zshrc` to make it permanent, th ### Installer fails on NVIDIA Jetson -The installer auto-detects NVIDIA Jetson devices (Orin and Thor) and applies required host configuration before the normal install flow. -If the Jetson setup step fails, verify that you have `sudo` access and that Docker is installed and running. +The installer auto-detects NVIDIA Jetson devices (Orin and Thor) and applies required host configuration before the normal install flow. If the Jetson setup step fails, verify that you have `sudo` access and that Docker is installed and running. -For JetPack 6 (L4T 36.x), the setup switches iptables to legacy mode and adjusts the Docker daemon configuration. -For JetPack 7 (L4T 38.x / Thor), only bridge netfilter and sysctl settings are applied. -For JetPack 7 (L4T 39.x), bridge netfilter is loaded only when the host is missing it. -Some R39 images already ship with `br_netfilter` configured and are left untouched. -On affected R39 hosts, the installer prints `loading br_netfilter (required by k3s inside the OpenShell gateway)`. -Without this fix, sandbox pods fail DNS resolution against the in-cluster service and the onboard `Setting up OpenClaw inside sandbox` step times out. +For JetPack 6 (L4T 36.x), the setup switches iptables to legacy mode and adjusts the Docker daemon configuration. For JetPack 7 (L4T 38.x / Thor), only bridge netfilter and sysctl settings are applied. For JetPack 7 (L4T 39.x), bridge netfilter is loaded only when the host is missing it. Some R39 images already ship with `br_netfilter` configured and are left untouched. On affected R39 hosts, the installer prints `loading br_netfilter (required by k3s inside the OpenShell gateway)`. Without this fix, sandbox pods fail DNS resolution against the in-cluster service and the onboard `Setting up OpenClaw inside sandbox` step times out. If the L4T version is not recognized, the setup step is skipped and the installer continues normally. @@ -281,11 +247,8 @@ NemoClaw's preflight runs a short `docker run --rm busybox nslookup nemoclaw-dns Use the preflight headline to choose the recovery path: -- If no DNS servers could be reached, Docker could not reach its configured resolver. - Follow the platform-specific UDP port 53 and Docker DNS steps below. -- If the DNS server was reachable but rejected the query with `NXDOMAIN` or `REFUSED`, the resolver answered, so the UDP port 53 fix is not relevant. - Check the resolver used by Docker, such as dnsmasq, Pi-hole, unbound, or systemd-resolved, and remove any forwarding rule, blocklist entry, or ACL that rejects `registry.npmjs.org`. - If needed, configure Docker to use an organization-approved resolver that can resolve public names, restart Docker, and retry onboarding. +- If no DNS servers could be reached, Docker could not reach its configured resolver. Follow the platform-specific UDP port 53 and Docker DNS steps below. +- If the DNS server was reachable but rejected the query with `NXDOMAIN` or `REFUSED`, the resolver answered, so the UDP port 53 fix is not relevant. Check the resolver used by Docker, such as dnsmasq, Pi-hole, unbound, or systemd-resolved, and remove any forwarding rule, blocklist entry, or ACL that rejects `registry.npmjs.org`. If needed, configure Docker to use an organization-approved resolver that can resolve public names, restart Docker, and retry onboarding. For an unreachable resolver, pick the matching platform path below, apply it, then re-run `$$nemoclaw onboard`. @@ -304,9 +267,7 @@ When the lookup returns an answer, retry onboarding. ### Direct DNS lookups fail in a Docker-driver GPU sandbox -Egress covered by OpenShell network policies resolves destinations through the gateway. -A direct DNS lookup inside the agent network namespace can fail on Docker-driver GPU hosts such as DGX Spark even when policy-covered inference, messaging, and search work normally. -Direct in-sandbox DNS depends on Docker and the host resolver and is not a supported NemoClaw network-policy path, so NemoClaw does not use it as a sandbox health check. +Egress covered by OpenShell network policies resolves destinations through the gateway. A direct DNS lookup inside the agent network namespace can fail on Docker-driver GPU hosts such as DGX Spark even when policy-covered inference, messaging, and search work normally. Direct in-sandbox DNS depends on Docker and the host resolver and is not a supported NemoClaw network-policy path, so NemoClaw does not use it as a sandbox health check. Run a manual lookup only when you are diagnosing a custom tool that performs its own DNS resolution: @@ -315,19 +276,13 @@ openshell sandbox exec --name -- getent hosts host.openshell.inte openshell sandbox exec --name -- getent hosts example.com ``` -If the host alias resolves but the external name does not, Docker's embedded resolver may be forwarding to an upstream DNS server that the sandbox bridge cannot use. -Do not treat this result as evidence that a policy-covered feature is unhealthy. -Test the affected inference, messaging, or search request through its normal policy path and inspect denied requests with `openshell term`. +If the host alias resolves but the external name does not, Docker's embedded resolver may be forwarding to an upstream DNS server that the sandbox bridge cannot use. Do not treat this result as evidence that a policy-covered feature is unhealthy. Test the affected inference, messaging, or search request through its normal policy path and inspect denied requests with `openshell term`. -If a custom tool requires direct DNS, configure the Docker daemon to use a resolver that containers can reach. -On hosts with VPN or split-DNS software, use an upstream resolver that remains reachable from the Docker bridge, then recreate or rebuild the sandbox. -Keep bridge networking enabled so the sandbox retains its normal Docker network isolation. +If a custom tool requires direct DNS, configure the Docker daemon to use a resolver that containers can reach. On hosts with VPN or split-DNS software, use an upstream resolver that remains reachable from the Docker bridge, then recreate or rebuild the sandbox. Keep bridge networking enabled so the sandbox retains its normal Docker network isolation. ### Host DNS resolution is blocked before provider validation -NemoClaw also checks that the host process can resolve the provider host before it starts NVIDIA provider validation. -A firewall rule that blocks host DNS traffic on port `53` can make later validation fail with `curl: (6) Could not resolve host: integrate.api.nvidia.com` even when container DNS probes look healthy. -Current onboarding stops earlier with a host DNS diagnostic and remediation hints. +NemoClaw also checks that the host process can resolve the provider host before it starts NVIDIA provider validation. A firewall rule that blocks host DNS traffic on port `53` can make later validation fail with `curl: (6) Could not resolve host: integrate.api.nvidia.com` even when container DNS probes look healthy. Current onboarding stops earlier with a host DNS diagnostic and remediation hints. Verify host DNS outside NemoClaw: @@ -335,51 +290,31 @@ Verify host DNS outside NemoClaw: node -e 'require("node:dns").resolve4("integrate.api.nvidia.com", (err, addrs) => { if (err) { console.error(err); process.exit(1); } console.log(addrs.join(",")); })' ``` -Fix the host firewall, VPN, or DNS policy so the host can resolve the provider endpoint, then rerun onboarding. -If you intentionally use a non-NVIDIA provider and need to bypass only this preflight, set `NEMOCLAW_SKIP_HOST_DNS_PREFLIGHT=1`. +Fix the host firewall, VPN, or DNS policy so the host can resolve the provider endpoint, then rerun onboarding. If you intentionally use a non-NVIDIA provider and need to bypass only this preflight, set `NEMOCLAW_SKIP_HOST_DNS_PREFLIGHT=1`. ### Port already in use -The NemoClaw dashboard uses port `18789` by default and the gateway uses port `8080`. -If another sandbox already owns the dashboard port, onboarding scans ports `18789` through `18799` and uses the next free port. -If all ports in that range are occupied, the error lists the owner for each port and suggests using `--control-ui-port` with a port outside the range. +The NemoClaw dashboard uses port `18789` by default and the gateway uses port `8080`. If another sandbox already owns the dashboard port, onboarding scans ports `18789` through `18799` and uses the next free port. If all ports in that range are occupied, the error lists the owner for each port and suggests using `--control-ui-port` with a port outside the range. -NemoClaw allocates each Hermes sandbox an OpenAI-compatible API port from `8642` through `8652`, so it rejects every port in that range as a dashboard port for any agent. -When all ports in the API range are occupied, the error lists the owner for each port. -Destroy a listed Hermes sandbox or stop a listed non-OpenShell listener, then rerun onboarding. +NemoClaw allocates each Hermes sandbox an OpenAI-compatible API port from `8642` through `8652`, so it rejects every port in that range as a dashboard port for any agent. When all ports in the API range are occupied, the error lists the owner for each port. Destroy a listed Hermes sandbox or stop a listed non-OpenShell listener, then rerun onboarding. -On macOS, the port check also tries a privileged `lsof` probe without prompting for a password so root-owned listeners are detected before the sandbox build starts. -For a new sandbox, NemoClaw reserves the selected loopback port through sandbox preparation and the image build. -If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources. -NemoClaw releases the reservation immediately before `openshell forward start` runs. -If forwarding then fails, onboarding removes the new sandbox and tells you to resolve the reported error before retrying. +On macOS, the port check also tries a privileged `lsof` probe without prompting for a password so root-owned listeners are detected before the sandbox build starts. For a new sandbox, NemoClaw reserves the selected loopback port through sandbox preparation and the image build. If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources. NemoClaw releases the reservation immediately before `openshell forward start` runs. If forwarding then fails, onboarding removes the new sandbox and tells you to resolve the reported error before retrying. -When a previous onboard, upgrade, or sandbox crash leaves a stale `openclaw-gateway` host process holding the dashboard port, `$$nemoclaw onboard --fresh`, `$$nemoclaw destroy` (when destroying the last sandbox), and `$$nemoclaw uninstall` automatically sweep the dashboard port range and signal `SIGTERM` then `SIGKILL` to recover. -The sweep only targets processes owned by the current user whose command line matches `openclaw-gateway` or `openshell forward` markers, and skips dashboard ports owned by other live sandboxes. +When a previous onboard, upgrade, or sandbox crash leaves a stale `openclaw-gateway` host process holding the dashboard port, `$$nemoclaw onboard --fresh`, `$$nemoclaw destroy` (when destroying the last sandbox), and `$$nemoclaw uninstall` automatically sweep the dashboard port range and signal `SIGTERM` then `SIGKILL` to recover. The sweep only targets processes owned by the current user whose command line matches `openclaw-gateway` or `openshell forward` markers, and skips dashboard ports owned by other live sandboxes. -If onboarding preflight resolves the complete listener set for a gateway port conflict, the diagnostic lists every listener. -Each entry contains the process name and PID, or only the PID when NemoClaw cannot read the process name. -The diagnostic identifies listeners that fail ownership verification, but it does not print a reusable process-stop command. -If NemoClaw resolves no listener, the diagnostic provides an `lsof` inspection command. -Before you stop a listener, confirm that it is not part of a second NemoClaw gateway environment. -Release that environment with `NEMOCLAW_GATEWAY_PORT= $$nemoclaw uninstall` instead of stopping its process. +If onboarding preflight resolves the complete listener set for a gateway port conflict, the diagnostic lists every listener. Each entry contains the process name and PID, or only the PID when NemoClaw cannot read the process name. The diagnostic identifies listeners that fail ownership verification, but it does not print a reusable process-stop command. If NemoClaw resolves no listener, the diagnostic provides an `lsof` inspection command. Before you stop a listener, confirm that it is not part of a second NemoClaw gateway environment. Release that environment with `NEMOCLAW_GATEWAY_PORT= $$nemoclaw uninstall` instead of stopping its process. -If a non-NemoClaw process is already bound to the dashboard port or the gateway port, identify the conflicting process. -Stop it only when its command line names the application you intend to stop, you own the process or administer its service, and the application has no active work: +If a non-NemoClaw process is already bound to the dashboard port or the gateway port, identify the conflicting process. Stop it only when its command line names the application you intend to stop, you own the process or administer its service, and the application has no active work: ```bash sudo lsof -i :18789 -sTCP:LISTEN -P -n ``` -Stop it through its service manager when one owns it. -Otherwise, repeat the listener check immediately before you signal only the PID from that fresh result. -Repeat the check again before any `SIGKILL`. -Then retry onboarding. +Stop it through its service manager when one owns it. Otherwise, repeat the listener check immediately before you signal only the PID from that fresh result. Repeat the check again before any `SIGKILL`. Then retry onboarding. -Alternatively, override the conflicting port instead of stopping the other process. -Pass `--control-ui-port` with the desired dashboard port: +Alternatively, override the conflicting port instead of stopping the other process. Pass `--control-ui-port` with the desired dashboard port: ```bash $$nemoclaw onboard --control-ui-port 19000 @@ -409,8 +344,7 @@ Remote/headless hosts should keep the OpenShell gateway on loopback and bind the NEMOCLAW_DASHBOARD_BIND=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 $$nemoclaw onboard ``` -Use `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` again on later `$$nemoclaw connect` calls. -If the sandbox was originally created without remote bind, recreate it with the same onboard command plus `--recreate-sandbox` before connecting remotely. +Use `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` again on later `$$nemoclaw connect` calls. If the sandbox was originally created without remote bind, recreate it with the same onboard command plus `--recreate-sandbox` before connecting remotely. NemoClaw rejects `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` for Docker-driver gateways while gateway JWT auth is active. @@ -420,14 +354,9 @@ Use `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` only on supported gateway modes and ### Older-glibc gateway compatibility container -OpenShell 0.0.106 directly supports Linux hosts with glibc 2.39 or newer. -On an older trusted host, `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1` explicitly opts into NemoClaw's compatibility container. -Leave it unset on supported hosts. +OpenShell 0.0.106 directly supports Linux hosts with glibc 2.39 or newer. On an older trusted host, `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1` explicitly opts into NemoClaw's compatibility container. Leave it unset on supported hosts. -The compatibility container uses host networking and mounts the host Docker socket read-only. -A read-only socket mount still permits privileged Docker API operations and can control the host, so do not enable this mode on an untrusted or shared host. -The gateway remains loopback-bound, and startup fails closed unless the configured Unix socket answers as a Docker daemon. -See [Gateway Compatibility Container](../security/security-controls/gateway-authentication-controls#gateway-compatibility-container) for the container boundary and removal conditions. +The compatibility container uses host networking and mounts the host Docker socket read-only. A read-only socket mount still permits privileged Docker API operations and can control the host, so do not enable this mode on an untrusted or shared host. The gateway remains loopback-bound, and startup fails closed unless the configured Unix socket answers as a Docker daemon. See [Gateway Compatibility Container](../security/security-controls/gateway-authentication-controls#gateway-compatibility-container) for the container boundary and removal conditions. Refer to [Environment Variables](commands#environment-variables) for the full list of port overrides. @@ -435,9 +364,7 @@ Refer to [Environment Variables](commands#environment-variables) for the full li ### Running multiple sandboxes simultaneously -Each sandbox requires its own dashboard port. -If you onboard a second sandbox without overriding the port, onboarding uses the next free port in the `18789` to `18799` range. -`onboard` checks `openshell forward list` before starting a new forward, so a second onboard cannot silently take over the first sandbox's port. +Each sandbox requires its own dashboard port. If you onboard a second sandbox without overriding the port, onboarding uses the next free port in the `18789` to `18799` range. `onboard` checks `openshell forward list` before starting a new forward, so a second onboard cannot silently take over the first sandbox's port. Assign a distinct port only when you want a specific value: @@ -461,8 +388,7 @@ openshell forward list $$nemoclaw list ``` -`$$nemoclaw list` prints the recorded dashboard URL for each sandbox. -These dashboard ports are separate from the gateway-wide inference route. +`$$nemoclaw list` prints the recorded dashboard URL for each sandbox. These dashboard ports are separate from the gateway-wide inference route. @@ -470,9 +396,7 @@ These dashboard ports are separate from the gateway-wide inference route. A gateway port that keeps listening after you uninstall, or that keeps serving after you re-onboard without `NEMOCLAW_GATEWAY_PORT` set, can belong to a second environment. -Onboarding under a non-default `NEMOCLAW_GATEWAY_PORT` registers the sandbox on gateway `nemoclaw-` and stores its registry and state under `~/.nemoclaw/gateways//`. -Every later command reads the state root that its own `NEMOCLAW_GATEWAY_PORT` selects, so a command run without that variable operates on port `8080` and neither reuses nor stops the other environment. -Clearing the variable does not move an existing sandbox back to the default port. +Onboarding under a non-default `NEMOCLAW_GATEWAY_PORT` registers the sandbox on gateway `nemoclaw-` and stores its registry and state under `~/.nemoclaw/gateways//`. Every later command reads the state root that its own `NEMOCLAW_GATEWAY_PORT` selects, so a command run without that variable operates on port `8080` and neither reuses nor stops the other environment. Clearing the variable does not move an existing sandbox back to the default port. List the gateways the host still has: @@ -482,10 +406,7 @@ openshell gateway list -If this happened after the deprecated global `$$nemoclaw stop`, read the command's final status line. -Without a resolved sandbox name, the command releases a gateway only when a valid, explicitly set `NEMOCLAW_GATEWAY_PORT` selects it. -`Host services stopped; managed gateway not released.` means the command stopped scoped host services but intentionally left the gateway listener running. -After `openshell gateway list` confirms the target port, rerun the deprecated full stop with that scope only when you intend to release that gateway: +If this happened after the deprecated global `$$nemoclaw stop`, read the command's final status line. Without a resolved sandbox name, the command releases a gateway only when a valid, explicitly set `NEMOCLAW_GATEWAY_PORT` selects it. `Host services stopped; managed gateway not released.` means the command stopped scoped host services but intentionally left the gateway listener running. After `openshell gateway list` confirms the exact target port, rerun the deprecated full stop with that scope only when you intend to release that gateway: ```bash NEMOCLAW_GATEWAY_PORT=9000 $$nemoclaw stop @@ -495,11 +416,7 @@ If NemoClaw reports that release was not confirmed, inspect the remaining listen -If the gateway name and its port-scoped state remain, treat it as a second environment and select that port for cleanup. -If the gateway is absent but the port still listens, cleanup did not stop the listener; follow the process or service remediation printed by uninstall before you retry. -If uninstall reported that it kept an `openshell-gateway` process owned by another user running, that process still holds the port. -This can happen after uninstall exits successfully because NemoClaw does not treat another user's process as a cleanup failure. -Ask that user to stop the process, or onboard under a different `NEMOCLAW_GATEWAY_PORT`. +If the gateway name and its port-scoped state remain, treat it as a second environment and select that port for cleanup. If the gateway is absent but the port still listens, cleanup did not stop the listener; follow the process or service remediation printed by uninstall before you retry. If uninstall reported that it kept an `openshell-gateway` process owned by another user running, that process still holds the port. This can happen after uninstall exits successfully because NemoClaw does not treat another user's process as a cleanup failure. Ask that user to stop the process, or onboard under a different `NEMOCLAW_GATEWAY_PORT`. Remove one environment by selecting its port: @@ -513,14 +430,9 @@ Remove every gateway port in one run: $$nemoclaw uninstall --all-gateway-ports ``` -After you confirm uninstall and it exits with status `0`, run `openshell gateway list` again. -For a NemoClaw-managed gateway without `--keep-openshell`, the gateway name that uninstall removed must be absent. -An externally supervised gateway or a run with `--keep-openshell` preserves the gateway process and its resources. +After you confirm uninstall and it exits with status `0`, run `openshell gateway list` again. For a NemoClaw-managed gateway without `--keep-openshell`, the gateway name that uninstall removed must be absent. An externally supervised gateway or a run with `--keep-openshell` preserves the gateway process and its resources. -The same scoping applies to `$$nemoclaw stop`. -When `stop` reports that no valid gateway binding is registered for a sandbox, the sandbox can be registered under a different gateway port. -Rerun `stop` with that `NEMOCLAW_GATEWAY_PORT` value set. -If that does not find the sandbox, resolve the missing, invalid, or unreadable registry entry that the command reports. +The same scoping applies to `$$nemoclaw stop`. When `stop` reports that no valid gateway binding is registered for a sandbox, the sandbox can be registered under a different gateway port. Rerun `stop` with that `NEMOCLAW_GATEWAY_PORT` value set. If that does not find the sandbox, resolve the missing, invalid, or unreadable registry entry that the command reports. Refer to [Uninstall NemoClaw](../manage-sandboxes/operate-sandboxes/uninstall-nemoclaw) for the full sweep contract. @@ -528,11 +440,9 @@ Refer to [Uninstall NemoClaw](../manage-sandboxes/operate-sandboxes/uninstall-ne Refer to [Use Shared Gateway Routes](../inference/manage-inference/use-shared-gateway-routes) for route time-sharing, provider-global compatibility, and status drift fields. -If `inference set` reports a valid shared-route conflict, align the named sandbox records or remove a sandbox you no longer need. -If onboarding or `connect` reports a provider-global identity conflict, align the same-name provider's custom endpoint, API family, and credential environment-variable name across the named sandboxes, or remove a conflicting sandbox you no longer need. +If `inference set` reports a valid shared-route conflict, align the named sandbox records or remove a sandbox you no longer need. If onboarding or `connect` reports a provider-global identity conflict, align the same-name provider's custom endpoint, API family, and credential environment-variable name across the named sandboxes, or remove a conflicting sandbox you no longer need. -If the error names incomplete legacy custom-route metadata, back up and remove the affected sandbox, then re-onboard it with an explicit custom endpoint and API family. -For an OpenAI-compatible route, replace the example endpoint, model, and sandbox name in this recovery sequence: +If the error names incomplete legacy custom-route metadata, back up and remove the affected sandbox, then re-onboard it with an explicit custom endpoint and API family. For an OpenAI-compatible route, replace the example endpoint, model, and sandbox name in this recovery sequence: ```bash $$nemoclaw legacy-sandbox destroy @@ -543,15 +453,13 @@ NEMOCLAW_PROVIDER=custom \ $$nemoclaw onboard --name legacy-sandbox ``` -If the error names an invalid gateway binding, restore the affected row's known-good `gatewayName` and `gatewayPort` metadata from a trusted backup; otherwise back up and remove the sandbox, then re-onboard it. -Do not guess or copy a binding from another sandbox because lifecycle commands use it to select the gateway. +If the error names an invalid gateway binding, restore the affected row's known-good `gatewayName` and `gatewayPort` metadata from a trusted backup; otherwise back up and remove the sandbox, then re-onboard it. Do not guess or copy a binding from another sandbox because lifecycle commands use it to select the gateway. ## Onboarding ### Cgroup v2 errors during onboard -Older NemoClaw releases relied on a Docker cgroup workaround on Ubuntu 24.04, DGX Spark, and WSL2. -Current OpenShell releases handle that behavior themselves, so NemoClaw no longer requires a Spark-specific setup step. +Older NemoClaw releases relied on a Docker cgroup workaround on Ubuntu 24.04, DGX Spark, and WSL2. Current OpenShell releases handle that behavior themselves, so NemoClaw no longer requires a Spark-specific setup step. If onboarding reports that Docker is missing or unreachable, fix Docker first and retry onboarding: @@ -559,14 +467,11 @@ If onboarding reports that Docker is missing or unreachable, fix Docker first an $$nemoclaw onboard ``` -Podman is not a tested runtime. -If onboarding or sandbox lifecycle fails, switch to a tested runtime (Docker Desktop, Colima, or Docker Engine) and rerun onboarding. +Podman is not a tested runtime. If onboarding or sandbox lifecycle fails, switch to a tested runtime (Docker Desktop, Colima, or Docker Engine) and rerun onboarding. ### Cluster fails with `overlayfs snapshotter cannot be enabled` on Docker 26+ -Docker Engine 26 and later default fresh installations to the [containerd image store](https://docs.docker.com/engine/storage/containerd/), which exposes its layers via the `overlayfs` snapshotter rather than the legacy `overlay2` graph driver. -The k3s server inside the OpenShell cluster image needs to mount its own overlay filesystem on top, and the kernel rejects nesting two non-trivial overlay mounts. -The cluster container then loops with: +Docker Engine 26 and later default fresh installations to the [containerd image store](https://docs.docker.com/engine/storage/containerd/), which exposes its layers via the `overlayfs` snapshotter rather than the legacy `overlay2` graph driver. The k3s server inside the OpenShell cluster image needs to mount its own overlay filesystem on top, and the kernel rejects nesting two non-trivial overlay mounts. The cluster container then loops with: ```text "overlayfs" snapshotter cannot be enabled for "/var/lib/rancher/k3s/agent/containerd", @@ -574,24 +479,16 @@ try using "fuse-overlayfs" or "native": failed to mount overlay: ... err: invalid argument ``` -This is a Docker default-driver change, not a NemoClaw or OpenShell regression. -The same hardware uses the legacy `overlay2` driver and is unaffected when it runs Docker 25 or earlier, or any Docker version with the containerd image store disabled. +This is a Docker default-driver change, not a NemoClaw or OpenShell regression. The same hardware uses the legacy `overlay2` driver and is unaffected when it runs Docker 25 or earlier, or any Docker version with the containerd image store disabled. -NemoClaw detects the Docker 26+ containerd-snapshotter overlayfs configuration during onboarding and transparently builds a small drop-in replacement for the cluster image on the local Docker engine. -The patched image installs `fuse-overlayfs` and selects it as the k3s snapshotter, bypassing the kernel-level nested-overlay limitation. -No host configuration changes, sudo, or Docker restart required. +NemoClaw detects the Docker 26+ containerd-snapshotter overlayfs configuration during onboarding and transparently builds a small drop-in replacement for the cluster image on the local Docker engine. The patched image installs `fuse-overlayfs` and selects it as the k3s snapshotter, bypassing the kernel-level nested-overlay limitation. No host configuration changes, sudo, or Docker restart required. -The auto-fix runs once per OpenShell version on the affected host. -Subsequent onboarding runs reuse the cached patched image. -Hosts without the conflict (`Driver: overlay2` in `docker info`, macOS Docker Desktop, or Linux installations that disable the containerd image store) see no change in behavior. +The auto-fix runs once per OpenShell version on the affected host. Subsequent onboarding runs reuse the cached patched image. Hosts without the conflict (`Driver: overlay2` in `docker info`, macOS Docker Desktop, or Linux installations that disable the containerd image store) see no change in behavior. Override knobs: -- `NEMOCLAW_DISABLE_OVERLAY_FIX=1`: skip the auto-fix and run against the unmodified upstream cluster image. - Useful for diagnosis or when you have already applied the manual workaround below. -- `NEMOCLAW_OVERLAY_SNAPSHOTTER=native`: build the patched image with k3s's `native` snapshotter instead of `fuse-overlayfs`. - The `native` snapshotter copies image layers instead of overlaying them, so it uses more disk but does not depend on FUSE. - Default is `fuse-overlayfs`. +- `NEMOCLAW_DISABLE_OVERLAY_FIX=1`: skip the auto-fix and run against the unmodified upstream cluster image. Useful for diagnosis or when you have already applied the manual workaround below. +- `NEMOCLAW_OVERLAY_SNAPSHOTTER=native`: build the patched image with k3s's `native` snapshotter instead of `fuse-overlayfs`. The `native` snapshotter copies image layers instead of overlaying them, so it uses more disk but does not depend on FUSE. Default is `fuse-overlayfs`. If you prefer to disable the new Docker storage driver instead of running the patched image, edit `/etc/docker/daemon.json`: @@ -602,15 +499,11 @@ If you prefer to disable the new Docker storage driver instead of running the pa } ``` -Then restart Docker (`sudo systemctl restart docker`) and re-run `$$nemoclaw onboard`. -This restores the legacy `overlay2` driver host-wide, which kills any other running containers. -Prefer the auto-fix unless you need the change for unrelated reasons. -Switching storage drivers also rebuilds the entire local image graph: previously-pulled images become unusable and Docker re-pulls them on first reference, so expect a cold cache and additional disk usage right after the restart. +Then restart Docker (`sudo systemctl restart docker`) and re-run `$$nemoclaw onboard`. This restores the legacy `overlay2` driver host-wide, which kills any other running containers. Prefer the auto-fix unless you need the change for unrelated reasons. Switching storage drivers also rebuilds the entire local image graph: previously-pulled images become unusable and Docker re-pulls them on first reference, so expect a cold cache and additional disk usage right after the restart. ### OpenShell version above maximum -Each NemoClaw release validates against a range of tested OpenShell versions. -If the installed OpenShell version exceeds the configured maximum, `$$nemoclaw onboard` exits with an error: +Each NemoClaw release validates against a range of tested OpenShell versions. If the installed OpenShell version exceeds the configured maximum, `$$nemoclaw onboard` exits with an error: ```text ✗ openshell is above the maximum supported by this NemoClaw release. @@ -619,19 +512,16 @@ If the installed OpenShell version exceeds the configured maximum, `$$nemoclaw o Upgrade NemoClaw to a version that supports your OpenShell release, or install a supported OpenShell version from the [OpenShell releases page](https://github.com/NVIDIA/OpenShell/releases). -For fresh installs, NemoClaw passes the blueprint range to `install-openshell.sh` and resolves a compatible published OpenShell release before downloading. -If GitHub release metadata is unavailable, the script uses its bundled fallback pin and the post-install gate still enforces the configured range. +For fresh installs, NemoClaw passes the blueprint range to `install-openshell.sh` and resolves a compatible published OpenShell release before downloading. If GitHub release metadata is unavailable, the script uses its bundled fallback pin and the post-install gate still enforces the configured range. ### Installer Reports an OpenShell Gateway Version Mismatch -On Linux, an existing OpenShell package can provide a systemd user service that starts a different gateway version from the user-local version that NemoClaw installs. -The installer stops before onboarding instead of using the two versions together. -The error reports both gateway versions and binary paths. +On Linux, an existing OpenShell package can provide a systemd user service that starts a different gateway version from the user-local version that NemoClaw installs. The installer stops before onboarding instead of using the two versions together. The error reports both gateway versions and binary paths. -Do not remove the existing OpenShell package if its gateway manages resources outside NemoClaw. -Package removal can stop that gateway. -Align the package with the version in the installer error, or plan the migration of those resources first. + Do not remove the existing OpenShell package if its gateway manages resources outside NemoClaw. + Package removal can stop that gateway. Align the package with the version in the installer error, + or plan the migration of those resources first. If you no longer need the APT-installed OpenShell package, remove it and rerun the installer: @@ -657,67 +547,45 @@ migration N was previously applied has been modified ``` -The first error means the installed OpenShell migration set does not contain migration N. -The second error means that migration set defines migration N with different contents. -NemoClaw identifies `/openshell.db` as incompatible with the installed OpenShell migration set for both errors. -This failure can happen after an OpenShell downgrade. -Installing a NemoClaw release that is older than the installed one performs that downgrade, because each release pins one OpenShell version and the installer reinstalls OpenShell at the pin. -You reach that state in one of three ways: +The first error means the installed OpenShell migration set does not contain migration N. The second error means that migration set defines migration N with different contents. NemoClaw identifies `/openshell.db` as incompatible with the installed OpenShell migration set for both errors. This failure can happen after an OpenShell downgrade. Installing a NemoClaw release that is older than the installed one performs that downgrade, because each release pins one OpenShell version and the installer reinstalls OpenShell at the pin. You reach that state in one of three ways: - You select an older release with `NEMOCLAW_INSTALL_TAG` or `NEMOCLAW_INSTALL_REF`. - The default `lkg` release is older than the NemoClaw release already on the host. - You install an older OpenShell yourself. -The diagnosis always prints the database path. -When an unused archive path is available, it also prints that archive path beside the selected state directory and the profile-specific onboarding command. -When no unused archive path is available, it asks you to keep the gateway stopped and inspect the state directory instead. +The diagnosis always prints the database path. When an unused archive path is available, it also prints that archive path beside the selected state directory and the profile-specific onboarding command. When no unused archive path is available, it asks you to keep the gateway stopped and inspect the state directory instead. -The selected state directory contains the gateway database, mutual TLS private keys, JSON Web Token signing material, and every sandbox and provider registration on the selected gateway. -Moving it makes those registrations and credentials unavailable to the fresh gateway. -Other sandboxes on the selected gateway can require re-onboarding and credential entry. + The selected state directory contains the gateway database, mutual TLS private keys, JSON Web + Token signing material, and every sandbox and provider registration on the selected gateway. + Moving it makes those registrations and credentials unavailable to the fresh gateway. Other + sandboxes on the selected gateway can require re-onboarding and credential entry. -When a service manager owns the gateway, the printed recovery stops it in the same command chain that moves the state directory. -NemoClaw cannot establish in advance that the directory stays free, because the managed service restarts the gateway on failure and a replacement can start at any point before the move. -Running the stop inside the chain removes that gap. +When a service manager owns the gateway, the printed recovery stops it in the same command chain that moves the state directory. NemoClaw cannot establish in advance that the directory stays free, because the managed service restarts the gateway on failure and a replacement can start at any point before the move. Running the stop inside the chain removes that gap. -NemoClaw resolves the owning service before it prints, so the stop names the unit that runs on this host: the upstream OpenShell package unit, the NemoClaw user service, or the Homebrew formula. -When no service manager owns the gateway, NemoClaw runs it standalone. -Before it offers the state move in that case, the gateway runtime checks the recorded process and scans current gateway process identities for the runtime namespace tied to the selected state directory. -It withholds the move unless that scan establishes that the standalone gateway state is unused. +NemoClaw resolves the owning service before it prints, so the stop names the unit that runs on this host: the upstream OpenShell package unit, the NemoClaw user service, or the Homebrew formula. When no service manager owns the gateway, NemoClaw runs it standalone. Before it offers the state move in that case, the gateway runtime checks the recorded process and scans current gateway process identities for the runtime namespace tied to the selected state directory. It withholds the move unless that scan establishes that the standalone gateway state is unused. When NemoClaw prints the state move, run the commands it prints: 1. Stop the owning gateway service, when the printed chain includes that step. -2. Create the printed `.incompatible` archive with owner-only access. - If that path exists, NemoClaw adds a numeric suffix instead of nesting or replacing an earlier archive. +2. Create the printed `.incompatible` archive with owner-only access. If that path exists, NemoClaw adds a numeric suffix instead of nesting or replacing an earlier archive. 3. Move the selected state directory into the archive as `gateway-state`. -4. Run the printed onboarding command only after the stop, the archive, and the move succeed. - Standard onboarding prints `$$nemoclaw onboard --resume`. - The portable experimental profile prints its required fresh-onboarding command for this gateway-state recovery. +4. Run the printed onboarding command only after the stop, the archive, and the move succeed. Standard onboarding prints `$$nemoclaw onboard --resume`. The portable experimental profile prints its required fresh-onboarding command for this gateway-state recovery. -The archive remains beside the selected state directory and retains the previous gateway records and credentials. -Keep it owner-only until onboarding completes and every required sandbox and provider registration is restored. -Delete the archive only after you no longer need its gateway records or credentials for recovery. +The archive remains beside the selected state directory and retains the previous gateway records and credentials. Keep it owner-only until onboarding completes and every required sandbox and provider registration is restored. Delete the archive only after you no longer need its gateway records or credentials for recovery. ### Installer Reports That the Systemd User Manager Is Unavailable -On Linux, an OpenShell package can install `/usr/lib/systemd/user/openshell-gateway.service` on a host without a reachable systemd user manager. -The service query can then return this diagnostic: +On Linux, an OpenShell package can install `/usr/lib/systemd/user/openshell-gateway.service` on a host without a reachable systemd user manager. The service query can then return this diagnostic: ```text Failed to connect to bus: No medium found ``` -The installer accepts only recognized user-manager-unavailable diagnostics for the standalone gateway fallback. -It checks `.wants`, `.requires`, and `.upholds` links in the standard systemd user unit paths. -The installer keeps the standalone lifecycle only when neither gateway service has an activation path that can later claim port `8080`. -The installer also stops when `SYSTEMD_UNIT_PATH` overrides the standard paths. -The installer does not parse, modify, or remove a package or foreign unit to make this decision. +The installer accepts only recognized user-manager-unavailable diagnostics for the standalone gateway fallback. It checks `.wants`, `.requires`, and `.upholds` links in the standard systemd user unit paths. The installer keeps the standalone lifecycle only when neither gateway service has an activation path that can later claim port `8080`. The installer also stops when `SYSTEMD_UNIT_PATH` overrides the standard paths. The installer does not parse, modify, or remove a package or foreign unit to make this decision. -If an activation path exists, the installer stops because the service can start later and compete for port `8080`. -Restore the systemd user manager, then inspect both possible services: +If an activation path exists, the installer stops because the service can start later and compete for port `8080`. Restore the systemd user manager, then inspect both possible services: ```bash systemctl --user status openshell-gateway.service @@ -726,25 +594,17 @@ systemctl --user status nemoclaw-openshell-gateway.service systemctl --user is-enabled nemoclaw-openshell-gateway.service ``` -Resolve the competing service through its package or platform owner. -Do not delete an activation link or edit a unit file by hand. -Rerun the installer only after the owner confirms that no enabled user service can claim port `8080`. +Resolve the competing service through its package or platform owner. Do not delete an activation link or edit a unit file by hand. Rerun the installer only after the owner confirms that no enabled user service can claim port `8080`. -Unknown service query errors remain fatal. -The installer also stops for malformed effective metadata, an untrusted unit or executable path, an executable failure, or a gateway version mismatch. -Follow the reported condition instead of forcing the standalone fallback. +Unknown service query errors remain fatal. The installer also stops for malformed effective metadata, an untrusted unit or executable path, an executable failure, or a gateway version mismatch. Follow the reported condition instead of forcing the standalone fallback. ### Sandbox build fails during OpenClaw plugin install -During sandbox creation, the OpenClaw image setup can install managed plugins for selected features such as web search or diagnostics. -If the build reaches `openclaw plugins install` and the npm registry or ClawHub is blocked, NemoClaw classifies that narrow failure and prints a policy hint instead of only generic resume guidance. -Brave Search uses an external OpenClaw plugin and can reach this install path. -Tavily ships with the pinned OpenClaw runtime, so NemoClaw verifies the bundled extension instead of installing a separate Tavily package. +During sandbox creation, the OpenClaw image setup can install managed plugins for selected features such as web search or diagnostics. If the build reaches `openclaw plugins install` and the npm registry or ClawHub is blocked, NemoClaw classifies that narrow failure and prints a policy hint instead of only generic resume guidance. Brave Search uses an external OpenClaw plugin and can reach this install path. Tavily ships with the pinned OpenClaw runtime, so NemoClaw verifies the bundled extension instead of installing a separate Tavily package. -Check that the active policy and host network allow the npm registry and ClawHub endpoints needed by the plugin, or disable the feature that requested the plugin. -For example, if the plugin is for web search, disable that feature and resume onboarding: +Check that the active policy and host network allow the npm registry and ClawHub endpoints needed by the plugin, or disable the feature that requested the plugin. For example, if the plugin is for web search, disable that feature and resume onboarding: ```bash NEMOCLAW_WEB_SEARCH_PROVIDER=none $$nemoclaw onboard --resume @@ -762,12 +622,7 @@ $$nemoclaw onboard --resume ### Web search verification reports a warning or security error -When web search is enabled, onboarding checks the selected agent configuration and sends a real search request through the sandbox egress path. -Configuration and egress verification are best effort, so those failed checks print a warning and let onboarding finish. -The selected credential's live sandbox isolation check is required. -If NemoClaw confirms that the raw Brave or Tavily key is visible, or the sandbox does not return a valid isolation result, it reports a security error. -The CLI pauses onboarding and exits with a nonzero status. -Recreate that sandbox through the supported onboarding flow before using it: +When web search is enabled, onboarding checks the selected agent configuration and sends a real search request through the sandbox egress path. Configuration and egress verification are best effort, so those failed checks print a warning and let onboarding finish. The selected credential's live sandbox isolation check is required. If NemoClaw confirms that the raw Brave or Tavily key is visible, or the sandbox does not return a valid isolation result, it reports a security error. The CLI pauses onboarding and exits with a nonzero status. Recreate that sandbox through the supported onboarding flow before using it: ```bash $$nemoclaw onboard --recreate-sandbox @@ -780,8 +635,7 @@ $$nemoclaw credentials list $$nemoclaw policy list ``` -Look for `-brave-search` with the `brave` preset or `-tavily-search` with the `tavily` preset. -Do not replace an `openshell:resolve:env:` value in the sandbox configuration with a raw API key. +Look for `-brave-search` with the `brave` preset or `-tavily-search` with the `tavily` preset. Do not replace an `openshell:resolve:env:` value in the sandbox configuration with a raw API key. @@ -792,8 +646,7 @@ Confirm that OpenClaw reports the provider selected during onboarding. $$nemoclaw config get --key tools.web.search --format yaml ``` -The provider should be `brave` or `tavily` and `enabled` should be `true`. -If the provider is wrong, rerun onboarding with `NEMOCLAW_WEB_SEARCH_PROVIDER=brave` or `tavily` and the matching `BRAVE_API_KEY` or `TAVILY_API_KEY`. +The provider should be `brave` or `tavily` and `enabled` should be `true`. If the provider is wrong, rerun onboarding with `NEMOCLAW_WEB_SEARCH_PROVIDER=brave` or `tavily` and the matching `BRAVE_API_KEY` or `TAVILY_API_KEY`. @@ -804,31 +657,26 @@ Confirm that the generated Hermes configuration selects the Tavily backend. $$nemoclaw exec -- cat /sandbox/.hermes/config.yaml ``` -The output should include a `web` mapping with `backend: tavily`. -If it does not, rerun onboarding with `NEMOCLAW_WEB_SEARCH_PROVIDER=tavily` and `TAVILY_API_KEY`. +The output should include a `web` mapping with `backend: tavily`. If it does not, rerun onboarding with `NEMOCLAW_WEB_SEARCH_PROVIDER=tavily` and `TAVILY_API_KEY`. -Rerunning onboarding with a different provider recreates the sandbox because the provider configuration and credential attachment are build-time inputs. -NemoClaw validates the replacement key before it removes the existing sandbox, then backs up and restores the supported workspace state during recreation. -If the configuration is correct but the egress probe fails, keep the matching preset applied and inspect the blocked request with `openshell term` before widening any policy rule. +Rerunning onboarding with a different provider recreates the sandbox because the provider configuration and credential attachment are build-time inputs. NemoClaw validates the replacement key before it removes the existing sandbox, then backs up and restores the supported workspace state during recreation. If the configuration is correct but the egress probe fails, keep the matching preset applied and inspect the blocked request with `openshell term` before widening any policy rule. ### Sandbox containers cannot reach the gateway -On native Linux Docker-driver hosts, `$$nemoclaw onboard` verifies the route that sandbox containers use to reach the OpenShell gateway. -If a host firewall blocks that path, onboarding exits with output like: +On native Linux Docker-driver hosts, `$$nemoclaw onboard` verifies the route that sandbox containers use to reach the OpenShell gateway. If a host firewall blocks that path, onboarding exits with output like: ```text ✗ Sandbox containers cannot reach the gateway at host.openshell.internal:8080. A host firewall may be blocking traffic from the OpenShell Docker bridge. ``` -Apply the `ufw` command printed by onboarding, then rerun onboarding. -If the message does not include a subnet, derive it from the OpenShell Docker network: +Apply the `ufw` command printed by onboarding, then rerun onboarding. If the message does not include a subnet, derive it from the OpenShell Docker network: ```bash SUBNET=$(docker network inspect openshell-docker --format '{{(index .IPAM.Config 0).Subnet}}') @@ -836,38 +684,19 @@ sudo ufw allow from "$SUBNET" to any port 8080 proto tcp $$nemoclaw onboard ``` -This reachability check uses a disposable Docker probe and does not create or replace a sandbox. -If Docker GPU compatibility recreation fails later, follow [GPU routing or compatibility patch failed](#gpu-routing-or-compatibility-patch-failed). -That path can restore the pre-patch sandbox. -If its diagnostics report manual cleanup, use only the printed container command. -That command targets the failed replacement and preserves the restored sandbox. +This reachability check uses a disposable Docker probe and does not create or replace a sandbox. If Docker GPU compatibility recreation fails later, follow [GPU routing or compatibility patch failed](#gpu-routing-or-compatibility-patch-failed). That path can restore the pre-patch sandbox. If its diagnostics report manual cleanup, use only the printed exact-container command. That command targets the failed replacement and preserves the restored sandbox. ### Custom OpenClaw image creates without a gateway or dashboard -`$$nemoclaw onboard --from ` treats the supplied Dockerfile as the complete sandbox image rather than adding it on top of the stock managed runtime. -If deployment verification cannot reach the gateway, NemoClaw checks for `/tmp/gateway.log`, `/usr/local/bin/nemoclaw-start`, and `/sandbox/.openclaw/openclaw.json` in the custom sandbox. -When all three paths are absent, the CLI reports that the image lacks the NemoClaw-managed OpenClaw runtime and does not suggest repeated dashboard port-forward retries. -This failure commonly occurs when the custom Dockerfile starts from `ghcr.io/nvidia/nemoclaw/sandbox-base` alone because that image is an intermediate dependency image. +`$$nemoclaw onboard --from ` treats the supplied Dockerfile as the complete sandbox image rather than adding it on top of the stock managed runtime. If deployment verification cannot reach the gateway, NemoClaw checks for `/tmp/gateway.log`, `/usr/local/bin/nemoclaw-start`, and `/sandbox/.openclaw/openclaw.json` in the custom sandbox. When all three paths are absent, the CLI reports that the image lacks the NemoClaw-managed OpenClaw runtime and does not suggest repeated dashboard port-forward retries. This failure commonly occurs when the custom Dockerfile starts from `ghcr.io/nvidia/nemoclaw/sandbox-base` alone because that image is an intermediate dependency image. -Rebuild the custom image from the full stock Dockerfile and source context for the same NemoClaw release. -For the version-pinned plugin workflow, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). +Rebuild the custom image from the full stock Dockerfile and source context for the same NemoClaw release. For the version-pinned plugin workflow, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). If the sandbox is unreachable or the managed runtime paths are present, NemoClaw retains the existing generic gateway-log and host OpenShell-log guidance because the base-only failure is not proven. -A custom image without the managed runtime can fail while NemoClaw starts the sandbox container. -NemoClaw reports exit code 127 without assigning a cause unless captured logs contain the `env` error for missing `nemoclaw-start`. -When that error is present, the failure output identifies the missing managed startup command and gives the same rebuild guidance. -If NemoClaw saves pre-rollback diagnostics, the reported directory contains the captured container logs. -If rollback succeeds, NemoClaw restores and starts the pre-patch sandbox container. -It does not print a sandbox deletion command for the restored sandbox. -If the failed replacement container remains, NemoClaw prints a container-specific Docker cleanup command. -If NemoClaw cannot confirm whether the replacement remains but retains its validated ID, it prints the same target-safe command. -Without a validated ID, it reports cleanup as unknown and prints no deletion command. -If rollback fails, sandbox and container state can be uncertain. -If NemoClaw reports a diagnostics directory, inspect it. -Inspect the diagnostics before removing any container. +A custom image without the managed runtime can fail while NemoClaw starts the sandbox container. NemoClaw reports exit code 127 without assigning a cause unless captured logs contain the exact `env` error for missing `nemoclaw-start`. When that error is present, the failure output identifies the missing managed startup command and gives the same rebuild guidance. If NemoClaw saves pre-rollback diagnostics, the reported directory contains the captured container logs. If rollback succeeds, NemoClaw restores and starts the pre-patch sandbox container. It does not print a sandbox deletion command for the restored sandbox. If the failed replacement container remains, NemoClaw prints an exact-container Docker cleanup command. If NemoClaw cannot confirm whether the replacement remains but retains its validated exact ID, it prints the same target-safe command. Without a validated exact ID, it reports cleanup as unknown and prints no deletion command. If rollback fails, sandbox and container state can be uncertain. If NemoClaw reports a diagnostics directory, inspect it. Inspect the diagnostics before removing any container. @@ -875,8 +704,7 @@ Inspect the diagnostics before removing any container. ### `connect` exits because the gateway is down -`$$nemoclaw connect` checks the OpenShell gateway before it tries dashboard forwarding, SSH, or inference repair. -If the gateway is not reachable, the command exits early and prints recovery guidance. +`$$nemoclaw connect` checks the OpenShell gateway before it tries dashboard forwarding, SSH, or inference repair. If the gateway is not reachable, the command exits early and prints recovery guidance. Resume onboarding so NemoClaw recreates or reconnects the managed gateway, then retry: @@ -906,31 +734,24 @@ nemohermes recover ### Sandbox container reports `(unhealthy)` while the agent gateway process is still alive -The in-sandbox OpenClaw gateway can drop its HTTP listener while its process stays alive. -A restart-class configuration change makes the gateway restart itself in place, and if that restart fails the process parks with no listener (`/tmp/gateway.log` shows `gateway startup failed: ... Process will stay alive`). -Docker then marks the container `(unhealthy)` even though `pgrep` still finds the gateway. +The in-sandbox OpenClaw gateway can drop its HTTP listener while its process stays alive. A restart-class configuration change makes the gateway restart itself in place, and if that restart fails the process parks with no listener (`/tmp/gateway.log` shows `gateway startup failed: ... Process will stay alive`). Docker then marks the container `(unhealthy)` even though `pgrep` still finds the gateway. NemoClaw prevents restart-class configuration changes from causing this condition and recovers a gateway that stops serving: - The generated sandbox config pins `gateway.reload.mode` to `hot`, so configuration changes never make the gateway restart itself out from under the sandbox supervisor. - A serving watchdog inside the sandbox stops a gateway process that does not serve, and the supervisor relaunches it. -The watchdog treats the gateway as serving only while the local health endpoint answers `200` or `401`, the same response requirement the sandbox applies when it waits for the gateway at startup. -These probe outcomes count as not serving: +The watchdog treats the gateway as serving only while the local health endpoint answers `200` or `401`, the same response requirement the sandbox applies when it waits for the gateway at startup. These probe outcomes count as not serving: - The connection is refused. - The probe times out. - The connection is accepted and then closed without a response. - The health endpoint returns an HTTP status other than `200` or `401`. -The watchdog logs the cause of every not-serving probe. -Its recovery bound depends on whether the gateway has ever served: +The watchdog logs the cause of every not-serving probe. Its recovery bound depends on whether the gateway has ever served: -- After the gateway returns a serving response, the watchdog stops it on the fourth not-serving probe with no intervening serving response. - At the default 30-second interval, this takes roughly two minutes. -- Before the gateway has ever served, the watchdog uses a longer boot grace window because it cannot distinguish a slow boot from a gateway that cannot serve. - It stops the gateway on the 20th not-serving probe, roughly 10 minutes after launch at the default interval. - Set `NEMOCLAW_GATEWAY_WATCHDOG_BOOT_GRACE_PROBES` to change this bound. +- After the gateway returns a serving response, the watchdog stops it on the fourth not-serving probe with no intervening serving response. At the default 30-second interval, this takes roughly two minutes. +- Before the gateway has ever served, the watchdog uses a longer boot grace window because it cannot distinguish a slow boot from a gateway that cannot serve. It stops the gateway on the 20th not-serving probe, roughly 10 minutes after launch at the default interval. Set `NEMOCLAW_GATEWAY_WATCHDOG_BOOT_GRACE_PROBES` to change this bound. Each of these watchdog settings accepts a positive integer of up to nine digits: @@ -940,40 +761,27 @@ Each of these watchdog settings accepts a positive integer of up to nine digits: The watchdog rejects any other value, logs which setting it rejected, and falls back to that setting's default. -Look for `[gateway-watchdog]` lines in `$$nemoclaw logs`. -Before recovery reaches its bound, each not-serving probe line shows the count and bound. -Examples include `(2/4 since the last serving response)` and `(7/20 since launch, having never served)`. +Look for `[gateway-watchdog]` lines in `$$nemoclaw logs`. Before recovery reaches its bound, each not-serving probe line shows the count and bound. Examples include `(2/4 since the last serving response)` and `(7/20 since launch, having never served)`. -If the watchdog reports that this supervisor is no longer the gateway's parent, the process survived without the supervisor that relaunches it. -Recover the sandbox with `$$nemoclaw recover`. +If the watchdog reports that this supervisor is no longer the gateway's parent, the process survived without the supervisor that relaunches it. Recover the sandbox with `$$nemoclaw recover`. -If the watchdog reports `health probe inconclusive`, the probe itself could not run inside the sandbox. -An inconclusive probe leaves the gateway untouched and preserves the current not-serving count, whether the watchdog is counting since launch or since the last serving response. -If `curl` is missing, the watchdog reports this diagnostic: +If the watchdog reports `health probe inconclusive`, the probe itself could not run inside the sandbox. An inconclusive probe leaves the gateway untouched and preserves the current not-serving count, whether the watchdog is counting since launch or since the last serving response. If `curl` is missing, the watchdog reports this exact diagnostic: ```text [gateway-watchdog] curl is unavailable; serving watchdog disabled (#7377) ``` -This diagnostic means that the watchdog is disabled. -For a custom image, add `curl` to the image first. -Then rebuild the sandbox: +This diagnostic means that the watchdog is disabled. For a custom image, add `curl` to the image first. Then rebuild the sandbox: ```bash $$nemoclaw rebuild --yes ``` -When the gateway is not serving, `openclaw health` can report `gateway_transport_error`, often `1006 abnormal closure (no close frame)`, while the container stays `running` with no restarts. -An agent reply can come from the OpenClaw CLI's embedded in-process fallback instead of the gateway. -Therefore, an agent reply does not confirm that the gateway is serving. +When the gateway is not serving, `openclaw health` can report `gateway_transport_error`, often `1006 abnormal closure (no close frame)`, while the container stays `running` with no restarts. An agent reply can come from the OpenClaw CLI's embedded in-process fallback instead of the gateway. Therefore, an agent reply does not confirm that the gateway is serving. -The watchdog converts a gateway that cannot serve into a process exit that the sandbox supervisor already knows how to relaunch. -It does not correct the OpenClaw lifecycle condition that left the process running. -It stays necessary until an OpenClaw gateway that cannot serve exits on its own. +The watchdog converts a gateway that cannot serve into a process exit that the sandbox supervisor already knows how to relaunch. It does not correct the OpenClaw lifecycle condition that left the process running. It stays necessary until an OpenClaw gateway that cannot serve exits on its own. -Because of the `hot` pin, restart-class configuration changes made inside the sandbox log `config reload requires gateway restart; hot mode ignoring` and do not take effect until the gateway restarts. -For example, `openclaw plugins install` logs that message until the gateway restarts. -Apply them with a supervised restart: +Because of the `hot` pin, restart-class configuration changes made inside the sandbox log `config reload requires gateway restart; hot mode ignoring` and do not take effect until the gateway restarts. For example, `openclaw plugins install` logs that message until the gateway restarts. Apply them with a supervised restart: ```bash $$nemoclaw gateway restart @@ -989,72 +797,39 @@ $$nemoclaw rebuild --yes ### Invalid sandbox name -Sandbox names must contain 1 to 19 characters. -They must be lowercase, start with a letter, contain only letters, numbers, and single internal hyphens, and end with a letter or number. -Consecutive hyphens (`--`) are not allowed. -The CLI rejects names that do not match these rules. -It prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input, so passing `--name MyAssistant` reports `Try: myassistant` and you can rerun with the suggested slug. +Sandbox names must contain 1 to 19 characters. They must be lowercase, start with a letter, contain only letters, numbers, and single internal hyphens, and end with a letter or number. Consecutive hyphens (`--`) are not allowed. The CLI rejects names that do not match these rules. It prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input, so passing `--name MyAssistant` reports `Try: myassistant` and you can rerun with the suggested slug. -The CLI writes the rejected value as a quoted preview instead of raw input. -The preview reads at most the first 80 UTF-16 code units from the input and escapes each code unit outside printable ASCII as `\uXXXX`. -Escaping can make the preview longer than 80 output characters. -This prevents a rejected name from injecting control sequences into terminal or CI output. +The CLI writes the rejected value as a quoted preview instead of raw input. The preview reads at most the first 80 UTF-16 code units from the input and escapes each code unit outside printable ASCII as `\uXXXX`. Escaping can make the preview longer than 80 output characters. This prevents a rejected name from injecting control sequences into terminal or CI output. -Names that collide with global CLI commands are also rejected. -Reserved names include `onboard`, `list`, `deploy`, `setup`, `start`, `stop`, `status`, `debug`, `uninstall`, `credentials`, and `help`. -Using a reserved name would cause the CLI to route to the global command instead of the sandbox. +Names that collide with global CLI commands are also rejected. Reserved names include `onboard`, `list`, `deploy`, `setup`, `start`, `stop`, `status`, `debug`, `uninstall`, `credentials`, and `help`. Using a reserved name would cause the CLI to route to the global command instead of the sandbox. -If the name does not match these rules or is reserved, the wizard exits with an error. -Choose a name such as `my-assistant` or `dev1`. +If the name does not match these rules or is reserved, the wizard exits with an error. Choose a name such as `my-assistant` or `dev1`. ### Sandbox creation fails on DGX On DGX machines, sandbox creation can fail if the gateway's DNS has not finished propagating or if a stale port forward from a previous onboard run is still active. -Run `$$nemoclaw onboard` to retry. -The wizard cleans up stale port forwards and waits for gateway readiness automatically. +Run `$$nemoclaw onboard` to retry. The wizard cleans up stale port forwards and waits for gateway readiness automatically. ### GPU Setup Fails with a Placeholder GPU Name -On Windows, WSL, and native Linux ARM64 hosts, some systems report a placeholder display adapter name even when no NVIDIA GPU firmware is present. -This section also applies when preflight reports no GPU on an ARM64 Linux host whose `nvidia-smi` shows a non-placeholder GPU name. -NVIDIA NIM and GPU-backed sandbox setup require a real NVIDIA GPU. +On Windows, WSL, and native Linux ARM64 hosts, some systems report a placeholder display adapter name even when no NVIDIA GPU firmware is present. This section also applies when preflight reports no GPU on an ARM64 Linux host whose `nvidia-smi` shows a non-placeholder GPU name. NVIDIA NIM and GPU-backed sandbox setup require a real NVIDIA GPU. -When the primary memory-query probe reports exactly one placeholder-named GPU row on a native or Docker Desktop-backed WSL ARM64 Linux host without firmware-confirmed NVIDIA platform metadata, onboarding runs one bounded Docker CUDA workload. -When that probe reports a single non-placeholder NVIDIA GPU name on such a host and the NVIDIA kernel-driver interface (`/proc/driver/nvidia`) is absent, onboarding runs the same workload. -A WSL2 host never provides that interface because Windows paravirtualizes the GPU through `/dev/dxg`. -A non-placeholder name that does not identify an NVIDIA GPU or product family does not start the workload. -NemoClaw treats a recognized NVIDIA product model from `/sys/class/dmi/id/product_name` or `/sys/firmware/devicetree/base/model`, or a known Tegra device node, as authoritative platform identity. -Docker may pull the CUDA sample image from `nvcr.io` and keeps the image in the local cache after the container exits. -The workload uses this command: +When the primary memory-query probe reports exactly one placeholder-named GPU row on a native or Docker Desktop-backed WSL ARM64 Linux host without firmware-confirmed NVIDIA platform metadata, onboarding runs one bounded Docker CUDA workload. When that probe reports a single non-placeholder NVIDIA GPU name on such a host and the NVIDIA kernel-driver interface (`/proc/driver/nvidia`) is absent, onboarding runs the same workload. A WSL2 host never provides that interface because Windows paravirtualizes the GPU through `/dev/dxg`. A non-placeholder name that does not identify an NVIDIA GPU or product family does not start the workload. NemoClaw treats a recognized NVIDIA product model from `/sys/class/dmi/id/product_name` or `/sys/firmware/devicetree/base/model`, or a known Tegra device node, as authoritative platform identity. Docker may pull the CUDA sample image from `nvcr.io` and keeps the image in the local cache after the container exits. The workload uses this command: ```bash docker run --rm --gpus all nvcr.io/nvidia/k8s/cuda-sample@sha256:7c7540bdf1f942d4fb6db97069fd6c289471b54ac29e3c7fcdf914cf77af7d41 ``` -The run is bounded to 3 minutes. -Set `NEMOCLAW_WSL_GPU_PROOF_TIMEOUT_MS` to a positive millisecond value to change that bound. -Increase the value only when the image pull or GPU workload needs more than 3 minutes. -A passing workload lets onboarding treat the detected GPU as eligible for GPU passthrough during that run. -For Windows-on-Arm, this proof is a technical detection check and does not change the Unsupported product status or establish platform qualification. -Refer to [Platform Support and Launch Claims](platform-support#out-of-scope-and-not-supported) for the current support boundary. -A failed or timed-out workload leaves the GPU unproven and does not enable GPU passthrough. -The names-only unified-memory fallback does not run this workload and rejects denylisted names. -WSL hosts that are not Docker Desktop-backed do not run the workload and continue to report the GPU as unavailable. +The run is bounded to 3 minutes. Set `NEMOCLAW_WSL_GPU_PROOF_TIMEOUT_MS` to a positive millisecond value to change that bound. Increase the value only when the image pull or GPU workload needs more than 3 minutes. A passing workload lets onboarding treat the detected GPU as eligible for GPU passthrough during that run. For Windows-on-Arm, this proof is a technical detection check and does not change the Unsupported product status or establish platform qualification. Refer to [Platform Support and Launch Claims](platform-support#out-of-scope-and-not-supported) for the current support boundary. A failed or timed-out workload leaves the GPU unproven and does not enable GPU passthrough. The names-only unified-memory fallback does not run this workload and rejects denylisted names. WSL hosts that are not Docker Desktop-backed do not run the workload and continue to report the GPU as unavailable. -When GPU detection rejects the `nvidia-smi` report, preflight prints the failed check under the `Local NIM unavailable — no GPU detected` line, for example an absent `/proc/driver/nvidia` interface or a failed bounded CUDA proof. -If NemoClaw rejects the detected GPU name during preflight, select a CPU or remote inference provider, or move the setup to a host with a supported NVIDIA GPU and current drivers. +When GPU detection rejects the `nvidia-smi` report, preflight prints the failed check under the `Local NIM unavailable — no GPU detected` line, for example an absent `/proc/driver/nvidia` interface or a failed bounded CUDA proof. If NemoClaw rejects the detected GPU name during preflight, select a CPU or remote inference provider, or move the setup to a host with a supported NVIDIA GPU and current drivers. -Jetson/Tegra hosts support sandbox GPU passthrough through the compatibility route. -Onboarding detects those hosts separately and propagates eligible host group IDs for selected `/dev/nvmap`, `/dev/nvhost-*`, and `/dev/nvgpu/igpu0/*` nodes plus real `/dev/dri/renderD*` character devices. -If that path fails, follow the Jetson/Tegra compatibility guidance below instead of treating a missing `nvidia-smi` result as a placeholder adapter. +Jetson/Tegra hosts support sandbox GPU passthrough through the compatibility route. Onboarding detects those hosts separately and propagates eligible host group IDs for selected `/dev/nvmap`, `/dev/nvhost-*`, and `/dev/nvgpu/igpu0/*` nodes plus real `/dev/dri/renderD*` character devices. If that path fails, follow the Jetson/Tegra compatibility guidance below instead of treating a missing `nvidia-smi` result as a placeholder adapter. ### Colima socket not detected (macOS) -Newer Colima versions use the XDG base directory (`~/.config/colima/default/docker.sock`) instead of the legacy path (`~/.colima/default/docker.sock`). -Some installations expose a top-level Colima socket at `~/.colima/docker.sock`. -NemoClaw checks all three paths. -If neither is found, verify that Colima is running: +Newer Colima versions use the XDG base directory (`~/.config/colima/default/docker.sock`) instead of the legacy path (`~/.colima/default/docker.sock`). Some installations expose a top-level Colima socket at `~/.colima/docker.sock`. NemoClaw checks all three paths. If neither is found, verify that Colima is running: ```bash colima status @@ -1062,21 +837,16 @@ colima status ### Sandbox build is slow or hangs (under-provisioned container runtime) -Default Colima ships with 2 vCPU and 2 GiB of memory, which is not enough headroom for the BuildKit-driven sandbox image build. -On macOS Apple Silicon, the build can stall part-way through with no progress and no error, leaving the wizard waiting indefinitely. +Default Colima ships with 2 vCPU and 2 GiB of memory, which is not enough headroom for the BuildKit-driven sandbox image build. On macOS Apple Silicon, the build can stall part-way through with no progress and no error, leaving the wizard waiting indefinitely. -Preflight inspects `docker info` for `NCPU` and `MemTotal` and prints a warning when the runtime falls below 4 vCPU or 8 GiB. -In interactive onboarding, the warning prompt defaults to abort, so pressing Enter stops the run before the sandbox build reaches the likely stall point. -Type `y` only when you intentionally want to continue on the smaller runtime. -Non-interactive onboarding prints the warning and continues. -On Colima, raise the resources before re-running onboard: +Preflight inspects `docker info` for `NCPU` and `MemTotal` and prints a warning when the runtime falls below 4 vCPU or 8 GiB. In interactive onboarding, the warning prompt defaults to abort, so pressing Enter stops the run before the sandbox build reaches the likely stall point. Type `y` only when you intentionally want to continue on the smaller runtime. Non-interactive onboarding prints the warning and continues. On Colima, raise the resources before re-running onboard: ```bash colima stop colima start --cpu 6 --memory 12 --disk 100 ``` -On Docker Desktop, raise CPU and memory limits in *Settings → Resources*, then apply and restart. +On Docker Desktop, raise CPU and memory limits in _Settings → Resources_, then apply and restart. To silence the warning when the host is intentionally small, set `NEMOCLAW_IGNORE_RUNTIME_RESOURCES=1` before running `$$nemoclaw onboard`. @@ -1084,9 +854,7 @@ To silence the warning when the host is intentionally small, set `NEMOCLAW_IGNOR ### Managed Sandbox Image Build Requires Local BuildKit -On a local Docker-driver gateway, NemoClaw builds each generated OpenClaw or Hermes sandbox image with host-side BuildKit. -The generated Dockerfiles include BuildKit-only file-mode, per-step network, and mount controls. -NemoClaw stops before sandbox creation in these cases: +On a local Docker-driver gateway, NemoClaw builds each generated OpenClaw or Hermes sandbox image with host-side BuildKit. The generated Dockerfiles include BuildKit-only file-mode, per-step network, and mount controls. NemoClaw stops before sandbox creation in these cases: - The local build is disabled. - The staged build context fails trust validation. @@ -1103,40 +871,27 @@ docker info docker buildx version ``` -Repair Docker access or the Docker Buildx plugin when either Docker command fails. -Then rerun the original onboarding or rebuild command. -For a resumable onboarding session, run: +Repair Docker access or the Docker Buildx plugin when either Docker command fails. Then rerun the original onboarding or rebuild command. For a resumable onboarding session, run: ```bash $$nemoclaw onboard --resume ``` -A passing recovery completes the local BuildKit build before sandbox creation starts. -If NemoClaw rejects the staged build context trust boundary, do not change its permissions or move its Dockerfile. -Rerun the command so NemoClaw creates a new private staged context. -If the new context is also rejected, preserve the complete error and stop instead of forcing the gateway builder. +A passing recovery completes the local BuildKit build before sandbox creation starts. If NemoClaw rejects the staged build context trust boundary, do not change its permissions or move its Dockerfile. Rerun the command so NemoClaw creates a new private staged context. If the new context is also rejected, preserve the complete error and stop instead of forcing the gateway builder. -This requirement does not change user-supplied `--from` contexts, which continue to use the OpenShell gateway builder. -It also preserves the gateway fallback when a generated LangChain Deep Agents Code image does not complete its local prebuild. +This requirement does not change user-supplied `--from` contexts, which continue to use the OpenShell gateway builder. It also preserves the gateway fallback when a generated LangChain Deep Agents Code image does not complete its local prebuild. ### Re-onboard fails because port 18789 is held by SSH -After destroying a sandbox and gateway, the SSH port-forward process for the dashboard can be left running. -Re-running onboard then fails preflight with `Port 18789 is not available. -Blocked by: ssh`. +After destroying a sandbox and gateway, the SSH port-forward process for the dashboard can be left running. Re-running onboard then fails preflight with `Port 18789 is not available. Blocked by: ssh`. -Current NemoClaw detects this case and kills the orphaned SSH process automatically before retrying the port check. -If you see the error on an older release, identify the SSH process. -Use fresh listener output to confirm that your user owns the process and that it still listens on local port `18789`: +Current NemoClaw detects this case and kills the orphaned SSH process automatically before retrying the port check. If you see the error on an older release, identify the SSH process. Use fresh listener output to confirm that your user owns the process and that it still listens on local port `18789`: ```bash sudo lsof -i :18789 -sTCP:LISTEN -P -n ``` -Inspect the process separately with `ps -p -o user=,args=`. -Stop it only when the owner is your user, the command line is the stale SSH port forward for local port `18789`, and no active terminal or file-transfer session uses that process. -Repeat the listener check immediately before you signal only the PID from that fresh result. -Then re-run `$$nemoclaw onboard`. +Inspect the process separately with `ps -p -o user=,args=`. Stop it only when the owner is your user, the command line is the stale SSH port forward for local port `18789`, and no active terminal or file-transfer session uses that process. Repeat the listener check immediately before you signal only the PID from that fresh result. Then re-run `$$nemoclaw onboard`. @@ -1144,18 +899,9 @@ Then re-run `$$nemoclaw onboard`. ### Sandbox Keeps Using the Previous Messaging Credential -Rerunning `$$nemoclaw onboard --non-interactive` with a replacement `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, or `MSTEAMS_APP_PASSWORD` previously reported success while the sandbox kept using the old credential. -When you rerun onboarding, NemoClaw evaluates every active messaging credential binding and compares each supplied credential with its SHA-256 hash in the sandbox registry. -When you provide a replacement credential, NemoClaw runs the channel's configured checks before it backs up supported workspace and manifest-declared state, destroys the sandbox, recreates it, and restores the backup. -Files outside those state paths are not preserved. -If an available pre-recreation check fails, onboarding stops before it backs up supported workspace and manifest-declared state or destroys the existing sandbox. -Discord and Microsoft Teams require non-empty replacement input but cannot prove upstream credential validity before recreation, so send a real test message after onboarding and confirm that the recreated sandbox receives it and responds. -If the channel state changes during rotation, onboarding stops before it destroys the existing sandbox and asks you to retry with the updated state. -If you do not supply a credential, or the supplied credential matches the recorded hash, the credential check does not trigger recreation. -If you replace a credential for a channel that you stopped with `channels stop`, onboarding does not trigger recreation because that channel is inactive. +Rerunning `$$nemoclaw onboard --non-interactive` with a replacement `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, or `MSTEAMS_APP_PASSWORD` previously reported success while the sandbox kept using the old credential. When you rerun onboarding, NemoClaw evaluates every active messaging credential binding and compares each supplied credential with its SHA-256 hash in the sandbox registry. When you provide a replacement credential, NemoClaw runs the channel's configured checks before it backs up supported workspace and manifest-declared state, destroys the sandbox, recreates it, and restores the backup. Files outside those state paths are not preserved. If an available pre-recreation check fails, onboarding stops before it backs up supported workspace and manifest-declared state or destroys the existing sandbox. Discord and Microsoft Teams require non-empty replacement input but cannot prove upstream credential validity before recreation, so send a real test message after onboarding and confirm that the recreated sandbox receives it and responds. If the channel state changes during rotation, onboarding stops before it destroys the existing sandbox and asks you to retry with the updated state. If you do not supply a credential, or the supplied credential matches the recorded hash, the credential check does not trigger recreation. If you replace a credential for a channel that you stopped with `channels stop`, onboarding does not trigger recreation because that channel is inactive. -If you suspect a sandbox is still using a stale messaging credential, follow [Rotate a Messaging Credential](../security/credential-rotation#rotate-a-messaging-credential) to export the replacement without putting it in shell history. -Then rerun onboarding so the credential check runs: +If you suspect a sandbox is still using a stale messaging credential, follow [Rotate a Messaging Credential](../security/credential-rotation#rotate-a-messaging-credential) to export the replacement without putting it in shell history. Then rerun onboarding so the credential check runs: ```bash $$nemoclaw onboard --name \ @@ -1168,8 +914,7 @@ $$nemoclaw onboard --name \ On systems with 8 GB RAM or less and no swap configured, the sandbox image push can exhaust available memory and get killed by the Linux OOM killer (exit code 137). -NemoClaw automatically detects low memory during onboarding and prompts to create a 4 GB swap file. -If this automatic step fails or you are using a custom setup flow, create swap manually before running `$$nemoclaw onboard`: +NemoClaw automatically detects low memory during onboarding and prompts to create a 4 GB swap file. If this automatic step fails or you are using a custom setup flow, create swap manually before running `$$nemoclaw onboard`: ```bash sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none @@ -1182,17 +927,11 @@ $$nemoclaw onboard ### Onboarding Reports a Rejected or Unconfirmed Policy Update -Onboarding can submit several policy mutations in sequence when you deselect policy presets and select others. -It submits deselection mutations before selection mutations. -Each successful mutation updates the live OpenShell gateway policy and the sandbox registry before the next mutation starts. -If a later mutation fails, the earlier successful mutations remain applied and recorded. +Onboarding can submit several policy mutations in sequence when you deselect policy presets and select others. It submits deselection mutations before selection mutations. Each successful mutation updates and verifies the live OpenShell policy before the next mutation starts. If a later mutation fails, the earlier successful mutations remain in that live policy. -Each mutation uses a temporary `policy.yaml` file in a `nemoclaw-policy-*` directory. -When the submission finishes, NemoClaw removes that directory, whether or not the gateway accepted the mutation. -If cleanup fails, NemoClaw reports the directory that still holds the policy instead of reporting the submission result. +Each mutation uses a temporary `policy.yaml` file in a `nemoclaw-policy-*` directory. When the submission finishes, NemoClaw removes that directory, whether or not the gateway accepted the mutation. If cleanup fails, NemoClaw reports the directory that still holds the policy instead of reporting the submission result. -When NemoClaw reports this directory, do not retry the policy operation. -Use Bash to enter and validate the path from the error before you remove its contents: +When NemoClaw reports this directory, do not retry the policy operation. Use Bash to enter and validate the exact path from the error before you remove its contents: ```bash cleanup_retained_policy() { @@ -1314,24 +1053,15 @@ PY cleanup_retained_policy ``` -The procedure opens the validated directory without following a symbolic link and removes `policy.yaml` relative to that open directory. -It fails if the directory contains anything else. -It intentionally leaves the empty directory because deleting it later by pathname would reintroduce a directory-replacement race. -Continue only after the procedure reports that the retained policy material was removed. -Do not remove the empty directory by pathname. -If validation or cleanup fails, preserve the path and complete error message for support. -Do not use another removal command on that path. +The procedure opens the validated directory without following a symbolic link and removes `policy.yaml` relative to that open directory. It fails if the directory contains anything else. It intentionally leaves the empty directory because deleting it later by pathname would reintroduce a directory-replacement race. Continue only after the procedure reports that the retained policy material was removed. Do not remove the empty directory by pathname. If validation or cleanup fails, preserve the exact path and complete error message for support. Do not use another removal command on that path. -After temporary-directory cleanup, treat the gateway state as unknown because the cleanup error replaced the submission result. -Restore access to the OpenShell gateway, then read the sandbox policy: +After temporary-directory cleanup, treat the gateway state as unknown because the cleanup error replaced the submission result. Restore access to the OpenShell gateway, then read the sandbox policy: ```bash $$nemoclaw policy list ``` -If `policy list` reports `⚠ Could not query gateway — showing local state only.`, stop because the command did not read the gateway policy. -If it reports container-runtime recovery guidance, restore that runtime and run `policy list` again. -Do not resume or start fresh onboarding until `policy list` reports the live gateway policy. +If `policy list` reports that it could not query OpenShell, stop because no local policy state can substitute for the live document. If it reports container-runtime recovery guidance, restore that runtime and run `policy list` again. Do not resume or start fresh onboarding until `policy list` reports the live gateway policy. When NemoClaw reports a rejected or unconfirmed policy mutation, onboarding stops and: @@ -1348,52 +1078,28 @@ The gateway read the policy and refused it: OpenShell rejected the policy for sandbox 'my-assistant' (exit ): . The policy was not applied and re-applying it will be rejected again; change the preset selection instead. ``` -An OpenShell refusal means this mutation did not change the live policy. -Earlier successful mutations in the same onboarding step remain applied and recorded. -Use the quoted OpenShell diagnostic to identify what the gateway refused. -After you inspect `policy list`, follow [Previous onboarding session failed](#previous-onboarding-session-failed) to start fresh onboarding and choose a different preset selection. +An OpenShell refusal means this mutation did not change the live policy. Earlier successful mutations in the same onboarding step remain applied and recorded. Use the quoted OpenShell diagnostic to identify what the gateway refused. After you inspect `policy list`, follow [Previous onboarding session failed](#previous-onboarding-session-failed) to start fresh onboarding and choose a different preset selection. -NemoClaw could not confirm the result. -This covers a connection that ended before the result arrived, an unreachable gateway, an elapsed deadline, a rejected credential, and a refusal the gateway reported with a status NemoClaw does not recognize as final. -NemoClaw reports this whenever the gateway did not return an explicit refusal, because only an explicit refusal proves the policy was not applied: +NemoClaw could not confirm the result. This covers a connection that ended before the result arrived, an unreachable gateway, an elapsed deadline, a rejected credential, and a refusal the gateway reported with a status NemoClaw does not recognize as final. NemoClaw reports this whenever the gateway did not return an explicit refusal, because only an explicit refusal proves the policy was not applied: ```text Could not confirm the policy update for sandbox 'my-assistant': . The gateway may or may not have applied it; read the current policy back before retrying. ``` -The gateway state is unknown, so read the sandbox policy with `policy list` before you retry. -`policy list` compares the sandbox registry with the live gateway policy and flags a preset that is applied in one place but not the other. -The unconfirmed mutation does not update the sandbox registry. - -The same connection problem that made the result unconfirmed can also stop `policy list` from reaching the gateway. -When that happens, `policy list` prints `⚠ Could not query gateway — showing local state only.` and still exits 0. -If the container runtime is down, it prints that runtime's recovery guidance instead. -Stop while `policy list` reports local state only because that output does not show whether the gateway applied the mutation. -Restore gateway access and run `policy list` again before you resume or start fresh onboarding. +The gateway state is unknown, so read the sandbox policy with `policy list` before you retry. `policy list` derives applied state from OpenShell and has no local policy record to reconcile. -After `policy list` reads the live gateway policy, follow the result below. -The [failed-session recovery steps](#previous-onboarding-session-failed) provide the resume and fresh onboarding commands. +The same connection problem that made the result unconfirmed can also stop `policy list` from reaching the gateway. When that happens, `policy list` reports that OpenShell is unavailable and does not claim a local policy fallback. If the container runtime is down, it prints that runtime's recovery guidance instead. Stop while `policy list` cannot read OpenShell because the command cannot show whether the gateway applied the mutation. Restore gateway access and run `policy list` again before you resume or start fresh onboarding. -- If an affected preset reports `active on gateway, missing from local state`, resume the failed session. - The gateway completed the addition, and resume can record the applied preset locally without submitting that policy change again. -- If an affected preset reports `recorded locally, not active on gateway`, do not resume or start fresh onboarding. - The gateway may have completed the removal while the sandbox registry retained the preset. - Preserve the original unconfirmed error and the complete `policy list` output for support. -- If `policy list` reports no disagreement for the unconfirmed mutation, compare the live preset set with the selection in the failed session. - Resume only if the live policy still requires the unconfirmed addition or removal to match that selection. - Otherwise, start fresh onboarding and select the preset set that `policy list` reports as active. +After `policy list` reads the live OpenShell policy, compare that one authoritative result with the change you requested. If the requested addition or removal is already present, do not submit it again. If it is absent, retry the convenience command after OpenShell connectivity is stable. The [failed-session recovery steps](#previous-onboarding-session-failed) provide the resume and fresh onboarding commands for non-policy onboarding state. ### Previous onboarding session failed If a previous `$$nemoclaw onboard` attempt fails partway through (for example, a provider or inference-setup step reporting an error), NemoClaw records the failure in `~/.nemoclaw/onboard-session.json`. -When you re-run the installer, it detects the failed session and does not silently retry it. -Silent retry would loop on the same failure if your original choice, such as an unreachable provider, was the cause. +When you re-run the installer, it detects the failed session and does not silently retry it. Silent retry would loop on the same failure if your original choice, such as an unreachable provider, was the cause. -- In an interactive terminal, the installer prompts whether to resume the failed session or start fresh. - Press `R` (or Enter) to retry the same session, or `f` to discard it and make fresh choices. -- In non-interactive mode (piped `curl | bash` with `NEMOCLAW_NON_INTERACTIVE=1`, CI, scripts), the installer refuses and exits with a non-zero status so a scripted re-run cannot loop. - You must opt in to one of two paths explicitly: +- In an interactive terminal, the installer prompts whether to resume the failed session or start fresh. Press `R` (or Enter) to retry the same session, or `f` to discard it and make fresh choices. +- In non-interactive mode (piped `curl | bash` with `NEMOCLAW_NON_INTERACTIVE=1`, CI, scripts), the installer refuses and exits with a non-zero status so a scripted re-run cannot loop. You must opt in to one of two paths explicitly: Start over with new choices to discard the recorded session and provider/model selection. @@ -1421,8 +1127,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepage -Or use environment variables instead. -Set them on the `bash` side of the pipe because only the right-hand process inherits them. +Or use environment variables instead. Set them on the `bash` side of the pipe because only the right-hand process inherits them. @@ -1456,22 +1161,15 @@ This is only useful if the original failure was transient, for example a network $$nemoclaw onboard --resume ``` -For a checkpoint schema 4 portable session, the plain command restores the portable profile from the checkpoint. -You can also state the matching profile explicitly: +For a checkpoint schema 4 portable session, the plain command restores the portable profile from the checkpoint. You can also state the matching profile explicitly: ```bash $$nemoclaw onboard --experimental-profile portable --resume ``` -Portable resume does not trust ambient Docker or Podman runtime selectors. -It derives and verifies the recorded current-user rootless Podman authority before it continues onboarding. -If NemoClaw reports unsafe ownership, type, or mode, correct that filesystem condition and retry. -Portable onboarding always uses the `.config` directory beneath the canonical home reported by the operating system; changing `HOME` or `XDG_CONFIG_HOME` does not select another location. -For a recorded alternate configuration root or other user ID, home, runtime root, endpoint, runtime kind, or ownership drift, do not edit the checkpoint; run fresh onboarding. +Portable resume does not trust ambient Docker or Podman runtime selectors. It derives and verifies the recorded current-user rootless Podman authority before it continues onboarding. If NemoClaw reports unsafe ownership, type, or mode, correct that filesystem condition and retry. Portable onboarding always uses the `.config` directory beneath the canonical home reported by the operating system; changing `HOME` or `XDG_CONFIG_HOME` does not select another location. For a recorded alternate configuration root or other user ID, home, runtime root, endpoint, runtime kind, or ownership drift, do not edit the checkpoint; run fresh onboarding. -If NemoClaw reports that an active checkpoint uses schema 1, 2, or 3, the older checkpoint did not record enough profile and runtime authority for resume. -NemoClaw preserves the session and exits before portable configuration, socket activation, or resource changes. -Discard that active session and start fresh onboarding: +If NemoClaw reports that an active checkpoint uses schema 1, 2, or 3, the older checkpoint did not record enough profile and runtime authority for resume. NemoClaw preserves the session and exits before portable configuration, socket activation, or resource changes. Discard that active session and start fresh onboarding: ```bash $$nemoclaw onboard --fresh @@ -1485,9 +1183,7 @@ $$nemoclaw onboard --experimental-profile portable --fresh -OpenClaw resume does not repeat completed non-secret sandbox, web search, messaging, or resource choices. -Resume also reuses registered web search and messaging credentials when the same onboarding session recorded their successful OpenShell registration and OpenShell still reports the recorded name and type and the same credential-key set. -If the session lacks that registration receipt, the provider is missing, or its binding does not match, interactive resume requests the credential again; non-interactive resume preserves the completed choice, reports the required environment variable, and exits so you can export it before retrying `$$nemoclaw onboard --resume`. +OpenClaw resume does not repeat completed non-secret sandbox, web search, messaging, or resource choices. Resume also reuses registered web search and messaging credentials when the same onboarding session recorded their successful OpenShell registration and OpenShell still reports the exact expected name, type, and credential keys. If the session lacks that registration receipt, the provider is missing, or its binding does not match, interactive resume requests the credential again; non-interactive resume preserves the completed choice, reports the required environment variable, and exits so you can export it before retrying `$$nemoclaw onboard --resume`. @@ -1499,16 +1195,14 @@ rm ~/.nemoclaw/onboard-session.json ### Kubernetes namespace not ready -If onboarding fails with `Kubernetes namespace not ready`, a previous failed or interrupted setup may have left stale OpenShell or NemoClaw state behind. -Clean up the failed installation before re-running the installer: +If onboarding fails with `Kubernetes namespace not ready`, a previous failed or interrupted setup may have left stale OpenShell or NemoClaw state behind. Clean up the failed installation before re-running the installer: ```bash $$nemoclaw uninstall --yes curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` -The normal uninstall path keeps user data under `~/.nemoclaw/`, including sandbox registry metadata, backups, and saved credentials unless you explicitly remove them. -If `$$nemoclaw uninstall` reports that the local uninstall script is missing, follow the CLI's security boundary: download the versioned NVIDIA/NemoClaw tag URL that it prints, inspect the script locally, run that local copy, and then retry the installer. +The normal uninstall path keeps user data under `~/.nemoclaw/`, including sandbox registry metadata, backups, and saved credentials unless you explicitly remove them. If `$$nemoclaw uninstall` reports that the local uninstall script is missing, follow the CLI's security boundary: download the versioned NVIDIA/NemoClaw tag URL that it prints, inspect the script locally, run that local copy, and then retry the installer. ```bash curl -fsSLo uninstall.sh @@ -1531,8 +1225,8 @@ NemoClaw uses two gateway layers for OpenClaw sandboxes: Start and recover them in this order: container runtime, OpenShell gateway, sandbox container, then the in-sandbox OpenClaw gateway. -Do not start the OpenClaw gateway by hand before the OpenShell gateway is healthy. -NemoClaw cannot select, inspect, or reconnect the sandbox until OpenShell can see the owning gateway. + Do not start the OpenClaw gateway by hand before the OpenShell gateway is healthy. NemoClaw cannot + select, inspect, or reconnect the sandbox until OpenShell can see the owning gateway. If the host rebooted or the OpenShell gateway is down, first run: @@ -1541,17 +1235,13 @@ If the host rebooted or the OpenShell gateway is down, first run: $$nemoclaw status ``` -The status command selects or starts the sandbox's recorded OpenShell gateway when possible, then checks whether OpenShell can still see the sandbox. -If the sandbox container is present but stopped on a Docker-driver host, status can recover the labeled container and then re-query OpenShell. -After the sandbox is visible again, use `$$nemoclaw recover` only for the in-sandbox OpenClaw gateway and host forwards. -Use `$$nemoclaw gateway restart` when you intentionally need the in-sandbox gateway to reload supported runtime configuration. +The status command selects or starts the sandbox's recorded OpenShell gateway when possible, then checks whether OpenShell can still see the sandbox. If the sandbox container is present but stopped on a Docker-driver host, status can recover the labeled container and then re-query OpenShell. After the sandbox is visible again, use `$$nemoclaw recover` only for the in-sandbox OpenClaw gateway and host forwards. Use `$$nemoclaw gateway restart` when you intentionally need the in-sandbox gateway to reload supported runtime configuration. ### Reconnect after a host reboot -After a host reboot, the container runtime, OpenShell gateway, and sandbox may not be running. -Follow these steps to reconnect. +After a host reboot, the container runtime, OpenShell gateway, and sandbox may not be running. Follow these steps to reconnect. 1. Start the container runtime. @@ -1560,25 +1250,17 @@ Follow these steps to reconnect. 1. Check the managed OpenShell gateway service. - If a custom-port gateway is NemoClaw-managed, skip this service check and continue with the NemoClaw recovery step using the same environment value. - Only the default port `8080` uses a NemoClaw-managed service. - If the gateway is externally supervised, inspect and restart it through the supervisor declared by `NEMOCLAW_GATEWAY_MANAGEMENT`, regardless of port. + If a custom-port gateway is NemoClaw-managed, skip this service check and continue with the NemoClaw recovery step using the same environment value. Only the default port `8080` uses a NemoClaw-managed service. If the gateway is externally supervised, inspect and restart it through the supervisor declared by `NEMOCLAW_GATEWAY_MANAGEMENT`, regardless of port. - On Apple Silicon macOS with Homebrew, let NemoClaw inspect and restart the official formula service. - NemoClaw runs each Homebrew operation inside the checksum-verified temporary trust boundary. - Continue to the NemoClaw recovery step below instead of running `brew services` directly. + On Apple Silicon macOS with Homebrew, let NemoClaw inspect and restart the official formula service. NemoClaw runs each Homebrew operation inside the checksum-verified temporary trust boundary. Continue to the NemoClaw recovery step below instead of running `brew services` directly. - NemoClaw verifies the staged formula checksum and temporarily trusts only `nvidia/openshell/openshell` around each Homebrew inspection, start, or stop operation. - If formula verification fails or Homebrew cannot grant or remove temporary trust, rerun the standard NemoClaw installer and then rerun onboarding: + NemoClaw verifies the staged formula checksum and temporarily trusts only `nvidia/openshell/openshell` around each Homebrew inspection, start, or stop operation. If formula verification fails or Homebrew cannot grant or remove temporary trust, rerun the standard NemoClaw installer and then rerun onboarding: ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` - The standalone gateway is selected only when Homebrew or both the staged formula and installed keg are absent. - When the formula or keg exists, Homebrew remains the lifecycle authority. - NemoClaw does not switch to the standalone gateway after a Homebrew inspection, start, or stop failure. - Follow the reported repair guidance instead of changing service ownership manually. - If the installed service fails inspection, startup, or its health check, NemoClaw prints this log command: + + The standalone gateway is selected only when Homebrew or both the staged formula and installed keg are absent. When the formula or keg exists, Homebrew remains the lifecycle authority. NemoClaw does not switch to the standalone gateway after a Homebrew inspection, start, or stop failure. Follow the reported repair guidance instead of changing service ownership manually. If the installed service fails inspection, startup, or its health check, NemoClaw prints this log command: ```bash tail -n 200 "$(brew --prefix)/var/log/openshell/openshell-gateway.out.log" "$(brew --prefix)/var/log/openshell/openshell-gateway.err.log" @@ -1612,12 +1294,7 @@ Follow these steps to reconnect. journalctl --user --unit nemoclaw-openshell-gateway --no-pager --lines=200 ``` - The tarball unit is under `$XDG_CONFIG_HOME/systemd/user`, or `~/.config/systemd/user` when `XDG_CONFIG_HOME` is not absolute. - It starts with your user session; NemoClaw does not enable lingering. - On Linux, NemoClaw attempts the standalone fallback when a managed service fails inspection, startup, or its health check. - The standalone gateway starts only after NemoClaw verifies exclusive ownership of the gateway port. - The fallback does not bypass managed-service trust validation or unsafe environment configuration. - These conditions remain hard failures: + The tarball unit is under `$XDG_CONFIG_HOME/systemd/user`, or `~/.config/systemd/user` when `XDG_CONFIG_HOME` is not absolute. It starts with your user session; NemoClaw does not enable lingering. On Linux, NemoClaw attempts the standalone fallback when a managed service fails inspection, startup, or its health check. The standalone gateway starts only after NemoClaw verifies exclusive ownership of the gateway port. The fallback does not bypass managed-service trust validation or unsafe environment configuration. These conditions remain hard failures: - Homebrew formula identity query, metadata, or official-tap validation errors - Foreign or symlinked systemd units, or an untrusted systemd executable identity @@ -1647,9 +1324,7 @@ Follow these steps to reconnect. $$nemoclaw onboard --resume ``` - Wait a few seconds, then re-check with `openshell sandbox list`. - On Docker-driver hosts, NemoClaw also looks for OpenShell-labeled sandbox containers when the gateway is healthy but reports the sandbox as missing. - It can start a stopped labeled container, or restore the latest GPU-backup sibling container name and start it. + Wait a few seconds, then re-check with `openshell sandbox list`. On Docker-driver hosts, NemoClaw also looks for OpenShell-labeled sandbox containers when the gateway is healthy but reports the sandbox as missing. It can start a stopped labeled container, or restore the latest GPU-backup sibling container name and start it. 1. Reconnect. @@ -1657,9 +1332,7 @@ Follow these steps to reconnect. $$nemoclaw connect ``` - The gateway usually rotates its SSH host keys across a reboot. - `connect` detects the resulting identity drift, prunes the stale `openshell-*` entries from `~/.ssh/known_hosts`, and retries automatically. - You do not need to edit `known_hosts` by hand or re-run `$$nemoclaw onboard` in this case. + The gateway usually rotates its SSH host keys across a reboot. `connect` detects the resulting identity drift, prunes the stale `openshell-*` entries from `~/.ssh/known_hosts`, and retries automatically. You do not need to edit `known_hosts` by hand or re-run `$$nemoclaw onboard` in this case. @@ -1671,26 +1344,19 @@ Follow these steps to reconnect. $$nemoclaw tunnel start ``` - OpenShell-managed channel messaging handles Telegram, Discord, Slack, WeChat, and WhatsApp at onboarding, not through a separate bridge process from `$$nemoclaw tunnel start`. - WeChat and WhatsApp are experimental. - To pause a single bridge without destroying the sandbox, use `$$nemoclaw channels stop `. + OpenShell-managed channel messaging handles Telegram, Discord, Slack, WeChat, and WhatsApp at onboarding, not through a separate bridge process from `$$nemoclaw tunnel start`. WeChat and WhatsApp are experimental. To pause a single bridge without destroying the sandbox, use `$$nemoclaw channels stop `. -If the sandbox remains missing after restarting the gateway, run `$$nemoclaw rebuild --yes` while the local registry entry still exists. -The rebuild path uses the recorded sandbox metadata and the snapshot flow to preserve supported workspace and agent state. -If the sandbox was intentionally deleted and you want a clean setup instead, run `$$nemoclaw destroy` to remove the stale local entry, then run `$$nemoclaw onboard`. -Create a snapshot first when the sandbox is reachable enough to back up state. -For details, refer to [Create and Restore Snapshots](../manage-sandboxes/state-and-backups/create-and-restore-snapshots). +If the sandbox remains missing after restarting the gateway, its authoritative OpenShell policy and live workspace are unavailable, so `rebuild --yes` cannot recreate it from NemoClaw registry metadata. Run `$$nemoclaw destroy --yes` to remove the stale local entry, then run `$$nemoclaw onboard`. The missing sandbox's state cannot be recovered unless you already have a separate snapshot; restore that snapshot explicitly after onboarding. For details, refer to [Create and Restore Snapshots](../manage-sandboxes/state-and-backups/create-and-restore-snapshots). + ### Gateway Port Stays Bound After Destroying the Last Sandbox -Destroying the final sandbox with `--cleanup-gateway` stops the packaged OpenShell gateway service before it reaps host gateway processes, so the gateway port is released. -The service is stopped, not disabled or removed, and the next onboarding run starts it again. -If the service cannot be stopped, `destroy` exits non-zero and prints the status command for the service. +Destroying the final sandbox with `--cleanup-gateway` stops the packaged OpenShell gateway service before it reaps host gateway processes, so the gateway port is released. The service is stopped, not disabled or removed, and the next onboarding run starts it again. If the service cannot be stopped, `destroy` exits non-zero and prints the status command for the service. On Apple Silicon macOS with Homebrew, rerun the standard NemoClaw installer to restore the pinned formula and temporary trust contract: @@ -1700,9 +1366,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash Then rerun `$$nemoclaw destroy --cleanup-gateway` instead of stopping the Homebrew service directly. -On Linux, stop the service yourself, then rerun `destroy`. -Use the service name that matches the install. -For package installs: +On Linux, stop the service yourself, then rerun `destroy`. Use the service name that matches the install. For package installs: ```bash systemctl --user stop openshell-gateway @@ -1718,16 +1382,9 @@ systemctl --user stop nemoclaw-openshell-gateway ### `gateway restart` or `recover` reports `privileged control unavailable` -Built-in OpenClaw and Hermes lifecycle commands require a running direct sandbox container that belongs to the named NemoClaw registry entry. -The host uses registry-scoped privileged direct-container control to send an authenticated request to the controller for the live topology. -It does not fall back to ordinary `openshell sandbox exec`, SSH, or a manual in-sandbox relaunch. +Built-in OpenClaw and Hermes lifecycle commands require a running direct sandbox container that belongs to the named NemoClaw registry entry. The host uses registry-scoped privileged direct-container control to send an authenticated request to the controller for the live topology. It does not fall back to ordinary `openshell sandbox exec`, SSH, or a manual in-sandbox relaunch. -Current built-in images support two direct-container shapes. -A direct root-entrypoint container uses the root PID 1 supervisor. -An OpenShell-managed container uses `/opt/openshell/bin/openshell-sandbox` as PID 1, exactly one nonroot `nemoclaw-start` supervisor, and the installed root-owned mode `0500` managed controller. -An arbitrary nonroot entrypoint that does not match that managed process shape fails with `privileged control unavailable`. -Kubernetes and other deployments without a matching direct container also fail closed with `privileged control unavailable`. -Run the lifecycle command from a supported direct-container deployment rather than trying to launch the gateway by hand. +Current built-in images support two direct-container shapes. A direct root-entrypoint container uses the root PID 1 supervisor. An OpenShell-managed container uses `/opt/openshell/bin/openshell-sandbox` as PID 1, exactly one nonroot `nemoclaw-start` supervisor, and the installed root-owned mode `0500` managed controller. An arbitrary nonroot entrypoint that does not match that managed process shape fails with `privileged control unavailable`. Kubernetes and other deployments without a matching direct container also fail closed with `privileged control unavailable`. Run the lifecycle command from a supported direct-container deployment rather than trying to launch the gateway by hand. On a direct-container deployment, first confirm that the sandbox is running: @@ -1742,31 +1399,7 @@ When `recover` repairs a stopped built-in gateway, NemoClaw repeats the recovery - Status `137` with blank stdout and stderr. - Status `1` with blank stdout and exactly one stderr line, `Error response from daemon: Container is restarting, wait until the container is running`. -For the Docker result, `` must be a 64-character lowercase hexadecimal ID that matches the selected registry-owned container. -Recovery makes at most 11 controller attempts in total. -It stops after 3 of those attempts return `SUPERVISOR_BUSY`. -The managed controller emits `SUPERVISOR_DISCOVERY_PENDING` only when an incomplete process-table scan during startup cannot yet prove either one exact supervisor or clean supervisor absence. -That result does not authorize container recreation or accept a supervisor identity; a later controller request must perform the full identity proof again. -Managed settle confirmation treats exact `SUPERVISOR_BUSY` and `SUPERVISOR_DISCOVERY_PENDING` results as inconclusive within its configured window. -Status `137` and the Docker restart result remain terminal during that confirmation. -The managed supervisor startup waiter accepts the four exact startup results, `SUPERVISOR_BUSY`, and the two container-transition results within its separate 11-attempt bound. -Unbound container IDs, reformatted Docker errors, status `137` with nonblank output, and other diagnostic results stop immediately. -NemoClaw treats `SUPERVISOR_UNAVAILABLE` as terminal because it can report unreadable or untrusted supervisor state, ambiguous discovery, or a process-identity change. -`SUPERVISOR_NOT_RUNNING` is a separate result that requires two zero-supervisor scans with a stable PID 1. -It enters the bounded startup retry first; only an exact missing-supervisor result that remains after the bound can authorize a container-identity-pinned recreation on a supported local Docker-driver sandbox with the legacy keepalive startup. -That recreation commits only after managed health and settle checks pass. -At the final commit handoff, NemoClaw asks OpenShell to stop the sandbox before it mutates either exact container. -After OpenShell acknowledges that stop, NemoClaw stops the exact replacement, removes the rollback container, and asks OpenShell to start the sandbox through its authoritative lifecycle path. -This preserves OpenShell's stopped/starting event fence while stale Docker removal snapshots settle; raw Docker stop/start events cannot strand the lifecycle row in `Error` or `Deleting`. -If the authoritative stop fails, NemoClaw leaves both containers intact. If the start or final `Ready`/exec/exact-container proof fails after rollback-container removal, NemoClaw reports that automatic rollback is unavailable. -To bypass that trusted recreation while troubleshooting, run `NEMOCLAW_DISABLE_SUPERVISOR_RELAUNCH=1 $$nemoclaw recover`; NemoClaw leaves the container unchanged and returns rebuild or re-onboard guidance. -If recovery stops after 3 `SUPERVISOR_BUSY` results, or if `gateway restart` reports `SUPERVISOR_BUSY`, wait for the active request to finish and retry the command. -If recovery exhausts the transition bound after status `137` or the Docker restart result, wait for the container to finish restarting and retry the command. -If the error mentions `SUPERVISOR_NOT_RUNNING` and trusted recreation could not proceed, `SUPERVISOR_REBUILD_REQUIRED`, a missing `nemoclaw-gateway-control` helper, or a missing managed controller, the sandbox image may predate the current lifecycle contract. -A `SUPERVISOR_UNAVAILABLE` result instead means the managed controller refused the current supervisor state rather than guessing which same-UID process is the gateway. -The current recovery action and any managed settle confirmation stop immediately. -If `recover` reports this result, follow its host-side `gateway restart` guidance. -If restart also reports `SUPERVISOR_UNAVAILABLE`, or the image is incompatible, rebuild the image: +For the Docker result, `` must be a 64-character lowercase hexadecimal ID that matches the selected registry-owned container. Recovery makes at most 11 controller attempts in total. It stops after 3 of those attempts return `SUPERVISOR_BUSY`. The managed controller emits `SUPERVISOR_DISCOVERY_PENDING` only when an incomplete process-table scan during startup cannot yet prove either one exact supervisor or clean supervisor absence. That result does not authorize container recreation or accept a supervisor identity; a later controller request must perform the full identity proof again. Managed settle confirmation treats exact `SUPERVISOR_BUSY` and `SUPERVISOR_DISCOVERY_PENDING` results as inconclusive within its configured window. Status `137` and the Docker restart result remain terminal during that confirmation. The managed supervisor startup waiter accepts the four exact startup results, `SUPERVISOR_BUSY`, and the two container-transition results within its separate 11-attempt bound. Unbound container IDs, reformatted Docker errors, status `137` with nonblank output, and other diagnostic results stop immediately. NemoClaw treats `SUPERVISOR_UNAVAILABLE` as terminal because it can report unreadable or untrusted supervisor state, ambiguous discovery, or a process-identity change. `SUPERVISOR_NOT_RUNNING` is a separate result that requires two zero-supervisor scans with a stable PID 1. It enters the bounded startup retry first; only an exact missing-supervisor result that remains after the bound can authorize a container-identity-pinned recreation on a supported local Docker-driver sandbox with the legacy keepalive startup. That recreation commits only after managed health and settle checks pass. At the final commit handoff, NemoClaw asks OpenShell to stop the sandbox before it mutates either exact container. After OpenShell acknowledges that stop, NemoClaw stops the exact replacement, removes the rollback container, and asks OpenShell to start the sandbox through its authoritative lifecycle path. This preserves OpenShell's stopped/starting event fence while stale Docker removal snapshots settle; raw Docker stop/start events cannot strand the lifecycle row in `Error` or `Deleting`. If the authoritative stop fails, NemoClaw leaves both containers intact. If the start or final `Ready`/exec/exact-container proof fails after rollback-container removal, NemoClaw reports that automatic rollback is unavailable. To bypass that trusted recreation while troubleshooting, run `NEMOCLAW_DISABLE_SUPERVISOR_RELAUNCH=1 $$nemoclaw recover`; NemoClaw leaves the container unchanged and returns rebuild or re-onboard guidance. If recovery stops after 3 `SUPERVISOR_BUSY` results, or if `gateway restart` reports `SUPERVISOR_BUSY`, wait for the active request to finish and retry the command. If recovery exhausts the transition bound after status `137` or the Docker restart result, wait for the container to finish restarting and retry the command. If the error mentions `SUPERVISOR_NOT_RUNNING` and trusted recreation could not proceed, `SUPERVISOR_REBUILD_REQUIRED`, a missing `nemoclaw-gateway-control` helper, or a missing managed controller, the sandbox image may predate the current lifecycle contract. An exact `SUPERVISOR_UNAVAILABLE` result instead means the managed controller refused the current supervisor state rather than guessing which same-UID process is the gateway. The current recovery action and any managed settle confirmation stop immediately. If `recover` reports this result, follow its host-side `gateway restart` guidance. If restart also reports `SUPERVISOR_UNAVAILABLE`, or the image is incompatible, rebuild the image: ```bash $$nemoclaw rebuild --yes @@ -1780,12 +1413,7 @@ For a custom image, update its Dockerfile to preserve the current NemoClaw entry ### Hermes config or shields reports a mutation already in progress -Hermes host config writes, shields transitions, and lifecycle seals use the same root-only mutation lock. -A host-side config write is bound to the SHA-256 digest of the matching read, installs fresh sealed config inodes, refreshes both config hashes, and restores the prior permissions before releasing that lock. -For shields changes, the lock remains held through recursive state-directory updates, config verification, and content-seal capture. -If a concurrent config or shields command reports `Hermes config mutation is already in progress`, do not remove `/run/nemoclaw/hermes-config-mutation.lock` manually. -Wait for the active config, shields, recovery, or restart command to finish, then retry. -If the command says shields are up, run `$$nemoclaw shields down` before `config set` or `inference set`. +Hermes host config writes, shields transitions, and lifecycle seals use the same root-only mutation lock. A host-side config write is bound to the SHA-256 digest of the matching read, installs fresh sealed config inodes, refreshes both config hashes, and restores the prior permissions before releasing that lock. For shields changes, the lock remains held through recursive state-directory updates, config verification, and content-seal capture. If a concurrent config or shields command reports `Hermes config mutation is already in progress`, do not remove `/run/nemoclaw/hermes-config-mutation.lock` manually. Wait for the active config, shields, recovery, or restart command to finish, then retry. If the command says shields are up, run `$$nemoclaw shields down` before `config set` or `inference set`. ### A Hermes command reports that runtime provider state mutation owns direct-container execution @@ -1795,9 +1423,7 @@ A current managed Hermes image on the Docker driver can report this retryable re Runtime provider state mutation owns direct-container execution for sandbox ''; retry after the provider fence is released. ``` -The refused command did not start a process inside the sandbox. -Do not bypass the fence with manual `docker exec`, OpenShell, or SSH commands, and do not delete `~/.nemoclaw/state/runtime-provider-lifecycle/`. -Wait for the active Shields or recovery command to finish, then reconcile the retained authority from the host: +The refused command did not start a process inside the sandbox. Do not bypass the fence with manual `docker exec`, OpenShell, or SSH commands, and do not delete `~/.nemoclaw/state/runtime-provider-lifecycle/`. Wait for the active Shields or recovery command to finish, then reconcile the retained authority from the host: ```bash $$nemoclaw shields status @@ -1807,21 +1433,13 @@ Use the status result as follows: - If status reports verified `UP`, `DOWN`, or mutable-default posture and exits with status `0`, retry the original command. - If status reports `runtime-provider recovery restored lockdown`, retry the intended `shields up` or `shields down` transition because provider verification rejected the persisted mutable posture. -- If status reports `DOWN (DRIFTED...)` or `NOT CONFIGURED (DRIFTED...)`, run `$$nemoclaw shields up`. - Open a new shields-down window only after `shields status` reports `UP` and exits with status `0`. +- If status reports `DOWN (DRIFTED...)` or `NOT CONFIGURED (DRIFTED...)`, run `$$nemoclaw shields up`. Open a new shields-down window only after `shields status` reports `UP` and exits with status `0`. -Hermes can also log `[SECURITY] Hermes startup held by an active runtime state mutation.` before it reads mutable state. -Do not kill or manually restart the held entrypoint. -Run `$$nemoclaw shields status` from the host so NemoClaw can recover the retained target and authenticate the startup release. -If recovery still fails, preserve the owner-only lifecycle ledger and the complete error for diagnosis instead of removing a marker or changing in-sandbox permissions. +Hermes can also log `[SECURITY] Hermes startup held by an active runtime state mutation.` before it reads mutable state. Do not kill or manually restart the held entrypoint. Run `$$nemoclaw shields status` from the host so NemoClaw can recover the exact retained target and authenticate the startup release. If recovery still fails, preserve the owner-only lifecycle ledger and the complete error for diagnosis instead of removing a marker or changing in-sandbox permissions. ### Hermes startup reports `HERMES_CONFIG_MUTATION_ORPHANED` -This refusal means a root-owned config or shields transaction stopped without a safely recoverable complete state. -NemoClaw intentionally does not guess whether a partially updated recursive state tree should be locked or mutable, and it does not treat a leftover lock file as permission to continue. -Do not delete `/run/nemoclaw/hermes-config-mutation.lock`, the restart state, or the persistent transaction marker manually. -The config root remains sealed from the sandbox identity, so an ordinary in-place `rebuild` cannot create a new trustworthy backup and will stop before deleting the sandbox. -If you already have a trusted host-side snapshot, record its selector, destroy the sealed sandbox, re-onboard the same sandbox name from trusted host configuration, and then restore that snapshot: +This refusal means a root-owned config or shields transaction stopped without a safely recoverable complete state. NemoClaw intentionally does not guess whether a partially updated recursive state tree should be locked or mutable, and it does not treat a leftover lock file as permission to continue. Do not delete `/run/nemoclaw/hermes-config-mutation.lock`, the restart state, or the persistent transaction marker manually. The config root remains sealed from the sandbox identity, so an ordinary in-place `rebuild` cannot create a new trustworthy backup and will stop before deleting the sandbox. If you already have a trusted host-side snapshot, record its selector, destroy the sealed sandbox, re-onboard the same sandbox name from trusted host configuration, and then restore that snapshot: ```bash $$nemoclaw snapshot list @@ -1830,24 +1448,15 @@ $$nemoclaw onboard --name --agent hermes $$nemoclaw snapshot restore ``` -Destroying the sealed sandbox permanently discards any state newer than the selected snapshot, so verify that the host-side snapshot exists before confirming destruction. -Without a trusted pre-incident snapshot, automatic state-preserving recovery is not available. -Recreate the sandbox from host-side onboarding configuration only if you accept losing the inaccessible in-sandbox state. +Destroying the sealed sandbox permanently discards any state newer than the selected snapshot, so verify that the host-side snapshot exists before confirming destruction. Without a trusted pre-incident snapshot, automatic state-preserving recovery is not available. Recreate the sandbox from host-side onboarding configuration only if you accept losing the inaccessible in-sandbox state. ### Hermes startup reports `HERMES_RESTART_SEAL_ORPHANED` -This refusal means a container recreation discarded the root-only restart metadata under `/run` while the persistent `/sandbox` tree still carries the frozen transaction marker, or a non-root entrypoint found a root transaction it cannot safely restore. -NemoClaw cannot safely infer the original ownership, modes, or inode flags from a partial seal, so startup fails closed even when `config.yaml` and `.env` still look readable. -Do not repair the paths with manual `chown`, `chmod`, or hash regeneration. -An ordinary `rebuild` cannot safely back up a sealed tree. -Restore a trusted snapshot into a new sandbox as shown above, or recreate from host-side onboarding configuration if no state-preserving recovery is required. +This refusal means a container recreation discarded the root-only restart metadata under `/run` while the persistent `/sandbox` tree still carries the frozen transaction marker, or a non-root entrypoint found a root transaction it cannot safely restore. NemoClaw cannot safely infer the original ownership, modes, or inode flags from a partial seal, so startup fails closed even when `config.yaml` and `.env` still look readable. Do not repair the paths with manual `chown`, `chmod`, or hash regeneration. An ordinary `rebuild` cannot safely back up a sealed tree. Restore a trusted snapshot into a new sandbox as shown above, or recreate from host-side onboarding configuration if no state-preserving recovery is required. ### Hermes startup reports `HERMES_LOCKED_PARENT_UNPROTECTED` -This refusal means Hermes config files look shields-locked but `/sandbox` is not `root:sandbox 1775`, so the sandbox identity could rename the entire `.hermes` lock root. -`shields up` cannot repair this after PID 1 has refused startup. -Do not change the parent ownership manually or accept the current bytes as a new seal. -Restore a trusted pre-incident snapshot into a new sandbox, or recreate the sandbox from host-side onboarding configuration. +This refusal means Hermes config files look shields-locked but `/sandbox` is not `root:sandbox 1775`, so the sandbox identity could rename the entire `.hermes` lock root. `shields up` cannot repair this after PID 1 has refused startup. Do not change the parent ownership manually or accept the current bytes as a new seal. Restore a trusted pre-incident snapshot into a new sandbox, or recreate the sandbox from host-side onboarding configuration. @@ -1855,10 +1464,7 @@ Restore a trusted pre-incident snapshot into a new sandbox, or recreate the sand ### OpenClaw startup reports `OPENCLAW_LOCKED_PARENT_UNPROTECTED` -This refusal means the OpenClaw config directory is root-owned but `/sandbox` is not `root:sandbox 1775`, so the sandbox identity could rename the entire `.openclaw` lock root. -PID 1 refuses startup before migration or config reads, and `shields up` cannot repair the parent after that refusal. -Do not change the parent ownership manually or accept the current bytes as a new seal. -If you have a trusted host-side snapshot from before the incident, record its selector, destroy the refused sandbox, re-onboard the same sandbox name from trusted host configuration, and restore that snapshot: +This refusal means the OpenClaw config directory is root-owned but `/sandbox` is not `root:sandbox 1775`, so the sandbox identity could rename the entire `.openclaw` lock root. PID 1 refuses startup before migration or config reads, and `shields up` cannot repair the parent after that refusal. Do not change the parent ownership manually or accept the current bytes as a new seal. If you have a trusted host-side snapshot from before the incident, record its selector, destroy the refused sandbox, re-onboard the same sandbox name from trusted host configuration, and restore that snapshot: ```bash $$nemoclaw snapshot list @@ -1867,8 +1473,7 @@ $$nemoclaw onboard --name --agent openclaw $$nemoclaw snapshot restore ``` -Destroying the refused sandbox permanently discards state newer than the selected snapshot, so confirm that the host-side snapshot exists before destruction. -Without a trusted pre-incident snapshot, recreate the sandbox from host-side onboarding configuration only if you accept losing the inaccessible in-sandbox state. +Destroying the refused sandbox permanently discards state newer than the selected snapshot, so confirm that the host-side snapshot exists before destruction. Without a trusted pre-incident snapshot, recreate the sandbox from host-side onboarding configuration only if you accept losing the inaccessible in-sandbox state. @@ -1882,8 +1487,7 @@ To upgrade the sandbox while preserving workspace state, run: $$nemoclaw rebuild ``` -The rebuild command backs up state, destroys the old sandbox, recreates it with the current image, and restores state. -Create a snapshot before rebuilding if you want an additional safety net: +The rebuild command backs up state, destroys the old sandbox, recreates it with the current image, and restores state. Create a snapshot before rebuilding if you want an additional safety net: ```bash $$nemoclaw snapshot create @@ -1892,24 +1496,19 @@ $$nemoclaw rebuild ### Snapshot Sanitization Requires a Verified Python Interpreter -The CLI operations listed below stop when NemoClaw cannot resolve a verified interpreter. -The error begins with this text: +The CLI operations listed below stop when NemoClaw cannot resolve a verified interpreter. The error begins with this text: ```text python3 is required for snapshot sanitization; install python3 and rerun ``` -NemoClaw removes credentials from copied state with an isolated `python3` helper. -The operation fails closed when that helper cannot run. -These operations can report the error: +NemoClaw removes credentials from copied state with an isolated `python3` helper. The operation fails closed when that helper cannot run. These operations can report the error: - `$$nemoclaw snapshot create` - `$$nemoclaw rebuild`, including rebuilds started by `$$nemoclaw upgrade-sandboxes` - `$$nemoclaw backup-all`, including the installer's pre-upgrade backup and an eligible stopped Docker-driver sandbox -A host that already has `python3` can still report this message. -NemoClaw does not search `PATH` for this credential-bearing helper. -NemoClaw accepts `python3` only at these locations: +A host that already has `python3` can still report this message. NemoClaw does not search `PATH` for this credential-bearing helper. NemoClaw accepts `python3` only at these locations: - `/usr/bin/python3` - `/usr/local/bin/python3` @@ -1917,26 +1516,22 @@ NemoClaw accepts `python3` only at these locations: - `/opt/local/bin/python3` - A `python3` executable beside the canonical Node.js executable -NemoClaw rejects a candidate that fails its ownership, permission, or executable checks. -For more information about the interpreter requirement, refer to [Prerequisites](../get-started/prerequisites). +NemoClaw rejects a candidate that fails its ownership, permission, or executable checks. For more information about the interpreter requirement, refer to [Prerequisites](../get-started/prerequisites). -If NemoClaw reports that it removed the incomplete snapshot, install or repair `python3` at an accepted location. -Then rerun the complete command. +If NemoClaw reports that it removed the incomplete snapshot, install or repair `python3` at an accepted location. Then rerun the complete command. If cleanup fails, treat the reported directory as retained until you confirm that it is absent. -A retained incomplete snapshot can contain unsanitized credentials. -The directory must remain owner-only. -You must not restore, copy, or share the directory. -You must remove the directory only by the path that NemoClaw reports. -You must confirm that the path no longer exists before you rerun the complete command. + A retained incomplete snapshot can contain unsanitized credentials. The directory must remain + owner-only. You must not restore, copy, or share the directory. You must remove the directory only + by the exact path that NemoClaw reports. You must confirm that the exact path no longer exists + before you rerun the complete command. ### Sandbox shows as stopped -When status reports `sandbox_container_stopped`, Docker still has a container for the sandbox, but the container is not running. -Use the lightest recovery path first instead of rebuilding immediately. +When status reports `sandbox_container_stopped`, Docker still has a container for the sandbox, but the container is not running. Use the lightest recovery path first instead of rebuilding immediately. 1. Confirm Docker can still see the labeled container. @@ -1948,24 +1543,21 @@ Use the lightest recovery path first instead of rebuilding immediately. - ```bash - $$nemoclaw recover - ``` +```bash +$$nemoclaw recover +``` - For a stopped, non-paused Docker-driver container, `recover` starts the existing container before it waits for OpenShell readiness. - It leaves a running or paused container unchanged. - If Docker cannot start the container, the command continues to the readiness check and reports the resulting failure. +For a stopped, non-paused Docker-driver container, `recover` starts the existing container before it waits for OpenShell readiness. It leaves a running or paused container unchanged. If Docker cannot start the container, the command continues to the readiness check and reports the resulting failure. - ```bash - $$nemoclaw status - ``` +```bash +$$nemoclaw status +``` - Deep Agents Code does not provide the `recover` command. - Use `$$nemoclaw start` to start its existing container. +Deep Agents Code does not provide the `recover` command. Use `$$nemoclaw start` to start its existing container. @@ -1975,39 +1567,31 @@ Use the lightest recovery path first instead of rebuilding immediately. $$nemoclaw status ``` - On Docker-driver hosts, status also attempts non-destructive recovery when OpenShell reports the sandbox as missing but Docker still has a stopped `openshell.ai/sandbox-name=` container or the latest GPU-backup sibling. - A successful recovery prints that the sandbox was recovered from Docker and then shows the refreshed OpenShell state. + On Docker-driver hosts, status also attempts non-destructive recovery when OpenShell reports the sandbox as missing but Docker still has a stopped `openshell.ai/sandbox-name=` container or the latest GPU-backup sibling. A successful recovery prints that the sandbox was recovered from Docker and then shows the refreshed OpenShell state. -1. Rebuild only if the sandbox cannot be restarted or status still cannot recover it while the local registry entry exists: +1. Rebuild only if the sandbox cannot be restarted but OpenShell still reports the live sandbox and its current policy: ```bash $$nemoclaw rebuild --yes ``` - Rebuild recreates the sandbox from recorded metadata and preserves supported workspace and agent state. - If the sandbox was intentionally deleted and you want a clean setup, run `$$nemoclaw destroy` to remove the stale local entry, then run `$$nemoclaw onboard`. + Rebuild captures the live sandbox policy and workspace before recreation. If the live sandbox is absent, use the clean-replacement sequence below instead. ### Sandbox is registered locally but missing from the gateway -After a gateway restart, host reboot, or manual OpenShell cleanup, NemoClaw may still have a local registry entry for a sandbox that the live gateway no longer lists. -`$$nemoclaw status` and `$$nemoclaw connect` preserve that local registry entry and print recovery guidance instead of deleting it automatically. -Run `$$nemoclaw rebuild --yes` when you want NemoClaw to recreate the sandbox from the recorded metadata, or run `$$nemoclaw destroy` when you intentionally want to remove the stale entry. +After a gateway restart, host reboot, or manual OpenShell cleanup, NemoClaw may still have a local registry entry for a sandbox that the live gateway no longer lists. `$$nemoclaw status` and `$$nemoclaw connect` preserve that local registry entry and print recovery guidance instead of deleting it automatically. First restore the gateway and retry status in case the sandbox returns. If it remains absent, run `$$nemoclaw destroy --yes` to remove the stale entry, then run `$$nemoclaw onboard`. Rebuild cannot recreate the missing sandbox because no authoritative OpenShell policy remains. State is recoverable only from a separate snapshot restored after onboarding. ### A command reports that the registry file is not valid JSON Registry operations that require complete sandbox records, such as `$$nemoclaw list` and `$$nemoclaw onboard`, stop with `Configuration file is present but is not valid JSON`, followed by the path to `sandboxes.json` and the recovery commands. -These operations stop instead of reading the file as an empty registry, so they cannot replace your sandbox records with empty state. -Optional messaging health checks omit registry-derived information when they cannot read the registry. -It does not rename, move, or rewrite the file. +These operations stop instead of reading the file as an empty registry, so they cannot replace your sandbox records with empty state. Optional messaging health checks omit registry-derived information when they cannot read the registry. It does not rename, move, or rewrite the file. Follow [Malformed Registry File](host-files-and-state#malformed-registry-file) to keep a copy and remove it. ### Status shows "not running" inside the sandbox -This is expected behavior. -When checking status inside an active sandbox, host-side sandbox state and inference configuration are not inspectable. -The status command detects the sandbox context and reports "active (inside sandbox)" instead. +This is expected behavior. When checking status inside an active sandbox, host-side sandbox state and inference configuration are not inspectable. The status command detects the sandbox context and reports "active (inside sandbox)" instead. Run `openshell sandbox list` on the host to check the underlying sandbox state. @@ -2017,23 +1601,13 @@ Run `openshell sandbox list` on the host to check the underlying sandbox state. ### Deep Agents Config Lock Failure Recovery -A `CRITICAL` Deep Agents config-lock diagnostic can report `fail-closed containment=`, `rollback failed`, or that the lock rollback could not restore the trusted posture. -A containment result identifies one of two confirmed postures or an incomplete containment attempt. -A `rollback failed` result or lock-rollback diagnostic does not confirm containment. -Both rollback diagnostics mean NemoClaw could not restore or confirm the original trusted posture. +A `CRITICAL` Deep Agents config-lock diagnostic can report `fail-closed containment=`, `rollback failed`, or that the lock rollback could not restore the trusted posture. A containment result identifies one of two confirmed postures or an incomplete containment attempt. A `rollback failed` result or lock-rollback diagnostic does not confirm containment. Both rollback diagnostics mean NemoClaw could not restore or confirm the original trusted posture. -- **Config-root posture** (`fail-closed containment=config-root`) means NemoClaw installed fresh `0444 root:root` config and hash inodes. - NemoClaw also confirmed `0500 root:root` on `/sandbox/.deepagents` and `1775 root:sandbox` on `/sandbox`. -- **Sandbox-parent posture** (`fail-closed containment=sandbox-parent`) means NemoClaw confirmed `0700 root:root` on `/sandbox`. - NemoClaw uses this posture when it cannot confirm the complete config-root posture. +- **Config-root posture** (`fail-closed containment=config-root`) means NemoClaw installed fresh `0444 root:root` config and hash inodes. NemoClaw also confirmed `0500 root:root` on `/sandbox/.deepagents` and `1775 root:sandbox` on `/sandbox`. +- **Sandbox-parent posture** (`fail-closed containment=sandbox-parent`) means NemoClaw confirmed `0700 root:root` on `/sandbox`. NemoClaw uses this posture when it cannot confirm the complete config-root posture. - `fail-closed containment=incomplete` means NemoClaw could not confirm either complete posture. -Preserve the complete `CRITICAL` diagnostic. -Do not retry `shields up`. -Do not run `chmod`, `chown`, or another repair inside the sandbox. -A confirmed containment posture removes the sandbox identity's access to the Deep Agents configuration. -An incomplete containment result, a `rollback failed` result, or a lock-rollback diagnostic does not establish a trustworthy boundary from which to accept the current bytes. -An ordinary `rebuild` cannot turn the current state into a trustworthy snapshot. +Preserve the complete `CRITICAL` diagnostic. Do not retry `shields up`. Do not run `chmod`, `chown`, or another repair inside the sandbox. A confirmed containment posture removes the sandbox identity's access to the Deep Agents configuration. An incomplete containment result, a `rollback failed` result, or a lock-rollback diagnostic does not establish a trustworthy boundary from which to accept the current bytes. An ordinary `rebuild` cannot turn the current state into a trustworthy snapshot. If you have a trusted host-side snapshot from before the failure, list the snapshots and record its selector: @@ -2042,8 +1616,8 @@ $$nemoclaw snapshot list ``` -Destroying the sandbox permanently discards state newer than the selected snapshot. -Confirm that the trusted host-side snapshot exists before you destroy the sandbox. + Destroying the sandbox permanently discards state newer than the selected snapshot. Confirm that + the trusted host-side snapshot exists before you destroy the sandbox. Destroy the sandbox, re-onboard the same name from trusted host configuration, and restore the snapshot: @@ -2059,8 +1633,8 @@ For snapshot contents and selector rules, refer to [Create and Restore Snapshots If no trusted snapshot exists and you do not need to preserve the current state, recreate the sandbox from host-side onboarding configuration. -This recreation permanently discards the current sandbox state. -Continue only if you accept that loss. + This recreation permanently discards the current sandbox state. Continue only if you accept that + loss. ```bash @@ -2075,13 +1649,11 @@ $$nemoclaw status $$nemoclaw shields status ``` -Continue only when `status` identifies the expected Deep Agents sandbox and `shields status` returns without a `CRITICAL` or corrupt-state diagnostic. -Then retry the original `shields up` operation. +Continue only when `status` identifies the expected Deep Agents sandbox and `shields status` returns without a `CRITICAL` or corrupt-state diagnostic. Then retry the original `shields up` operation. ### `dcode status` reports a stale inference route -The managed `dcode` runtime reads provider and model settings from `/sandbox/.deepagents/config.toml`. -NemoClaw generates that file during onboarding and rebuilds, and Deep Agents provider or model changes require fresh named recreation rather than `inference set`. +The managed `dcode` runtime reads provider and model settings from `/sandbox/.deepagents/config.toml`. NemoClaw generates that file during onboarding and rebuilds, and Deep Agents provider or model changes require fresh named recreation rather than `inference set`. Check the host-recorded route first: @@ -2110,8 +1682,7 @@ nemo-deepagents onboard --fresh --name --recreate-sandbox ### Trusted route-probe helper is missing -Deep Agents sandboxes created by older NemoClaw images may not contain the image-owned `/usr/local/lib/nemoclaw/dcode-managed-exec` helper. -Current `connect`, `status`, and `doctor` fail closed when that helper is missing because NemoClaw cannot run the authoritative `inference.local` route probe safely. +Deep Agents sandboxes created by older NemoClaw images may not contain the image-owned `/usr/local/lib/nemoclaw/dcode-managed-exec` helper. Current `connect`, `status`, and `doctor` fail closed when that helper is missing because NemoClaw cannot run the authoritative `inference.local` route probe safely. If the output says the trusted Deep Agents Code route-probe helper is missing, rebuild the sandbox with the current image and retry the command: @@ -2122,8 +1693,7 @@ nemo-deepagents status ### `dcode` refuses to start because upstream auth state exists -The managed launchers refuse upstream credential state inside `/sandbox/.deepagents/.state/auth.json` and `/sandbox/.deepagents/.state/chatgpt-auth.json`. -Those files can contain provider credentials or OAuth state that bypasses NemoClaw's host-owned credential boundary. +The managed launchers refuse upstream credential state inside `/sandbox/.deepagents/.state/auth.json` and `/sandbox/.deepagents/.state/chatgpt-auth.json`. Those files can contain provider credentials or OAuth state that bypasses NemoClaw's host-owned credential boundary. Remove the upstream auth state from the sandbox, then start `dcode` again: @@ -2132,8 +1702,7 @@ rm -f /sandbox/.deepagents/.state/auth.json /sandbox/.deepagents/.state/chatgpt- dcode status ``` -Do not put provider credentials in `/sandbox/.deepagents/.env`, project `.env` files, or Deep Agents config files. -Register credentials with NemoClaw or OpenShell on the host so the gateway can inject them at egress. +Do not put provider credentials in `/sandbox/.deepagents/.env`, project `.env` files, or Deep Agents config files. Register credentials with NemoClaw or OpenShell on the host so the gateway can inject them at egress. ### Managed MCP commands report an older Deep Agents runtime @@ -2141,8 +1710,7 @@ For the current rebuild and retry workflow, refer to [Agent MCP Capability Is Mi ### Tavily remains blocked after opt-in -Deep Agents does not have a NemoClaw-managed web-search feature. -The Tavily flow only opens Python egress for project code or manually configured tools that call Tavily. +Deep Agents does not have a NemoClaw-managed web-search feature. The Tavily flow only opens Python egress for project code or manually configured tools that call Tavily. Confirm that the target sandbox has the `tavily` preset applied: @@ -2166,13 +1734,11 @@ If Tavily is still blocked after rebuild, inspect recent policy denials: nemo-deepagents logs --tail 50 ``` -The `tavily` preset is a managed-Python opt-in. -It is process-wide for sandbox Python and is not a `dcode`-only boundary. +The `tavily` preset is a managed-Python opt-in. It is process-wide for sandbox Python and is not a `dcode`-only boundary. ### Deep Agents read-only path checks fail -The Deep Agents image keeps the managed Python environment under `/opt/venv` read-only and leaves `/sandbox` writable for project and agent state. -Writes under `/usr`, `/etc`, or `/opt/venv` should fail, while writes under `/sandbox` and `/tmp` should work. +The Deep Agents image keeps the managed Python environment under `/opt/venv` read-only and leaves `/sandbox` writable for project and agent state. Writes under `/usr`, `/etc`, or `/opt/venv` should fail, while writes under `/sandbox` and `/tmp` should work. If a read-only path probe reports that protected paths are writable, rebuild with the current NemoClaw image: @@ -2180,17 +1746,13 @@ If a read-only path probe reports that protected paths are writable, rebuild wit nemo-deepagents rebuild ``` -If startup reports that Landlock enforcement is unavailable, the Deep Agents sandbox fails closed instead of running with reduced filesystem enforcement. -Deep Agents uses `compatibility: strict` for its managed filesystem policy, so kernels older than 5.13 or VM-backed Docker runtimes without Landlock support can block sandbox creation. -Move the sandbox to a Linux kernel and container runtime that support Landlock, then rerun onboarding or rebuild the sandbox. +If startup reports that Landlock enforcement is unavailable, the Deep Agents sandbox fails closed instead of running with reduced filesystem enforcement. Deep Agents uses `compatibility: strict` for its managed filesystem policy, so kernels older than 5.13 or VM-backed Docker runtimes without Landlock support can block sandbox creation. Move the sandbox to a Linux kernel and container runtime that support Landlock, then rerun onboarding or rebuild the sandbox. ### Git clone fails with a certificate verification error -In networks that inspect TLS, OpenShell injects a proxy CA bundle into the sandbox. -Current NemoClaw exports that bundle as `GIT_SSL_CAINFO` during sandbox startup and persists it for `$$nemoclaw connect` sessions, so Git can trust the proxy CA. -It also forwards standard CA bundle variables for subprocesses, including `GIT_SSL_CAPATH`, `CURL_CA_BUNDLE`, and `REQUESTS_CA_BUNDLE`. +In networks that inspect TLS, OpenShell injects a proxy CA bundle into the sandbox. Current NemoClaw exports that bundle as `GIT_SSL_CAINFO` during sandbox startup and persists it for `$$nemoclaw connect` sessions, so Git can trust the proxy CA. It also forwards standard CA bundle variables for subprocesses, including `GIT_SSL_CAPATH`, `CURL_CA_BUNDLE`, and `REQUESTS_CA_BUNDLE`. If Git still reports `server certificate verification failed`, reconnect to the sandbox and check that the CA variables are present: @@ -2210,8 +1772,7 @@ $$nemoclaw rebuild ### External channel TLS fails behind a corporate MITM proxy (`NET:FAIL`) -On networks where a corporate proxy re-signs external TLS, endpoints such as `api.telegram.org` can fail certificate verification even when network policy allows the connection. -Logs show the request opening as `NET:OPEN ... api.telegram.org:443` followed by `NET:FAIL` because the corporate root is not in the OpenShell trust path. +On networks where a corporate proxy re-signs external TLS, endpoints such as `api.telegram.org` can fail certificate verification even when network policy allows the connection. Logs show the request opening as `NET:OPEN ... api.telegram.org:443` followed by `NET:FAIL` because the corporate root is not in the OpenShell trust path. Provide the corporate CA before onboarding, then onboard or rebuild the sandbox. @@ -2220,15 +1781,13 @@ export NEMOCLAW_CORPORATE_CA_BUNDLE=/path/to/corporate-ca.pem $$nemoclaw onboard ``` -Refer to [Configure Corporate CA Trust](../security/configure-corporate-ca-trust) for source precedence, host anchor discovery, image and runtime trust, custom Dockerfile requirements, import validation, and `NEMOCLAW_CORPORATE_CA_IMPORT=0`. -If the import does not occur, check onboarding output for `baking corporate proxy CA from ...` or a warning that the selected source was skipped. +Refer to [Configure Corporate CA Trust](../security/configure-corporate-ca-trust) for source precedence, host anchor discovery, image and runtime trust, custom Dockerfile requirements, import validation, and `NEMOCLAW_CORPORATE_CA_IMPORT=0`. If the import does not occur, check onboarding output for `baking corporate proxy CA from ...` or a warning that the selected source was skipped. ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` -Sandbox outbound network access is denied by default and enforced by the OpenShell proxy. -When a request targets a host that no applied policy preset allows, the proxy refuses the tunnel and tools surface only the protocol-level error: +Sandbox outbound network access is denied by default and enforced by the OpenShell proxy. When a request targets a host that no applied policy preset allows, the proxy refuses the tunnel and tools surface only the protocol-level error: ```text fatal: unable to access 'https://example.com/foo/bar/': CONNECT tunnel failed, response 403 @@ -2237,8 +1796,7 @@ curl: (56) CONNECT tunnel failed, response 403 This is a network-policy denial, not a tool or certificate problem. -When you run a command through `$$nemoclaw exec -- ...` and it exits non-zero, NemoClaw checks the sandbox audit log for a policy denial recorded after the command started. -If it finds one, it appends a short breadcrumb to stderr after the tool's own output, naming the denied `host:port` when it can be extracted safely and showing the commands below: +When you run a command through `$$nemoclaw exec -- ...` and it exits non-zero, NemoClaw checks the sandbox audit log for a policy denial recorded after the command started. If it finds one, it appends a short breadcrumb to stderr after the tool's own output, naming the denied `host:port` when it can be extracted safely and showing the commands below: ```text curl: (56) CONNECT tunnel failed, response 403 @@ -2260,13 +1818,7 @@ $$nemoclaw: recent network policy denial detected for [2001:db8::1]:443 inside s The tool's own stdout/stderr bytes and its exit code are left unchanged. The breadcrumb is printed by the host CLI after the command finishes, and only for a genuine failure with a fresh denial. A command that succeeds, or one that fails for an unrelated reason, prints no breadcrumb. Set `NEMOCLAW_NO_POLICY_HINT` to any non-empty value other than `0` or case-insensitive `false` (for example, `1`, `true`, `TRUE`, `yes`, or `YES`) to suppress it entirely. -The first interactive `$$nemoclaw connect` shell also prints a one-line reminder of this denial signature and the `logs` command below. -The reminder is shown once per top-level interactive session, and only when all of these hold: an egress proxy is configured, the shell is interactive with a terminal attached to stderr, and it is a top-level shell (not a nested subshell or pane). -Suppress it with `NEMOCLAW_NO_POLICY_HINT=1`. -The reminder names the sandbox when NemoClaw receives a valid sandbox name during sandbox creation. -If no valid name is available, it shows ``; run `$$nemoclaw list` to see your sandbox names. -If the reported sandbox name contains characters that are not valid in a sandbox name (uppercase letters, underscores, control characters, and similar) or exceeds 19 characters, the reminder shows the `` placeholder for safety rather than echoing the untrusted value. -The reminder is intentionally proactive: the denial itself is surfaced by the OpenShell proxy, so the `curl`/`git` error text is left unchanged and the reminder points you to the logs instead. +The first interactive `$$nemoclaw connect` shell also prints a one-line reminder of this denial signature and the `logs` command below. The reminder is shown once per top-level interactive session, and only when all of these hold: an egress proxy is configured, the shell is interactive with a terminal attached to stderr, and it is a top-level shell (not a nested subshell or pane). Suppress it with `NEMOCLAW_NO_POLICY_HINT=1`. The reminder names the sandbox when NemoClaw receives a valid sandbox name during sandbox creation. If no valid name is available, it shows ``; run `$$nemoclaw list` to see your sandbox names. If the reported sandbox name contains characters that are not valid in a sandbox name (uppercase letters, underscores, control characters, and similar) or exceeds 19 characters, the reminder shows the `` placeholder for safety rather than echoing the untrusted value. The reminder is intentionally proactive: the denial itself is surfaced by the OpenShell proxy, so the `curl`/`git` error text is left unchanged and the reminder points you to the logs instead. To see which rule denied the request, read the merged logs from the host: @@ -2275,10 +1827,12 @@ $$nemoclaw logs --tail 50 ``` -If the host should be reachable, allow it with a preset or a [custom preset](../network-policy/customize-network-policy#custom-preset-files): + If the host should be reachable, allow it with a preset or a [custom + preset](../network-policy/customize-network-policy#custom-preset-files): -If the host should be reachable, allow it with a built-in preset or apply a [reviewed custom preset file](../network-policy/configure-policies/create-custom-policy-presets) from the host: + If the host should be reachable, allow it with a built-in preset or apply a [reviewed custom + preset file](../network-policy/configure-policies/create-custom-policy-presets) from the host: ```bash @@ -2293,23 +1847,20 @@ nemo-deepagents policy add --from-file ./my-preset.yaml --yes -Replace `` with a real preset name such as `github`, `pypi`, or `npm`. -Run `$$nemoclaw policy add` with no preset to list the available presets. +Replace `` with a real preset name such as `github`, `pypi`, or `npm`. Run `$$nemoclaw policy add` with no preset to list the available presets. ### An `openclaw` command inside the sandbox fails with `scope upgrade pending approval` -The OpenClaw gateway refuses a command when the device asks for more scopes than are currently approved. -The failure names the request id but not the command that clears it: +The OpenClaw gateway refuses a command when the device asks for more scopes than are currently approved. The failure names the request id but not the command that clears it: ```text gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: 4899d110-911f-4bc7-ac1a-85d76c7b366f) ``` -When you run an `openclaw` command through `$$nemoclaw exec -- ...` and it exits non-zero, NemoClaw asks the sandbox whether any device request is pending. -If one is, it appends the review path to stderr after the command's own output: +When you run an `openclaw` command through `$$nemoclaw exec -- ...` and it exits non-zero, NemoClaw asks the sandbox whether any device request is pending. If one is, it appends the review path to stderr after the command's own output: ```text $$nemoclaw: a device scope upgrade is waiting for approval inside sandbox 'my-assistant'. @@ -2324,17 +1875,13 @@ Read `openclaw devices list`, confirm the requesting device and the requested sc The hint never names a request id. NemoClaw cannot tell which pending request belongs to the failed command, so naming one would present an unrelated request — for example a different device asking for `operator.admin` — as this command's remedy. You make that decision from `devices list`. -The probe runs only for a failed `openclaw` command. A command that succeeds, a non-`openclaw` command, and a transport failure all print no hint. -If the probe fails, or nothing is pending, no hint is added. -An invalid sandbox name is shown as ``. -`NEMOCLAW_NO_POLICY_HINT` suppresses this hint on the same terms as the network-policy breadcrumb above. +The probe runs only for a failed `openclaw` command. A command that succeeds, a non-`openclaw` command, and a transport failure all print no hint. If the probe fails, or nothing is pending, no hint is added. An invalid sandbox name is shown as ``. `NEMOCLAW_NO_POLICY_HINT` suppresses this hint on the same terms as the network-policy breadcrumb above. ### Sandbox creation reports a TLS certificate mismatch -If sandbox creation reports a TLS or certificate mismatch, the OpenShell gateway certificate may have changed since the CLI last registered it. -Remove the stale local gateway registration and then resume onboarding so NemoClaw refreshes the registration: +If sandbox creation reports a TLS or certificate mismatch, the OpenShell gateway certificate may have changed since the CLI last registered it. Remove the stale local gateway registration and then resume onboarding so NemoClaw refreshes the registration: ```bash openshell gateway remove nemoclaw @@ -2345,11 +1892,9 @@ $$nemoclaw onboard --resume ### `openclaw update` hangs or times out inside the sandbox -This is expected for the current NemoClaw deployment model. -NemoClaw installs `openclaw` into the sandbox image at build time, so the CLI is image-pinned rather than updated in place inside a running sandbox. +This is expected for the current NemoClaw deployment model. NemoClaw installs `openclaw` into the sandbox image at build time, so the CLI is image-pinned rather than updated in place inside a running sandbox. -Do not run `openclaw update` inside the sandbox. -Instead: +Do not run `openclaw update` inside the sandbox. Instead: 1. Upgrade to a NemoClaw release that includes the newer `openclaw` version. 2. If you build NemoClaw from source, bump the pinned `openclaw` version in `Dockerfile.base` and rebuild the sandbox base image. @@ -2361,9 +1906,7 @@ Instead: ### AWS EC2 Instance-Role Credential Discovery Is Unavailable -This is expected in an OpenClaw sandbox. -NemoClaw forces `AWS_EC2_METADATA_DISABLED=true` because OpenShell blocks the link-local EC2 Instance Metadata Service endpoint. -Existing sandboxes must use a current NemoClaw image before they receive this environment invariant. +This is expected in an OpenClaw sandbox. NemoClaw forces `AWS_EC2_METADATA_DISABLED=true` because OpenShell blocks the link-local EC2 Instance Metadata Service endpoint. Existing sandboxes must use a current NemoClaw image before they receive this environment invariant. Upgrade NemoClaw and rebuild the sandbox: @@ -2385,56 +1928,34 @@ Expected output: true ``` -Only EC2 instance-role discovery is disabled. -Static access keys, bearer tokens, shared profiles, SSO and process credentials, web identity, and ECS container credentials remain eligible. -NemoClaw's host-local Amazon Bedrock adapter is outside the sandbox credential-discovery boundary and remains available. -Do not add `169.254.169.254` to a network policy or override the variable. +Only EC2 instance-role discovery is disabled. Static access keys, bearer tokens, shared profiles, SSO and process credentials, web identity, and ECS container credentials remain eligible. NemoClaw's host-local Amazon Bedrock adapter is outside the sandbox credential-discovery boundary and remains available. Do not add `169.254.169.254` to a network policy or override the variable. ### Inference requests time out -Verify that the inference provider endpoint is reachable from the host. -Check the active provider and endpoint: +Verify that the inference provider endpoint is reachable from the host. Check the active provider and endpoint: ```bash $$nemoclaw status ``` -The main `Inference` line probes `https://inference.local/v1/models` from inside the sandbox and then sends one inference request over the same route, so it reflects the route the agent uses. -If that line shows `unauthorized`, `unhealthy`, `unreachable`, or `not probed`, inspect the labeled diagnostic lines to identify the failing hop. -An `unauthorized` line means the route answered but rejected the request, so refresh the provider credential rather than the route. -For local Ollama and local vLLM, `Inference (ollama backend)` or the corresponding local-backend line reports the host-side service separately. -For Local Ollama, current releases can also print `Inference (auth proxy)` when a proxy token is available. -If a local backend or auth-proxy diagnostic fails, start the backend or re-run onboarding so NemoClaw can recreate the proxy token, restart the proxy, and refresh the route. -For Ollama-backed OpenClaw sandboxes, agent passthrough uses the registered host route to warm an unloaded model after an Ollama daemon restart. -If that bounded warm-up fails or times out, NemoClaw reports the result and continues so OpenClaw can emit its canonical backend error. +The main `Inference` line probes `https://inference.local/v1/models` from inside the sandbox and then sends one inference request over the same route, so it reflects the route the agent uses. If that line shows `unauthorized`, `unhealthy`, `unreachable`, or `not probed`, inspect the labeled diagnostic lines to identify the failing hop. An `unauthorized` line means the route answered but rejected the request, so refresh the provider credential rather than the route. For local Ollama and local vLLM, `Inference (ollama backend)` or the corresponding local-backend line reports the host-side service separately. For Local Ollama, current releases can also print `Inference (auth proxy)` when a proxy token is available. If a local backend or auth-proxy diagnostic fails, start the backend or re-run onboarding so NemoClaw can recreate the proxy token, restart the proxy, and refresh the route. For Ollama-backed OpenClaw sandboxes, agent passthrough uses the registered host route to warm an unloaded model after an Ollama daemon restart. If that bounded warm-up fails or times out, NemoClaw reports the result and continues so OpenClaw can emit its canonical backend error. -If the endpoint is correct but requests still fail, check for network policy rules that may block the connection. -Then verify the credential and base URL for the provider you selected during onboarding. +If the endpoint is correct but requests still fail, check for network policy rules that may block the connection. Then verify the credential and base URL for the provider you selected during onboarding. -If you entered an AWS Bedrock Runtime URL such as `https://bedrock-runtime.us-east-1.amazonaws.com` in the **Other Anthropic-compatible endpoint** flow, NemoClaw auto-detects it and routes sandbox traffic through a host-local adapter. -Use the raw Bedrock Runtime host, not an Anthropic `/v1/messages` path, and verify that the model ID or inference profile ID is valid for that region. -For auth, export `AWS_BEARER_TOKEN_BEDROCK`, `AWS_PROFILE`, or standard IAM environment credentials before onboarding; if you paste a key at the `COMPATIBLE_ANTHROPIC_API_KEY` prompt, NemoClaw uses it only as the adapter's Bedrock bearer token. -Region errors usually mean the pasted endpoint region, `AWS_REGION`, `AWS_DEFAULT_REGION`, or the model/inference profile ID do not match. +If you entered an AWS Bedrock Runtime URL such as `https://bedrock-runtime.us-east-1.amazonaws.com` in the **Other Anthropic-compatible endpoint** flow, NemoClaw auto-detects it and routes sandbox traffic through a host-local adapter. Use the raw Bedrock Runtime host, not an Anthropic `/v1/messages` path, and verify that the model ID or inference profile ID is valid for that region. For auth, export `AWS_BEARER_TOKEN_BEDROCK`, `AWS_PROFILE`, or standard IAM environment credentials before onboarding; if you paste a key at the `COMPATIBLE_ANTHROPIC_API_KEY` prompt, NemoClaw uses it only as the adapter's Bedrock bearer token. Region errors usually mean the pasted endpoint region, `AWS_REGION`, `AWS_DEFAULT_REGION`, or the model/inference profile ID do not match. -For Ollama, vLLM, NIM, and compatible-endpoint inference validation, the default timeout is 180 seconds. -The managed NIM startup health wait uses a separate 15-minute (900-second) default and still exits early if the container stops before it becomes healthy. -On Docker 29.x or hosts using the containerd image store, managed NIM onboarding resolves and pulls the host-platform image digest when NGC exposes a multi-architecture image index. -If you still see NGC repository-format or attestation errors, confirm Docker can run `docker manifest inspect` for the selected image and that you are logged in to `nvcr.io`. -If large prompts still cause timeouts, increase it with `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` before re-running onboard: +For Ollama, vLLM, NIM, and compatible-endpoint inference validation, the default timeout is 180 seconds. The managed NIM startup health wait uses a separate 15-minute (900-second) default and still exits early if the container stops before it becomes healthy. On Docker 29.x or hosts using the containerd image store, managed NIM onboarding resolves and pulls the host-platform image digest when NGC exposes a multi-architecture image index. If you still see NGC repository-format or attestation errors, confirm Docker can run `docker manifest inspect` for the selected image and that you are logged in to `nvcr.io`. If large prompts still cause timeouts, increase it with `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` before re-running onboard: ```bash export NEMOCLAW_LOCAL_INFERENCE_TIMEOUT=300 $$nemoclaw onboard ``` -For local Ollama and vLLM, onboarding retries the container reachability check and can fall back to the host-side health check when the local backend is healthy. -If Ollama times out during a cold model load, NemoClaw retries once with a 300-second probe budget before failing. -If all attempts fail, the error includes container reachability diagnostics such as HTTP status and host gateway resolution. +For local Ollama and vLLM, onboarding retries the container reachability check and can fall back to the host-side health check when the local backend is healthy. If Ollama times out during a cold model load, NemoClaw retries once with a 300-second probe budget before failing. If all attempts fail, the error includes container reachability diagnostics such as HTTP status and host gateway resolution. -`NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` only covers the inference-server validation probe. -The post-create readiness wait has its own budget (`NEMOCLAW_SANDBOX_READY_TIMEOUT`); refer to [Sandbox onboard times out with "did not become ready within Ns"](#sandbox-onboard-times-out-with-did-not-become-ready-within-ns) for the readiness path. +`NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` only covers the inference-server validation probe. The post-create readiness wait has its own budget (`NEMOCLAW_SANDBOX_READY_TIMEOUT`); refer to [Sandbox onboard times out with "did not become ready within Ns"](#sandbox-onboard-times-out-with-did-not-become-ready-within-ns) for the readiness path. ### Sandbox onboard times out with "did not become ready within Ns" @@ -2445,18 +1966,11 @@ Onboarding ends with: Retry: $$nemoclaw onboard ``` -This is a separate budget from `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT`. -It covers the readiness wait that follows sandbox creation, including in-sandbox boot, OpenClaw start, and policy load. -It does not cover the inference probe. +This is a separate budget from `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT`. It covers the readiness wait that follows sandbox creation, including in-sandbox boot, OpenClaw start, and policy load. It does not cover the inference probe. -For a newly created OpenClaw or Hermes sandbox, `Ready` is not the final acceptance signal. -Within this same budget, NemoClaw also requires OpenShell to return a durable sandbox ID and accept `openshell sandbox exec --name -- true`. -NemoClaw keeps waiting only when OpenShell returns its `sandbox is not ready` response. -A missing or malformed ID, or another command failure, stops the wait. -Ordinary onboarding then follows the failed-creation cleanup path. -Portable OpenClaw onboarding preserves the sandbox as described below. +For a newly created OpenClaw or Hermes sandbox, `Ready` is not the final acceptance signal. Within this same budget, NemoClaw also requires OpenShell to return a durable sandbox ID and accept `openshell sandbox exec --name -- true`. NemoClaw keeps waiting only when OpenShell returns its exact `sandbox is not ready` response. A missing or malformed ID, or another command failure, stops the wait. Ordinary onboarding then follows the failed-creation cleanup path. Portable OpenClaw onboarding preserves the sandbox as described below. @@ -2473,27 +1987,15 @@ export NEMOCLAW_SANDBOX_READY_TIMEOUT=600 $$nemoclaw onboard ``` -The variable accepts seconds and applies to the readiness wait only. -When the ordinary create deadline expires, NemoClaw tries to delete the partially created sandbox. -After successful cleanup, the output ends with `Retry: $$nemoclaw onboard`. -If cleanup fails, NemoClaw instead reports that the failed sandbox could not be removed and prints `Manual cleanup: openshell sandbox delete ""`. +The variable accepts seconds and applies to the readiness wait only. When the ordinary create deadline expires, NemoClaw tries to delete the partially created sandbox. After successful cleanup, the output ends with `Retry: $$nemoclaw onboard`. If cleanup fails, NemoClaw instead reports that the failed sandbox could not be removed and prints `Manual cleanup: openshell sandbox delete ""`. -Portable OpenClaw onboarding preserves the sandbox when NemoClaw cannot verify its runtime identity. -It does not start dashboard forwarding on this failure path. -Inspect the preserved sandbox with the status commands below, then follow the recovery guidance from `$$nemoclaw status`. +Portable OpenClaw onboarding preserves the sandbox when NemoClaw cannot verify its exact runtime identity. It does not start dashboard forwarding on this failure path. Inspect the preserved sandbox with the status commands below, then follow the recovery guidance from `$$nemoclaw status`. -The failure path also differs when NemoClaw recreates an OpenShell-managed Docker runtime immediately before this wait. -NemoClaw pins the OpenShell sandbox ID before recreation. -Within the same deadline, NemoClaw requires two consecutive `Ready` observations that each confirm the ID and successful command execution. -It retries only OpenShell's `sandbox is not ready` response. -If the deadline expires, the ID changes, or another probe fails, NemoClaw preserves diagnostics and attempts to restore the pre-recreation Docker container. -If restoration fails, NemoClaw reports that the sandbox and container state is uncertain. -NemoClaw does not start dashboard or other host forwarding, and it does not delete a sandbox by its mutable name. -It leaves the sandbox in place for inspection and recovery. +The failure path also differs when NemoClaw recreates an OpenShell-managed Docker runtime immediately before this wait. NemoClaw pins the exact OpenShell sandbox ID before recreation. Within the same deadline, NemoClaw requires two consecutive `Ready` observations that each confirm the exact ID and successful command execution. It retries only OpenShell's exact `sandbox is not ready` response. If the deadline expires, the ID changes, or another probe fails, NemoClaw preserves diagnostics and attempts to restore the pre-recreation Docker container. If restoration fails, NemoClaw reports that the sandbox and container state is uncertain. NemoClaw does not start dashboard or other host forwarding, and it does not delete a sandbox by its mutable name. It leaves the sandbox in place for inspection and recovery. If readiness still fails after the extended budget, inspect the gateway and sandbox status: @@ -2502,8 +2004,7 @@ openshell sandbox list $$nemoclaw status ``` -If onboarding instead reports that the sandbox "did not re-register with OpenShell after policy application," the same timeout controls that post-policy command-readiness probe. -Raise the budget before retrying, then inspect the same gateway and sandbox status if re-registration still fails. +If onboarding instead reports that the sandbox "did not re-register with OpenShell after policy application," the same timeout controls that post-policy command-readiness probe. Raise the budget before retrying, then inspect the same gateway and sandbox status if re-registration still fails. ### Sandbox onboard fails with "entered Error phase before it became ready" @@ -2513,25 +2014,18 @@ Onboarding ends with: Sandbox 'my-assistant' entered Error phase before it became ready (waited up to 180s). ``` -On a fresh onboard the OpenShell gateway can (re)start its supervisor session and re-register the just-created sandbox. -During that window `openshell sandbox list` briefly reports the sandbox in the transient `Error` phase before it flips to `Ready`, as seen on DGX Spark when supervisor restart races the sandbox bootstrap. +On a fresh onboard the OpenShell gateway can (re)start its supervisor session and re-register the just-created sandbox. During that window `openshell sandbox list` briefly reports the sandbox in the transient `Error` phase before it flips to `Ready`, as seen on DGX Spark when supervisor restart races the sandbox bootstrap. -NemoClaw polls immediately, starts retrying after 250ms, and backs off to a 2-second cap. -It tolerates 30 consecutive `Error` observations by default so this transient recovers on its own. -Only `Error` that persists through the debounce count is terminal, unless the overall `NEMOCLAW_SANDBOX_READY_TIMEOUT` deadline expires first. -`Failed` and `CrashLoopBackOff` are always terminal and fail immediately. +NemoClaw polls immediately, starts retrying after 250ms, and backs off to a 2-second cap. It tolerates 30 consecutive `Error` observations by default so this transient recovers on its own. Only `Error` that persists through the debounce count is terminal, unless the overall `NEMOCLAW_SANDBOX_READY_TIMEOUT` deadline expires first. `Failed` and `CrashLoopBackOff` are always terminal and fail immediately. -If your host needs more observations for slower re-registration, raise the debounce. -Raise `NEMOCLAW_SANDBOX_READY_TIMEOUT` too if the overall deadline is too short. -To fail fast on the first `Error` poll, set the debounce to `1`: +If your host needs more observations for slower re-registration, raise the debounce. Raise `NEMOCLAW_SANDBOX_READY_TIMEOUT` too if the overall deadline is too short. To fail fast on the first `Error` poll, set the debounce to `1`: ```bash export NEMOCLAW_SANDBOX_READY_ERROR_DEBOUNCE=1 $$nemoclaw onboard ``` -If the failure persists after the debounce, the sandbox is stuck. -Inspect the retained diagnostics and gateway state: +If the failure persists after the debounce, the sandbox is stuck. Inspect the retained diagnostics and gateway state: ```bash openshell sandbox list @@ -2542,30 +2036,19 @@ $$nemoclaw status ### Agent fails at runtime after onboarding succeeds with a compatible endpoint -Some OpenAI-compatible servers (such as SGLang) expose `/v1/responses` but their -streaming mode is incomplete. -OpenClaw requires granular streaming events like `response.output_text.delta` -that these backends do not emit. +Some OpenAI-compatible servers (such as SGLang) expose `/v1/responses` but their streaming mode is incomplete. OpenClaw requires granular streaming events like `response.output_text.delta` that these backends do not emit. -For the compatible-endpoint provider, NemoClaw now defaults to -`/v1/chat/completions` and skips the Responses API probe entirely unless you -opt in. -If you onboarded an older release that selected `/v1/responses`, re-run -onboarding so the wizard rebuilds the image with chat completions: +For the compatible-endpoint provider, NemoClaw now defaults to `/v1/chat/completions` and skips the Responses API probe entirely unless you opt in. If you onboarded an older release that selected `/v1/responses`, re-run onboarding so the wizard rebuilds the image with chat completions: ```bash $$nemoclaw onboard ``` -If you previously set `NEMOCLAW_PREFERRED_API=openai-responses` to force the -Responses API, unset it before re-running onboard. +If you previously set `NEMOCLAW_PREFERRED_API=openai-responses` to force the Responses API, unset it before re-running onboard. -When you enable Telegram messaging with an OpenAI-compatible endpoint, onboarding also checks `inference.local` from inside the sandbox. -If that smoke check fails, fix the compatible-endpoint base URL, credentials, model, or network route before testing the Telegram bot again. +When you enable Telegram messaging with an OpenAI-compatible endpoint, onboarding also checks `inference.local` from inside the sandbox. If that smoke check fails, fix the compatible-endpoint base URL, credentials, model, or network route before testing the Telegram bot again. -Do not rely on `NEMOCLAW_INFERENCE_API_OVERRIDE` alone. -It patches the config at container startup but does not update the Dockerfile ARG baked into the image. -A fresh `$$nemoclaw onboard` is the reliable fix. +Do not rely on `NEMOCLAW_INFERENCE_API_OVERRIDE` alone. It patches the config at container startup but does not update the Dockerfile ARG baked into the image. A fresh `$$nemoclaw onboard` is the reliable fix. @@ -2573,38 +2056,29 @@ A fresh `$$nemoclaw onboard` is the reliable fix. ### Tool calls appear as assistant text -Local model servers must return structured `tool_calls` for OpenClaw to dispatch a tool. -When the inference response contains only text that resembles a tool request, the gateway treats it as ordinary assistant text and no tool runs. -The TUI can display a response such as: +Local model servers must return structured `tool_calls` for OpenClaw to dispatch a tool. When the inference response contains only text that resembles a tool request, the gateway treats it as ordinary assistant text and no tool runs. The TUI can display a response such as: ```json -{"arguments":{"query":"robotics"},"name":"memory_search"} +{ "arguments": { "query": "robotics" }, "name": "memory_search" } ``` -This symptom is different from a network or policy block. -`$$nemoclaw status`, `$$nemoclaw logs`, and `$$nemoclaw debug --quick` can all look healthy while conversation-level tool dispatch fails. +This symptom is different from a network or policy block. `$$nemoclaw status`, `$$nemoclaw logs`, and `$$nemoclaw debug --quick` can all look healthy while conversation-level tool dispatch fails. Ollama can serve local chat and some simple tool surfaces, but agent loops with several tools, long instructions, or multi-turn dispatch need a server that returns structured tool calls consistently. | Workload | Ollama is usually sufficient | Prefer vLLM with a parser | -|---|---|---| +| --- | --- | --- | | Plain chat | Yes | Optional | | One simple tool with short prompts | Often | Optional | | Agent loops with several tools | Risky | Yes | | Long system prompts or sender metadata | Risky | Yes | | Multi-turn tool dispatch | Risky | Yes | -On hosts other than N1x, [set up vLLM](../inference/local-inference/set-up-vllm) with automatic tool choice and the tool-call parser that matches the model family for persistent agent use. -After the parser-aware server is ready, re-run onboarding. -Select the **Local vLLM** entry marked **running (suggested)** for a server detected on `localhost:${NEMOCLAW_VLLM_PORT:-8000}`; for another address, select **Other OpenAI-compatible endpoint**. -On generic hosts, the Local vLLM entry includes an experimental label; on DGX Spark or DGX Station, it does not. -On N1x, stop any server that occupies `${NEMOCLAW_VLLM_PORT:-8000}`, then use only the Deferred managed-vLLM preview; existing and compatible servers are not admitted. +On hosts other than N1x, [set up vLLM](../inference/local-inference/set-up-vllm) with automatic tool choice and the tool-call parser that matches the model family for persistent agent use. After the parser-aware server is ready, re-run onboarding. Select the **Local vLLM** entry marked **running (suggested)** for a server detected on `localhost:${NEMOCLAW_VLLM_PORT:-8000}`; for another address, select **Other OpenAI-compatible endpoint**. On generic hosts, the Local vLLM entry includes an experimental label; on DGX Spark or DGX Station, it does not. On N1x, stop any server that occupies `${NEMOCLAW_VLLM_PORT:-8000}`, then use only the Deferred managed-vLLM preview; existing and compatible servers are not admitted. -Do not rely on direct edits to `openclaw.json` for a persistent provider change. -NemoClaw-managed rebuilds can overwrite those edits, while onboarding keeps the sandbox image, OpenShell route, and host-managed credentials aligned. +Do not rely on direct edits to `openclaw.json` for a persistent provider change. NemoClaw-managed rebuilds can overwrite those edits, while onboarding keeps the sandbox image, OpenShell route, and host-managed credentials aligned. -Ask the agent to perform an action that requires a tool, then confirm that the TUI does not show a JSON blob as assistant text, the gateway log shows tool dispatch followed by an answer, and `$$nemoclaw status` reports the intended local vLLM or compatible provider. -If JSON still appears as text, confirm that vLLM started with automatic tool choice and the parser required by the model family. +Ask the agent to perform an action that requires a tool, then confirm that the TUI does not show a JSON blob as assistant text, the gateway log shows tool dispatch followed by an answer, and `$$nemoclaw status` reports the intended local vLLM or compatible provider. If JSON still appears as text, confirm that vLLM started with automatic tool choice and the parser required by the model family. @@ -2619,17 +2093,11 @@ anthropic-streaming-missing-tool-use anthropic-streaming-missing-tool-use-stop-reason ``` -After an ordinary `/v1/messages` request succeeds, NemoClaw sends a streaming request that forces the endpoint to call the `emit_ok` validation tool. -The response must include a native Anthropic `tool_use` content block named `emit_ok` and a later `message_delta` with `stop_reason: tool_use`. -NemoClaw checks these observations independently: the first diagnostic means the named native block is absent, and the second means the stream does not finish the tool request with the required stop reason. +After an ordinary `/v1/messages` request succeeds, NemoClaw sends a streaming request that forces the endpoint to call the `emit_ok` validation tool. The response must include a native Anthropic `tool_use` content block named `emit_ok` and a later `message_delta` with `stop_reason: tool_use`. NemoClaw checks these observations independently: the first diagnostic means the named native block is absent, and the second means the stream does not finish the tool request with the required stop reason. -A text delta containing JSON such as `{"name":"emit_ok","arguments":{"value":"OK"}}` is still assistant text. -NemoClaw rejects it instead of treating it as a tool call because OpenClaw cannot dispatch text as a native Anthropic tool request. -Fix the endpoint's Anthropic tool parser or chat template so it emits native protocol events, then run onboarding again. +A text delta containing JSON such as `{"name":"emit_ok","arguments":{"value":"OK"}}` is still assistant text. NemoClaw rejects it instead of treating it as a tool call because OpenClaw cannot dispatch text as a native Anthropic tool request. Fix the endpoint's Anthropic tool parser or chat template so it emits native protocol events, then run onboarding again. -This check applies only to OpenClaw custom Anthropic routes. -Hermes and Deep Agents Code keep their existing `/v1/chat/completions` validation and do not run the native Anthropic `emit_ok` probe. -For a reasoning-only endpoint, `NEMOCLAW_REASONING=true` skips the streaming sequence and forced tool-call checks; OpenClaw still needs streaming and native tool calls at runtime, so use this only when the selected model cannot complete the onboarding probe. +This check applies only to OpenClaw custom Anthropic routes. Hermes and Deep Agents Code keep their existing `/v1/chat/completions` validation and do not run the native Anthropic `emit_ok` probe. For a reasoning-only endpoint, `NEMOCLAW_REASONING=true` skips the streaming sequence and forced tool-call checks; OpenClaw still needs streaming and native tool calls at runtime, so use this only when the selected model cannot complete the onboarding probe. ### Onboarding fails with duplicate Anthropic message_start events @@ -2639,42 +2107,23 @@ Validation for an OpenClaw **Other Anthropic-compatible endpoint** selection end Anthropic Messages API (streaming): duplicate message_start ``` -For OpenClaw custom Anthropic routes, NemoClaw sends a `stream: true` request to `/v1/messages` and validates the SSE event sequence (exactly one `message_start`, at least one `content_block_delta`, and a `message_stop`). -The same request forces the `emit_ok` tool and separately requires a native `tool_use` block plus `stop_reason: tool_use`; JSON-shaped assistant text does not satisfy that tool-call contract. -This error means the streaming layer on the endpoint or gateway is malformed even though its non-streaming responses are valid. -A working non-streaming response does not imply that streaming works. -Some inference gateways proxy plain requests correctly but corrupt the SSE stream, for example by emitting `message_start` twice for one request. -OpenClaw uses the streaming path, so without this check the defect would first surface inside the sandbox as a runtime failure. +For OpenClaw custom Anthropic routes, NemoClaw sends a `stream: true` request to `/v1/messages` and validates the SSE event sequence (exactly one `message_start`, at least one `content_block_delta`, and a `message_stop`). The same request forces the `emit_ok` tool and separately requires a native `tool_use` block plus `stop_reason: tool_use`; JSON-shaped assistant text does not satisfy that tool-call contract. This error means the streaming layer on the endpoint or gateway is malformed even though its non-streaming responses are valid. A working non-streaming response does not imply that streaming works. Some inference gateways proxy plain requests correctly but corrupt the SSE stream, for example by emitting `message_start` twice for one request. OpenClaw uses the streaming path, so without this check the defect would first surface inside the sandbox as a runtime failure. -Hermes and OpenAI-compatible-only agents use the endpoint's `/v1/chat/completions` surface for custom Anthropic selections instead. -Current onboarding validates that surface and does not reject those agents because of a malformed native `/v1/messages` stream they will not use. -An older Hermes sandbox that still uses native Anthropic Messages can report that no final response was produced; re-run onboarding to select and validate the managed Chat Completions route. +Hermes and OpenAI-compatible-only agents use the endpoint's `/v1/chat/completions` surface for custom Anthropic selections instead. Current onboarding validates that surface and does not reject those agents because of a malformed native `/v1/messages` stream they will not use. An older Hermes sandbox that still uses native Anthropic Messages can report that no final response was produced; re-run onboarding to select and validate the managed Chat Completions route. -Fix the streaming layer on the endpoint or gateway, or onboard with a different Anthropic-compatible endpoint. -The official Anthropic provider does not run this check and is not affected. -If an OpenClaw sandbox created by an older release fails at runtime with an empty final response on an Anthropic-compatible endpoint, re-run `$$nemoclaw onboard` so the streaming check can diagnose the endpoint. -If the endpoint serves a reasoning-only model, set `NEMOCLAW_REASONING=true` to skip the streaming sequence and forced tool-call checks. -Streaming or native tool-call defects then surface at runtime instead of during onboarding. +Fix the streaming layer on the endpoint or gateway, or onboard with a different Anthropic-compatible endpoint. The official Anthropic provider does not run this check and is not affected. If an OpenClaw sandbox created by an older release fails at runtime with an empty final response on an Anthropic-compatible endpoint, re-run `$$nemoclaw onboard` so the streaming check can diagnose the endpoint. If the endpoint serves a reasoning-only model, set `NEMOCLAW_REASONING=true` to skip the streaming sequence and forced tool-call checks. Streaming or native tool-call defects then surface at runtime instead of during onboarding. ### `NEMOCLAW_DISABLE_DEVICE_AUTH=1` does not change an existing sandbox -This is expected behavior. -`NEMOCLAW_DISABLE_DEVICE_AUTH` is a build-time setting used when NemoClaw creates the sandbox image. -Changing or exporting it later does not rewrite the baked `openclaw.json` inside an existing sandbox. +This is expected behavior. `NEMOCLAW_DISABLE_DEVICE_AUTH` is a build-time setting used when NemoClaw creates the sandbox image. Changing or exporting it later does not rewrite the baked `openclaw.json` inside an existing sandbox. -If you need a different device-auth setting, rerun onboarding so NemoClaw rebuilds the sandbox image with the desired configuration. -For the security trade-offs, refer to [Security Best Practices](../security/best-practices). +If you need a different device-auth setting, rerun onboarding so NemoClaw rebuilds the sandbox image with the desired configuration. For the security trade-offs, refer to [Security Best Practices](../security/best-practices). ### `openclaw.json` is empty after changing inference -Some runtime inference changes can leave `/sandbox/.openclaw/openclaw.json` empty if the write fails partway through. -When that happens, OpenClaw commands may report that the config is empty instead of showing a raw JSON parse error. +Some runtime inference changes can leave `/sandbox/.openclaw/openclaw.json` empty if the write fails partway through. When that happens, OpenClaw commands may report that the config is empty instead of showing a raw JSON parse error. -Current NemoClaw sandboxes capture a known-good config baseline after a successful startup. -On the next sandbox startup, NemoClaw restores `openclaw.json` from OpenClaw's last-good copy when available, or from the NemoClaw baseline. -Recovery validates the config tree and selected source before atomically replacing `openclaw.json` and `.config-hash`. -If it detects an unsafe link, unexpected owner, or path change, startup fails closed without following or modifying the unsafe target. -If the sandbox still cannot start or reports that no baseline is available, rebuild it from the host: +Current NemoClaw sandboxes capture a known-good config baseline after a successful startup. On the next sandbox startup, NemoClaw restores `openclaw.json` from OpenClaw's last-good copy when available, or from the NemoClaw baseline. Recovery validates the config tree and selected source before atomically replacing `openclaw.json` and `.config-hash`. If it detects an unsafe link, unexpected owner, or path change, startup fails closed without following or modifying the unsafe target. If the sandbox still cannot start or reports that no baseline is available, rebuild it from the host: ```bash $$nemoclaw rebuild @@ -2686,11 +2135,9 @@ $$nemoclaw rebuild ### A Shields command reports corrupt persisted state -If `shields up`, `shields down`, or `shields status` reports corrupt persisted state, NemoClaw refuses to infer or change the Shields posture. -It preserves the Shields state file, timer marker, and transition record so an active recovery authority remains intact. +If `shields up`, `shields down`, or `shields status` reports corrupt persisted state, NemoClaw refuses to infer or change the Shields posture. It preserves the Shields state file, timer marker, and transition record so an active recovery authority remains intact. -While the state remains corrupt, do not use `shields up` or an ordinary rebuild to replace it. -Inspect the reported state-file error and restore the state file from a trusted host backup before retrying. +While the state remains corrupt, do not use `shields up` or an ordinary rebuild to replace it. Inspect the reported state-file error and restore the state file from a trusted host backup before retrying. @@ -2698,18 +2145,9 @@ Inspect the reported state-file error and restore the state file from a trusted ### `shields up` or `shields down` fails after `.config-hash` was removed -`/sandbox/.openclaw/.config-hash` is the integrity sidecar for `openclaw.json`; deleting it during a manual config edit removes the file the shields guard captures alongside the config. -From the default mutable posture, `$$nemoclaw shields up` regenerates a stale hash from the current `openclaw.json` bytes. -On sandboxes with the updated guard, the same command synthesizes a truly absent `.config-hash` under the frozen tree. -Only a truly absent file is repaired; an unexpected file type at that name still fails closed. -`$$nemoclaw shields down` does not synthesize the hash: with the file missing it fails closed without modifying the config. -If shields are already up, another `shields up` also fails closed when the hash is missing. -Do not use mutable-posture synthesis to recover a locked sandbox. +`/sandbox/.openclaw/.config-hash` is the integrity sidecar for `openclaw.json`; deleting it during a manual config edit removes the file the shields guard captures alongside the config. From the default mutable posture, `$$nemoclaw shields up` regenerates a stale hash from the current `openclaw.json` bytes. On sandboxes with the updated guard, the same command synthesizes a truly absent `.config-hash` under the frozen tree. Only a truly absent file is repaired; an unexpected file type at that name still fails closed. `$$nemoclaw shields down` does not synthesize the hash: with the file missing it fails closed without modifying the config. If shields are already up, another `shields up` also fails closed when the hash is missing. Do not use mutable-posture synthesis to recover a locked sandbox. -Sandboxes created by an older NemoClaw release keep the older guard baked into the container image, where a missing `.config-hash` makes the transition fail closed and quarantine `openclaw.json` by renaming it to `.nemoclaw-rejected-openclaw.json-` in the same directory. -The config bytes are preserved, not deleted. -Upgrade the NemoClaw CLI before either recovery path because an older CLI restages the older guard. -To preserve the quarantined settings, copy the file to the host before rebuilding: +Sandboxes created by an older NemoClaw release keep the older guard baked into the container image, where a missing `.config-hash` makes the transition fail closed and quarantine `openclaw.json` by renaming it to `.nemoclaw-rejected-openclaw.json-` in the same directory. The config bytes are preserved, not deleted. Upgrade the NemoClaw CLI before either recovery path because an older CLI restages the older guard. To preserve the quarantined settings, copy the file to the host before rebuilding: ```bash docker exec ls -a /sandbox/.openclaw @@ -2717,9 +2155,7 @@ docker cp :/sandbox/.openclaw/ ./openclaw.json.recov $$nemoclaw rebuild --yes ``` -After the rebuild, inspect `./openclaw.json.recovered` and reapply required settings with the host-side `config set` command. -Do not overwrite the regenerated `openclaw.json` with an unreviewed quarantine copy. -To discard the quarantined settings, upgrade the CLI and run `$$nemoclaw rebuild --yes` without copying the file. +After the rebuild, inspect `./openclaw.json.recovered` and reapply required settings with the host-side `config set` command. Do not overwrite the regenerated `openclaw.json` with an unreviewed quarantine copy. To discard the quarantined settings, upgrade the CLI and run `$$nemoclaw rebuild --yes` without copying the file. @@ -2743,18 +2179,11 @@ $$nemoclaw channels add $$nemoclaw channels remove ``` -`channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. -Both offer to rebuild the sandbox so the image reflects the new channel set. -In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`, or any run without a terminal on stdin), the commands stage the change and leave the rebuild to a follow-up `$$nemoclaw rebuild`. -WeChat and WhatsApp are experimental. -Review [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them. +`channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. Both offer to rebuild the sandbox so the image reflects the new channel set. In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`, or any run without a terminal on stdin), the commands stage the change and leave the rebuild to a follow-up `$$nemoclaw rebuild`. WeChat and WhatsApp are experimental. Review [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them. -WeChat captures its bot token through a host-side QR scan during `$$nemoclaw onboard` or `channels add wechat`. -You scan the iLink QR from WeChat on your phone and NemoClaw registers the captured token with the OpenShell gateway. +WeChat captures its bot token through a host-side QR scan during `$$nemoclaw onboard` or `channels add wechat`. You scan the iLink QR from WeChat on your phone and NemoClaw registers the captured token with the OpenShell gateway. -WhatsApp pairs entirely inside the sandbox. -NemoClaw advertises WhatsApp for OpenClaw and Hermes sandboxes after you add the channel on the host. -Run `openclaw channels login --channel whatsapp` inside OpenClaw sandboxes, or run `hermes whatsapp` inside Hermes sandboxes. +WhatsApp pairs entirely inside the sandbox. NemoClaw advertises WhatsApp for OpenClaw and Hermes sandboxes after you add the channel on the host. Run `openclaw channels login --channel whatsapp` inside OpenClaw sandboxes, or run `hermes whatsapp` inside Hermes sandboxes. @@ -2762,13 +2191,9 @@ Run `openclaw channels login --channel whatsapp` inside OpenClaw sandboxes, or r ### `scripts/rcf_patch.py` is missing from the blueprint -`scripts/rcf_patch.py` is intentionally absent from current NemoClaw blueprints. -Older QA plans used that helper for a Dockerfile "Patch-4" test that corrupted the build-time `replaceConfigFile` monkey-patch and expected `ERROR: Patch 4 (replaceConfigFile EACCES) not applied`. -The old Patch-4 fail-closed test no longer applies because NemoClaw no longer patches OpenClaw's compiled `replaceConfigFile` source at image build time. +`scripts/rcf_patch.py` is intentionally absent from current NemoClaw blueprints. Older QA plans used that helper for a Dockerfile "Patch-4" test that corrupted the build-time `replaceConfigFile` monkey-patch and expected `ERROR: Patch 4 (replaceConfigFile EACCES) not applied`. The old Patch-4 fail-closed test no longer applies because NemoClaw no longer patches OpenClaw's compiled `replaceConfigFile` source at image build time. -Current sandboxes use a mutable-default config model instead. -Before a reviewed host-side lockdown, `/sandbox/.openclaw/openclaw.json` is group-writable by the sandbox and gateway users, so OpenClaw config mutations should write normally rather than requiring an EACCES swallow. -After lockdown, runtime config mutations should fail cleanly or route users to the supported host-side NemoClaw command. +Current sandboxes use a mutable-default config model instead. Before a reviewed host-side lockdown, `/sandbox/.openclaw/openclaw.json` is group-writable by the sandbox and gateway users, so OpenClaw config mutations should write normally rather than requiring an EACCES swallow. After lockdown, runtime config mutations should fail cleanly or route users to the supported host-side NemoClaw command. To validate this area now, use the config lifecycle tests instead of looking for `rcf_patch.py`: @@ -2779,10 +2204,7 @@ npm test -- test/runtime/sandbox/repro-2681-group-writable.test.ts ### `openclaw config set` or `unset` is blocked inside the sandbox -This is expected. -NemoClaw builds the sandbox's OpenClaw configuration (`/sandbox/.openclaw/openclaw.json`) from host-side onboarding, rebuild, inference, policy, and messaging inputs. -Fresh sandboxes keep that file writable by default so the agent can manage runtime state, but direct in-sandbox edits are not the supported or durable path for NemoClaw-managed settings. -NemoClaw's sandbox entrypoint installs a guard that intercepts `openclaw config set` and `openclaw config unset` and prints an actionable error, because changes made inside the running sandbox do not persist across rebuilds. +This is expected. NemoClaw builds the sandbox's OpenClaw configuration (`/sandbox/.openclaw/openclaw.json`) from host-side onboarding, rebuild, inference, policy, and messaging inputs. Fresh sandboxes keep that file writable by default so the agent can manage runtime state, but direct in-sandbox edits are not the supported or durable path for NemoClaw-managed settings. NemoClaw's sandbox entrypoint installs a guard that intercepts `openclaw config set` and `openclaw config unset` and prints an actionable error, because changes made inside the running sandbox do not persist across rebuilds. For most configuration changes, exit the sandbox and rerun onboarding: @@ -2790,8 +2212,7 @@ For most configuration changes, exit the sandbox and rerun onboarding: $$nemoclaw onboard ``` -If NemoClaw reports a resumable failed onboarding session, run `$$nemoclaw onboard --resume` instead. -This rebuilds the sandbox with your updated settings. +If NemoClaw reports a resumable failed onboarding session, run `$$nemoclaw onboard --resume` instead. This rebuilds the sandbox with your updated settings. For advanced live edits, use the host-side config command instead of running `openclaw config set` inside the sandbox: @@ -2803,21 +2224,17 @@ Host-side `config set` validates any HTTP or HTTPS URLs in the new value, includ ### `openclaw doctor --fix` cannot repair Discord channel config inside the sandbox -This is expected in NemoClaw-managed sandboxes. -NemoClaw bakes channel entries into `/sandbox/.openclaw/openclaw.json` at image build time. +This is expected in NemoClaw-managed sandboxes. NemoClaw bakes channel entries into `/sandbox/.openclaw/openclaw.json` at image build time. As a result, commands that try to rewrite the baked config from inside the sandbox, including `openclaw doctor --fix`, cannot repair Discord, Telegram, or Slack channel entries in place. -If your Discord channel config is wrong, rerun onboarding so NemoClaw rebuilds the sandbox image with the correct messaging selection. -Do not treat a failed `doctor --fix` run as proof that the Discord gateway path itself is broken. +If your Discord channel config is wrong, rerun onboarding so NemoClaw rebuilds the sandbox image with the correct messaging selection. Do not treat a failed `doctor --fix` run as proof that the Discord gateway path itself is broken. -If `openclaw doctor` reports that it moved Telegram single-account values under `channels.telegram.accounts.default`, rerun onboarding and rebuild the sandbox rather than trying to patch `openclaw.json` in place. -Current NemoClaw rebuilds bake Telegram in the account-based layout and set Telegram group chats to `groupPolicy: open`, which avoids the empty `groupAllowFrom` warning path for default group-chat access. +If `openclaw doctor` reports that it moved Telegram single-account values under `channels.telegram.accounts.default`, rerun onboarding and rebuild the sandbox rather than trying to patch `openclaw.json` in place. Current NemoClaw rebuilds bake Telegram in the account-based layout and set Telegram group chats to `groupPolicy: open`, which avoids the empty `groupAllowFrom` warning path for default group-chat access. ### `openclaw doctor --fix` tightened config permissions and the gateway can no longer save config -In a mutable NemoClaw sandbox, the gateway UID and the sandbox UID share the `sandbox` group, so `/sandbox/.openclaw` is setgid and group-writable (`2770`) and `openclaw.json` is group-writable (`660`). -OpenClaw's `openclaw doctor --fix` enforces its own single-user `700/600` layout, so running it inside the sandbox strips group write and breaks gateway-side config writes (for example, control-UI toggles that mutate `openclaw.json`). +In a mutable NemoClaw sandbox, the gateway UID and the sandbox UID share the `sandbox` group, so `/sandbox/.openclaw` is setgid and group-writable (`2770`) and `openclaw.json` is group-writable (`660`). OpenClaw's `openclaw doctor --fix` enforces its own single-user `700/600` layout, so running it inside the sandbox strips group write and breaks gateway-side config writes (for example, control-UI toggles that mutate `openclaw.json`). When you invoke the command through the host-side one-shot path, NemoClaw restores the mutable contract as the command exits: @@ -2825,10 +2242,7 @@ When you invoke the command through the host-side one-shot path, NemoClaw restor $$nemoclaw exec -- openclaw doctor --fix ``` -When cleanup succeeds, `exec` preserves the `openclaw doctor --fix` exit code. -If cleanup cannot inspect, restore, or verify the mutable config permission contract, it returns a cleanup failure instead and prints `OpenClaw permission cleanup failed (...)` with the command and cleanup statuses. -Do not recursively change ownership or permissions over a config tree that failed this safety check. -Inspect the reported condition and rebuild from trusted host-side configuration if the tree or image boundary is not the expected NemoClaw layout. +When cleanup succeeds, `exec` preserves the `openclaw doctor --fix` exit code. If cleanup cannot inspect, restore, or verify the mutable config permission contract, it returns a cleanup failure instead and prints `OpenClaw permission cleanup failed (...)` with the command and cleanup statuses. Do not recursively change ownership or permissions over a config tree that failed this safety check. Inspect the reported condition and rebuild from trusted host-side configuration if the tree or image boundary is not the expected NemoClaw layout. If you ran `openclaw doctor --fix` from an interactive `connect` shell or use an older sandbox image, repair the mutable contract without rebuilding: @@ -2836,12 +2250,7 @@ If you ran `openclaw doctor --fix` from an interactive `connect` shell or use an $$nemoclaw doctor --fix ``` -`$$nemoclaw doctor` reports the drift as a `Config permissions` warning, and `--fix` restores `2770/660`. -Restarting the sandbox repairs the same drift automatically when the config tree passes its safety checks, and NemoClaw's own `rebuild` re-applies the contract after its post-upgrade `openclaw doctor --fix` step. -For a persisted root-owned `700/600` tree, startup reclaims ownership only when both fixed config files have that posture under the expected sandbox-owned parent. -Other root-owned layouts, links, mounts, and ambiguous metadata fail closed so startup cannot mistake a shields-locked or unsafe tree for mutable drift. -If startup reports `[SECURITY] Refusing mutable config permission normalization`, NemoClaw stops startup without following or modifying the unsafe target; safe permission repairs completed before detection are not rolled back. -Rebuild with the current image and trusted host-side configuration instead of repairing the tree recursively. +`$$nemoclaw doctor` reports the drift as a `Config permissions` warning, and `--fix` restores `2770/660`. Restarting the sandbox repairs the same drift automatically when the config tree passes its safety checks, and NemoClaw's own `rebuild` re-applies the contract after its post-upgrade `openclaw doctor --fix` step. For a persisted root-owned `700/600` tree, startup reclaims ownership only when both fixed config files have that exact posture under the expected sandbox-owned parent. Other root-owned layouts, links, mounts, and ambiguous metadata fail closed so startup cannot mistake a shields-locked or unsafe tree for mutable drift. If startup reports `[SECURITY] Refusing mutable config permission normalization`, NemoClaw stops startup without following or modifying the unsafe target; safe permission repairs completed before detection are not rolled back. Rebuild with the current image and trusted host-side configuration instead of repairing the tree recursively. When verifying gateway write access by hand, step down to the gateway UID with the image's installed mechanism so the `sandbox` group membership is initialized: @@ -2849,8 +2258,7 @@ When verifying gateway write access by hand, step down to the gateway UID with t setpriv --reuid=gateway --regid=gateway --init-groups -- sh -c 'echo ok >> /sandbox/.openclaw/openclaw.json' ``` -If `setpriv` is unavailable, rebuild the sandbox from a NemoClaw-managed image that includes `util-linux`. -When a root entrypoint must change identity, it fails closed if this required privilege-separation command is missing. +If `setpriv` is unavailable, rebuild the sandbox from a NemoClaw-managed image that includes `util-linux`. When a root entrypoint must change identity, it fails closed if this required privilege-separation command is missing. Do not probe with `su -s /bin/sh gateway ...`: `su` does not initialize the gateway's supplementary groups the same way, so a group-write probe can spuriously report `EACCES` even when the mutable contract is intact. @@ -2866,14 +2274,11 @@ Separate the problem into two parts: 1. Baked config and provider wiring - Check that onboarding selected Discord and that the sandbox was created with the Discord messaging provider attached. - If Discord was skipped during onboarding, rerun onboarding and select Discord again. + Check that onboarding selected Discord and that the sandbox was created with the Discord messaging provider attached. If Discord was skipped during onboarding, rerun onboarding and select Discord again. 1. Native Discord gateway path - Successful login alone does not prove that Discord works end to end. - Discord also needs a working gateway connection to `gateway.discord.gg`. - If logs show errors such as `getaddrinfo EAI_AGAIN gateway.discord.gg`, repeated reconnect loops, or a `400` response while probing the gateway path, the problem is usually in the native gateway/proxy path rather than in the baked config. + Successful login alone does not prove that Discord works end to end. Discord also needs a working gateway connection to `gateway.discord.gg`. If logs show errors such as `getaddrinfo EAI_AGAIN gateway.discord.gg`, repeated reconnect loops, or a `400` response while probing the gateway path, the problem is usually in the native gateway/proxy path rather than in the baked config. Common signs of a native gateway-path failure: @@ -2891,11 +2296,9 @@ In that case: ### Discord preset validation behind a proxy -The built-in Discord policy preset intentionally allows the Node binaries used by the messaging runtime and does not allow `curl`. -As a result, `curl -s https://discord.com` failing, hanging, or printing no output is not proof that the Discord preset is broken. +The built-in Discord policy preset intentionally allows the Node binaries used by the messaging runtime and does not allow `curl`. As a result, `curl -s https://discord.com` failing, hanging, or printing no output is not proof that the Discord preset is broken. -Behind the OpenShell proxy, direct DNS-only checks can also be the wrong signal. -For example, `dns.resolve("gateway.discord.gg")` can fail even when HTTPS requests routed through the proxy are healthy. +Behind the OpenShell proxy, direct DNS-only checks can also be the wrong signal. For example, `dns.resolve("gateway.discord.gg")` can fail even when HTTPS requests routed through the proxy are healthy. Use Node HTTPS as the manual REST probe: @@ -2933,29 +2336,15 @@ https NODE ``` -Any HTTP status from these probes means the Node process reached the endpoint; the status can vary by unauthenticated path. -If the Node REST probe works but the Discord channel is still unhealthy, investigate the native gateway path instead of widening the preset. -Check the gateway logs and blocked-request output with `openshell term`, and look for `gateway.discord.gg` connection or WebSocket upgrade failures. +Any HTTP status from these probes means the Node process reached the endpoint; the exact status can vary by unauthenticated path. If the Node REST probe works but the Discord channel is still unhealthy, investigate the native gateway path instead of widening the preset. Check the gateway logs and blocked-request output with `openshell term`, and look for `gateway.discord.gg` connection or WebSocket upgrade failures. ### Messaging bridge appears running but no messages arrive -Telegram `getUpdates` allows only one active poller per bot token. -Reusing Discord or Slack credentials can create competing gateway or Socket Mode sessions and unreliable message delivery. -`$$nemoclaw status` can still report a bridge as running because the gateway process itself is alive. +Telegram `getUpdates` allows only one active poller per bot token. Reusing Discord or Slack credentials can create competing gateway or Socket Mode sessions and unreliable message delivery. `$$nemoclaw status` can still report a bridge as running because the gateway process itself is alive. -For Telegram group chats, first check BotFather privacy mode. -New Telegram bots default to privacy mode enabled, which prevents group messages from reaching `getUpdates` even when the user mentions the bot. -In @BotFather, run `/setprivacy`, choose the bot, and choose **Disable**. -Then remove the bot from the affected group and add it back; Telegram applies the privacy-mode change to group delivery only after the bot rejoins. +For Telegram group chats, first check BotFather privacy mode. New Telegram bots default to privacy mode enabled, which prevents group messages from reaching `getUpdates` even when the user mentions the bot. In @BotFather, run `/setprivacy`, choose the bot, and choose **Disable**. Then remove the bot from the affected group and add it back; Telegram applies the privacy-mode change to group delivery only after the bot rejoins. -For Telegram direct messages, make sure the rebuilt sandbox has a DM allowlist. -Set `TELEGRAM_ALLOWED_IDS` before rebuild; `TELEGRAM_AUTHORIZED_CHAT_IDS` and `TELEGRAM_CHAT_ID` are accepted as compatibility aliases. -Keep the aliases until QA automation and public repro templates have stopped exporting them for at least one full release. -Bot API `sendMessage` sends from the bot to a chat, so it only proves outbound Telegram API access. -To prove inbound agent routing, send a message from the Telegram client as an allowed user and then watch the gateway log for the agent turn and outbound reply. -For a reproducible outbound runtime check, run `NEMOCLAW_RUN_LIVE_E2E=1 npx vitest run --project e2e-live test/e2e/live/messaging-providers.test.ts --silent=false --reporter=default` with `NVIDIA_INFERENCE_API_KEY` set. -The check imports the installed OpenClaw Telegram `runtime-api.js`, calls `sendMessageTelegram` through an OpenShell-rewritten credential against a host-side fake Telegram API, and verifies the captured chat, text, token rewrite, and absence of unresolved placeholders. -When `TELEGRAM_BOT_TOKEN_REAL` and `TELEGRAM_CHAT_ID_E2E` are also set, the same lane performs an additional real outbound send; it does not prompt for or claim an interactive inbound reply. +For Telegram direct messages, make sure the rebuilt sandbox has a DM allowlist. Set `TELEGRAM_ALLOWED_IDS` before rebuild; `TELEGRAM_AUTHORIZED_CHAT_IDS` and `TELEGRAM_CHAT_ID` are accepted as compatibility aliases. Keep the aliases until QA automation and public repro templates have stopped exporting them for at least one full release. Bot API `sendMessage` sends from the bot to a chat, so it only proves outbound Telegram API access. To prove inbound agent routing, send a message from the Telegram client as an allowed user and then watch the gateway log for the agent turn and outbound reply. For a reproducible outbound runtime check, run `NEMOCLAW_RUN_LIVE_E2E=1 npx vitest run --project e2e-live test/e2e/live/messaging-providers.test.ts --silent=false --reporter=default` with `NVIDIA_INFERENCE_API_KEY` set. The check imports the installed OpenClaw Telegram `runtime-api.js`, calls `sendMessageTelegram` through an OpenShell-rewritten credential against a host-side fake Telegram API, and verifies the captured chat, text, token rewrite, and absence of unresolved placeholders. When `TELEGRAM_BOT_TOKEN_REAL` and `TELEGRAM_CHAT_ID_E2E` are also set, the same lane performs an additional real outbound send; it does not prompt for or claim an interactive inbound reply. To diagnose, open a shell in the sandbox and inspect the gateway log: @@ -2970,14 +2359,7 @@ A repeating line like the following confirms the conflict: [telegram] getUpdates conflict: 409: Conflict: terminated by other getUpdates request; retrying in 30s. ``` -To fix, run `$$nemoclaw destroy` on whichever sandbox should stop polling, or rerun onboarding on it with the channel disabled. -NemoClaw checks only the sandboxes in the selected OpenShell gateway's sandbox registry. -It cannot detect or prevent Slack credential reuse across independent OpenShell gateways. -Run only one active Slack sandbox on each OpenShell gateway. -Use distinct Slack bot and app tokens for Slack sandboxes on different OpenShell gateways. -Within the selected registry, onboarding, rebuild, and `channels add` abort on a conflict or an incomplete required check, including unavailable credential hashes. -Only `channels add --force` can accept the duplicate-consumer or shared-resource risk. -Sandboxes created before these checks were added, or managed by independent gateways, may still have a conflict without a NemoClaw warning. +To fix, run `$$nemoclaw destroy` on whichever sandbox should stop polling, or rerun onboarding on it with the channel disabled. NemoClaw checks only the sandboxes in the selected OpenShell gateway's sandbox registry. It cannot detect or prevent Slack credential reuse across independent OpenShell gateways. Run only one active Slack sandbox on each OpenShell gateway. Use distinct Slack bot and app tokens for Slack sandboxes on different OpenShell gateways. Within the selected registry, onboarding, rebuild, and `channels add` abort on a conflict or an incomplete required check, including unavailable credential hashes. Only `channels add --force` can accept the duplicate-consumer or shared-resource risk. Sandboxes created before these checks were added, or managed by independent gateways, may still have a conflict without a NemoClaw warning. @@ -2985,28 +2367,23 @@ Sandboxes created before these checks were added, or managed by independent gate ### Landlock filesystem restrictions silently degraded -After sandbox creation, NemoClaw checks whether the host kernel supports Landlock (Linux 5.13+). -If the kernel is too old or you are running on macOS (where the Docker VM kernel may lack Landlock), a warning prints: +After sandbox creation, NemoClaw checks whether the host kernel supports Landlock (Linux 5.13+). If the kernel is too old or you are running on macOS (where the Docker VM kernel may lack Landlock), a warning prints: ```text ⚠ Landlock: Docker VM kernel does not support Landlock (requires ≥5.13). Sandbox filesystem restrictions will silently degrade (best_effort mode). ``` -This warning is informational and does not block sandbox creation. -The sandbox runs without kernel-level filesystem restrictions, relying on container mount configuration instead. -For full filesystem enforcement, run on a Linux kernel 5.13 or later (Ubuntu 22.04 LTS and later include Landlock support). +This warning is informational and does not block sandbox creation. The sandbox runs without kernel-level filesystem restrictions, relying on container mount configuration instead. For full filesystem enforcement, run on a Linux kernel 5.13 or later (Ubuntu 22.04 LTS and later include Landlock support). ### Landlock filesystem policy blocks sandbox startup -Deep Agents uses strict Landlock compatibility. -If the host kernel, Docker VM, or sandbox filesystem mount cannot enforce the managed read-only policy, OpenShell refuses to start the sandbox instead of silently degrading. +Deep Agents uses strict Landlock compatibility. If the host kernel, Docker VM, or sandbox filesystem mount cannot enforce the managed read-only policy, OpenShell refuses to start the sandbox instead of silently degrading. -Run Deep Agents on a Linux kernel 5.13 or later with a container runtime that exposes Landlock to the sandbox. -After moving to a compatible host or runtime, rerun onboarding or rebuild the sandbox: +Run Deep Agents on a Linux kernel 5.13 or later with a container runtime that exposes Landlock to the sandbox. After moving to a compatible host or runtime, rerun onboarding or rebuild the sandbox: ```bash nemo-deepagents rebuild @@ -3016,28 +2393,23 @@ nemo-deepagents rebuild ### Sandbox lost after gateway restart -Sandboxes created with OpenShell versions older than 0.0.24 can become unreachable after a gateway restart because SSH secrets were not persisted. -Running `$$nemoclaw onboard` automatically upgrades OpenShell to 0.0.24 or later during the preflight check. -After the upgrade, recreate the sandbox with `$$nemoclaw onboard`. +Sandboxes created with OpenShell versions older than 0.0.24 can become unreachable after a gateway restart because SSH secrets were not persisted. Running `$$nemoclaw onboard` automatically upgrades OpenShell to 0.0.24 or later during the preflight check. After the upgrade, recreate the sandbox with `$$nemoclaw onboard`. ### DNS-backed HTTPS endpoint is not supported -NemoClaw rejects an explicit custom endpoint when it resolves a public HTTPS hostname but cannot pin the same peer address across the downstream OpenShell runtime boundary while preserving TLS SNI and host validation. -This can appear during a direct blueprint run, custom-endpoint onboarding, or a host-side `config set` write. +NemoClaw rejects an explicit custom endpoint when it resolves a public HTTPS hostname but cannot pin the same peer address across the downstream OpenShell runtime boundary while preserving TLS SNI and host validation. This can appear during a direct blueprint run, custom-endpoint onboarding, or a host-side `config set` write. + -It does not appear during a runtime `$$nemoclaw inference set` switch on an already-onboarded sandbox; that command routes a DNS-backed HTTPS endpoint through a local HTTPS Pin Runtime adapter instead of rejecting it. -Refer to [Commands](commands) for details. + It does not appear during a runtime `$$nemoclaw inference set` switch on an already-onboarded + sandbox; that command routes a DNS-backed HTTPS endpoint through a local HTTPS Pin Runtime adapter + instead of rejecting it. Refer to [Commands](commands) for details. -Use an HTTPS IP-literal endpoint whose certificate is valid for that address. -If your deployment permits non-TLS provider traffic, you can instead use a public HTTP endpoint that NemoClaw can rewrite to a DNS-pinned address. -Do not bypass the check with a private or internal address or by editing the persisted sandbox config directly. -For the full endpoint rules, refer to [Meet Custom Endpoint Security Requirements](../inference/custom-endpoints/custom-endpoint-security). +Use an HTTPS IP-literal endpoint whose certificate is valid for that address. If your deployment permits non-TLS provider traffic, you can instead use a public HTTP endpoint that NemoClaw can rewrite to a DNS-pinned address. Do not bypass the check with a private or internal address or by editing the persisted sandbox config directly. For the full endpoint rules, refer to [Meet Custom Endpoint Security Requirements](../inference/custom-endpoints/custom-endpoint-security). ### Agent cannot reach external hosts through a proxy -NemoClaw uses a default proxy address of `10.200.0.1:3128` (the OpenShell-injected gateway). -If your environment uses a different proxy, set `NEMOCLAW_PROXY_HOST` and `NEMOCLAW_PROXY_PORT` before onboarding: +NemoClaw uses a default proxy address of `10.200.0.1:3128` (the OpenShell-injected gateway). If your environment uses a different proxy, set `NEMOCLAW_PROXY_HOST` and `NEMOCLAW_PROXY_PORT` before onboarding: ```bash export NEMOCLAW_PROXY_HOST=proxy.example.com @@ -3045,27 +2417,17 @@ export NEMOCLAW_PROXY_PORT=8080 $$nemoclaw onboard ``` -These are build-time settings baked into the sandbox image. -Changing them after onboarding requires re-running `$$nemoclaw onboard` to rebuild the image. +These are build-time settings baked into the sandbox image. Changing them after onboarding requires re-running `$$nemoclaw onboard` to rebuild the image. -When `HTTP_PROXY` or `HTTPS_PROXY` is set on the host, NemoClaw adds `localhost`, `127.0.0.1`, `::1`, `0.0.0.0`, the container-host aliases `host.docker.internal` and `host.containers.internal`, and the managed inference hostname `inference.local` to `NO_PROXY` for host-side subprocesses and for the env forwarded into `openshell sandbox create`. -This keeps local Ollama health checks, model pulls, and managed inference traffic from being chained through a corporate or desktop proxy at the sandbox-create boundary, while preserving the proxy for external hosts. -For the local provider validation probe, NemoClaw removes `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY` from the probe process and sets `NO_PROXY=*` instead. -A host proxy therefore cannot answer for the local endpoint, including the `host.docker.internal` alias used for Windows-host Ollama. -Inside the running sandbox, processes continue to use the OpenShell L7 proxy for `inference.local` so OpenShell's internal routing, DNS, and audit boundaries stay intact. +When `HTTP_PROXY` or `HTTPS_PROXY` is set on the host, NemoClaw adds `localhost`, `127.0.0.1`, `::1`, `0.0.0.0`, the container-host aliases `host.docker.internal` and `host.containers.internal`, and the managed inference hostname `inference.local` to `NO_PROXY` for host-side subprocesses and for the env forwarded into `openshell sandbox create`. This keeps local Ollama health checks, model pulls, and managed inference traffic from being chained through a corporate or desktop proxy at the sandbox-create boundary, while preserving the proxy for external hosts. For the local provider validation probe, NemoClaw removes `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY` from the probe process and sets `NO_PROXY=*` instead. A host proxy therefore cannot answer for the local endpoint, including the `host.docker.internal` alias used for Windows-host Ollama. Inside the running sandbox, processes continue to use the OpenShell L7 proxy for `inference.local` so OpenShell's internal routing, DNS, and audit boundaries stay intact. ### Agent cannot reach a host-side HTTP service -When a sandbox needs to call an HTTP service running on the host, use the normal OpenShell network policy path. -Expose the service on a host IP address that the OpenShell gateway can reach, create a custom NemoClaw policy preset for that IP and port, and apply it with `$$nemoclaw policy add --from-file`. -The sandbox request then flows through the OpenShell proxy while NemoClaw preserves the existing live policy entries. +When a sandbox needs to call an HTTP service running on the host, use the normal OpenShell network policy path. Expose the service on a host IP address that the OpenShell gateway can reach, create a custom NemoClaw policy preset for that IP and port, and apply it with `$$nemoclaw policy add --from-file`. The sandbox request then flows through the OpenShell proxy while NemoClaw preserves the existing live policy entries. -Do not rely on `host.docker.internal` or `host.openshell.internal` as a general-purpose host-service path. -Those names may appear in the sandbox's `/etc/hosts`, but in OpenShell's sandbox network they are not guaranteed to point at a reachable host gateway. -Bypassing the proxy with `--noproxy '*'` also bypasses network policy enforcement and audit. +Do not rely on `host.docker.internal` or `host.openshell.internal` as a general-purpose host-service path. Those names may appear in the sandbox's `/etc/hosts`, but in OpenShell's sandbox network they are not guaranteed to point at a reachable host gateway. Bypassing the proxy with `--noproxy '*'` also bypasses network policy enforcement and audit. -First, make sure the host-side service listens on a non-loopback address. -For example, a health endpoint on port `50001` should be reachable from the host IP, not only from `127.0.0.1`: +First, make sure the host-side service listens on a non-loopback address. For example, a health endpoint on port `50001` should be reachable from the host IP, not only from `127.0.0.1`: ```bash curl -s http://10.0.0.5:50001/health @@ -3074,11 +2436,10 @@ curl -s http://10.0.0.5:50001/health Expected output: ```json -{"status":"ok"} +{ "status": "ok" } ``` -Then create a custom NemoClaw preset for the host-side service. -Replace `10.0.0.5`, `50001`, paths, methods, and binaries with the service you want the sandbox to reach: +Then create a custom NemoClaw preset for the host-side service. Replace `10.0.0.5`, `50001`, paths, methods, and binaries with the service you want the sandbox to reach: ```yaml preset: @@ -3113,40 +2474,37 @@ curl -s http://10.0.0.5:50001/health Expected output: ```json -{"status":"ok"} +{ "status": "ok" } ``` -If the request is still denied, check the blocked request in `openshell term`. -The policy `binaries` list must include the executable path that actually made the request. -If the response changes from `policy_denied` to `upstream_unreachable`, the policy matched, but the OpenShell gateway could not reach the host IP and port. +If the request is still denied, check the blocked request in `openshell term`. The policy `binaries` list must include the executable path that actually made the request. If the response changes from `policy_denied` to `upstream_unreachable`, the policy matched, but the OpenShell gateway could not reach the host IP and port. ### Agent cannot reach an external host -OpenShell blocks outbound connections to hosts not listed in the network policy. -Open the TUI to see blocked requests and approve them: +OpenShell blocks outbound connections to hosts not listed in the network policy. Open the TUI to see blocked requests and approve them: ```bash openshell term ``` To permanently allow an endpoint, add it to the network policy. + -Refer to [Customize the Network Policy](../network-policy/customize-network-policy) for details. + Refer to [Customize the Network Policy](../network-policy/customize-network-policy) for details. -For Deep Agents, follow [Customize the Network Policy](../network-policy/customize-network-policy) to choose between built-in presets, reviewed custom presets, baseline edits, and live-policy replacement. + For Deep Agents, follow [Customize the Network Policy](../network-policy/customize-network-policy) + to choose between built-in presets, reviewed custom presets, baseline edits, and live-policy + replacement. ### Dashboard not reachable after setting a custom port -If you ran `$$nemoclaw onboard` with a custom dashboard port and onboarding completed but the dashboard URL is unreachable, the sandbox was most likely created with an older NemoClaw version that did not pass the dashboard port into the sandbox at startup. -The browser may show connection refused or fail to load the page. -The gateway inside the sandbox continued listening on the default port 18789 while the SSH tunnel forwarded the custom port, leaving nothing at the other end of the tunnel. +If you ran `$$nemoclaw onboard` with a custom dashboard port and onboarding completed but the dashboard URL is unreachable, the sandbox was most likely created with an older NemoClaw version that did not pass the dashboard port into the sandbox at startup. The browser may show connection refused or fail to load the page. The gateway inside the sandbox continued listening on the default port 18789 while the SSH tunnel forwarded the custom port, leaving nothing at the other end of the tunnel. -Re-run onboarding on the current NemoClaw release with the desired port. -Current versions derive the dashboard port from `CHAT_UI_URL` automatically and inject it into the sandbox: +Re-run onboarding on the current NemoClaw release with the desired port. Current versions derive the dashboard port from `CHAT_UI_URL` automatically and inject it into the sandbox: ```bash CHAT_UI_URL=http://127.0.0.1:19000 $$nemoclaw onboard @@ -3160,24 +2518,18 @@ If you need to run multiple sandboxes at different ports at the same time, refer ### Control UI config endpoint returns 404 or non-JSON -The Control UI loads its runtime configuration from a gateway endpoint, not from a static -`controlui.bootstrap.config.json` file. No `controlui.bootstrap.config.json` path is served, -so requesting it returns `HTTP 404 Not Found` with a short plain-text body, and piping that -response to `jq` fails with a parse error such as `Invalid numeric literal`. +The Control UI loads its runtime configuration from a gateway endpoint, not from a static `controlui.bootstrap.config.json` file. No `controlui.bootstrap.config.json` path is served, so requesting it returns `HTTP 404 Not Found` with a short plain-text body, and piping that response to `jq` fails with a parse error such as `Invalid numeric literal`. -The supported Control UI config endpoint is `/__openclaw/control-ui-config.json`, served by -the OpenClaw gateway on the forwarded dashboard port. It is gated by the gateway auth token: +The supported Control UI config endpoint is `/__openclaw/control-ui-config.json`, served by the OpenClaw gateway on the forwarded dashboard port. It is gated by the gateway auth token: -- An unauthenticated request returns `HTTP 401 Unauthorized` with a JSON body - (`{"error":{"message":"Unauthorized","type":"unauthorized"}}`), which is already valid JSON. +- An unauthenticated request returns `HTTP 401 Unauthorized` with a JSON body (`{"error":{"message":"Unauthorized","type":"unauthorized"}}`), which is already valid JSON. - An authenticated request returns `HTTP 200 OK` with the Control UI config as JSON. -Resolve the forwarded dashboard port, then authenticate with the gateway token from -`$$nemoclaw gateway-token`: +Resolve the forwarded dashboard port, then authenticate with the gateway token from `$$nemoclaw gateway-token`: ```bash openshell forward list # note the dashboard PORT for the sandbox @@ -3188,25 +2540,18 @@ curl -fsS -H "Authorization: Bearer $TOKEN" \ && echo "Control UI config is valid JSON" ``` -The token is sensitive; treat it like a password and do not log, share, or commit it. For -browser access, use the tokenized URL from `$$nemoclaw dashboard-url` instead of -calling the config endpoint directly. +The token is sensitive; treat it like a password and do not log, share, or commit it. For browser access, use the tokenized URL from `$$nemoclaw dashboard-url` instead of calling the config endpoint directly. -Hermes manages its own dashboard sessions and does not expose an OpenClaw gateway auth token -or a `/__openclaw/control-ui-config.json` endpoint. Use `nemohermes status` to see the -dashboard and API endpoints for a Hermes sandbox. +Hermes manages its own dashboard sessions and does not expose an OpenClaw gateway auth token or a `/__openclaw/control-ui-config.json` endpoint. Use `nemohermes status` to see the dashboard and API endpoints for a Hermes sandbox. ### Ollama auth proxy did not start -NemoClaw keeps Ollama bound to `127.0.0.1:11434` and starts a token-gated -reverse proxy on `0.0.0.0:11435` so the sandbox can reach Ollama without -exposing it to the local network. -If the proxy fails to start, onboarding exits before configuring inference. +NemoClaw keeps Ollama bound to `127.0.0.1:11434` and starts a token-gated reverse proxy on `0.0.0.0:11435` so the sandbox can reach Ollama without exposing it to the local network. If the proxy fails to start, onboarding exits before configuring inference. Check whether the proxy port is occupied by another process: @@ -3214,37 +2559,28 @@ Check whether the proxy port is occupied by another process: sudo lsof -i :11435 ``` -Stop the conflicting process and re-run `$$nemoclaw onboard`. -The wizard cleans up stale proxy processes from previous runs automatically, so most failures resolve by retrying. +Stop the conflicting process and re-run `$$nemoclaw onboard`. The wizard cleans up stale proxy processes from previous runs automatically, so most failures resolve by retrying. If the proxy refuses to start because the backend also listens on a non-loopback interface, use the remediation that matches the reported backend: - For Ollama, bind the daemon to the reported loopback port with `OLLAMA_HOST=127.0.0.1:`, then restart Ollama and rerun onboarding. -- For an unauthenticated OpenAI-compatible endpoint, bind that endpoint server to a loopback address only on the reported port, then rerun onboarding. - Do not apply the Ollama setting to vLLM, llama-server, or another compatible endpoint. -- If recovery cannot identify the backend type, bind the reported service and port to a loopback address only, then rerun onboarding. - The neutral diagnostic intentionally does not name Ollama. +- For an unauthenticated OpenAI-compatible endpoint, bind that endpoint server to a loopback address only on the reported port, then rerun onboarding. Do not apply the Ollama setting to vLLM, llama-server, or another compatible endpoint. +- If recovery cannot identify the backend type, bind the reported service and port to a loopback address only, then rerun onboarding. The neutral diagnostic intentionally does not name Ollama. -In every case, the refusal prevents a backend listener from bypassing the protected route's token check. -For an IPv6 endpoint, keep it on its IPv6 loopback address instead of changing it to `127.0.0.1`. +In every case, the refusal prevents a backend listener from bypassing the protected route's token check. For an IPv6 endpoint, keep it on its IPv6 loopback address instead of changing it to `127.0.0.1`. -The proxy token is persisted to `~/.nemoclaw/ollama-proxy-token` with `0600` -permissions. -If the file is missing or unreadable after a host reboot, re-running -`$$nemoclaw onboard` regenerates it. +The proxy token is persisted to `~/.nemoclaw/ollama-proxy-token` with `0600` permissions. If the file is missing or unreadable after a host reboot, re-running `$$nemoclaw onboard` regenerates it. ### Ollama auth proxy is unreachable from the sandbox -On native Linux Docker-driver hosts, a host firewall can allow the host proxy check but block sandbox traffic to the Ollama auth proxy. -When that happens, onboarding exits before it saves the inference route and prints output like: +On native Linux Docker-driver hosts, a host firewall can allow the host proxy check but block sandbox traffic to the Ollama auth proxy. When that happens, onboarding exits before it saves the inference route and prints output like: ```text ✗ Sandbox containers cannot reach the Ollama auth proxy at host.openshell.internal:11435. A host firewall may be blocking traffic from the OpenShell Docker bridge. ``` -Apply the `ufw` command printed by onboarding, then rerun onboarding. -If the message does not include a subnet, derive it from the OpenShell Docker network: +Apply the `ufw` command printed by onboarding, then rerun onboarding. If the message does not include a subnet, derive it from the OpenShell Docker network: ```bash SUBNET=$(docker network inspect openshell-docker --format '{{(index .IPAM.Config 0).Subnet}}') @@ -3252,15 +2588,11 @@ sudo ufw allow from "$SUBNET" to any port 11435 proto tcp $$nemoclaw onboard ``` -Docker Desktop, WSL, and hosts without the OpenShell Docker network use different routing models. -In those cases NemoClaw treats an unavailable sandbox-side probe as non-blocking and relies on the regular proxy health check. +Docker Desktop, WSL, and hosts without the OpenShell Docker network use different routing models. In those cases NemoClaw treats an unavailable sandbox-side probe as non-blocking and relies on the regular proxy health check. ### `host.docker.internal` does not reliably reach the host from the sandbox -Configuring an inference provider with a base URL like `http://host.docker.internal:11434/v1` does not reliably reach a host Ollama service from inside the OpenShell sandbox. -OpenShell runs sandboxes inside a k3s network, where `host.docker.internal` is not a portable host-service route. -Depending on the platform, it may fail DNS resolution or resolve to an internal gateway/bridge address where the host's port `11434` is not forwarded. -The sandbox then sees a DNS failure or `connection refused`: +Configuring an inference provider with a base URL like `http://host.docker.internal:11434/v1` does not reliably reach a host Ollama service from inside the OpenShell sandbox. OpenShell runs sandboxes inside a k3s network, where `host.docker.internal` is not a portable host-service route. Depending on the platform, it may fail DNS resolution or resolve to an internal gateway/bridge address where the host's port `11434` is not forwarded. The sandbox then sees a DNS failure or `connection refused`: ```bash getent hosts host.docker.internal @@ -3282,28 +2614,17 @@ Expected output: * connect to 172.17.0.1 port 11434 failed: Connection refused ``` -For local Ollama, use the auth-proxy URL that NemoClaw's "Local Ollama" onboard -option configures automatically: +For local Ollama, use the auth-proxy URL that NemoClaw's "Local Ollama" onboard option configures automatically: ```text http://host.openshell.internal:11435/v1 ``` -`host.openshell.internal` resolves to the same gateway IP, and the -[token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) binds port -`11435` there and forwards requests to `127.0.0.1:11434` on the host. -If you need a different host service exposed to the sandbox, route it through -the OpenShell gateway rather than relying on `host.docker.internal`. -Refer to issue [#3136](https://github.com/NVIDIA/NemoClaw/issues/3136). +`host.openshell.internal` resolves to the same gateway IP, and the [token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) binds port `11435` there and forwards requests to `127.0.0.1:11434` on the host. If you need a different host service exposed to the sandbox, route it through the OpenShell gateway rather than relying on `host.docker.internal`. Refer to issue [#3136](https://github.com/NVIDIA/NemoClaw/issues/3136). ### Local inference health check resolves to IPv6 -Local inference health checks now use `127.0.0.1` instead of `localhost`. -On systems where `localhost` resolves to `::1` first, older NemoClaw releases -could probe the wrong address and report the local backend as unreachable -even when it was running. -If you see this on a current NemoClaw release, verify that the local backend -binds an IPv4 address and not only `::1`. +Local inference health checks now use `127.0.0.1` instead of `localhost`. On systems where `localhost` resolves to `::1` first, older NemoClaw releases could probe the wrong address and report the local backend as unreachable even when it was running. If you see this on a current NemoClaw release, verify that the local backend binds an IPv4 address and not only `::1`. ### Blueprint run failed @@ -3322,9 +2643,7 @@ For an end-to-end walkthrough with local inference on DGX Spark, refer to the [N ### Host freezes or logs `NVRM NV_ERR_NO_MEMORY` under local vLLM load -Treat a full host freeze separately from an agent tool-call hang. -If the Spark stops responding to SSH and ping, and the journal contains `NVRM NV_ERR_NO_MEMORY` or no software-side crash record, first isolate the local inference server before changing MCP or network policy configuration. -For onboarding-time context, refer to [Use an Existing Server](../inference/local-inference/set-up-vllm#use-an-existing-server). +Treat a full host freeze separately from an agent tool-call hang. If the Spark stops responding to SSH and ping, and the journal contains `NVRM NV_ERR_NO_MEMORY` or no software-side crash record, first isolate the local inference server before changing MCP or network policy configuration. For onboarding-time context, refer to [Use an Existing Server](../inference/local-inference/set-up-vllm#use-an-existing-server). Check whether vLLM is a bring-your-own server or the NemoClaw managed Spark profile: @@ -3336,8 +2655,7 @@ free -h journalctl -k --since "24 hours ago" --no-pager | grep -Ei 'NVRM|OOM|out of memory|lockup|watchdog' ``` -For a NemoClaw-managed Spark profile, derive the host port from the managed container's fixed `8000/tcp` mapping. -Bearer-protected profiles publish two bindings with the same host port, while bearerless profiles publish one all-interface binding. +For a NemoClaw-managed Spark profile, derive the host port from the managed container's fixed `8000/tcp` mapping. Bearer-protected profiles publish two bindings with the same host port, while bearerless profiles publish one all-interface binding. ```bash VLLM_HOST_PORT="$( @@ -3373,8 +2691,7 @@ For an existing vLLM server, inspect its launch arguments: docker inspect --format '{{json .Config.Cmd}}' ``` -Large checkpoints without explicit quantization, very long `--max-model-len` values, high `--gpu-memory-utilization`, and multiple concurrent sequences all consume the Spark's shared CPU/GPU memory pool. -Before reintroducing agent tools, restart vLLM with a smaller envelope, for example: +Large checkpoints without explicit quantization, very long `--max-model-len` values, high `--gpu-memory-utilization`, and multiple concurrent sequences all consume the Spark's shared CPU/GPU memory pool. Before reintroducing agent tools, restart vLLM with a smaller envelope, for example: ```bash vllm serve \ @@ -3384,21 +2701,15 @@ vllm serve \ --max-num-batched-tokens 4096 ``` -If the host still logs `NVRM NV_ERR_NO_MEMORY` while loading the model, switch to a smaller or quantized checkpoint. -For managed setup, prefer `NEMOCLAW_PROVIDER=install-vllm`, which selects the Spark profile and its registered model-specific serve arguments. -After standalone vLLM is stable, re-run onboarding and add MCP servers back one group at a time. +If the host still logs `NVRM NV_ERR_NO_MEMORY` while loading the model, switch to a smaller or quantized checkpoint. For managed setup, prefer `NEMOCLAW_PROVIDER=install-vllm`, which selects the Spark profile and its registered model-specific serve arguments. After standalone vLLM is stable, re-run onboarding and add MCP servers back one group at a time. ### CoreDNS CrashLoop after onboarding -If CoreDNS in the embedded k3s cluster crashes shortly after setup, it is usually because it resolves against `127.0.0.11`, which does not route inside the gateway container. -Run `fix-coredns.sh` to point CoreDNS at the container gateway IP instead, then recreate the sandbox. +If CoreDNS in the embedded k3s cluster crashes shortly after setup, it is usually because it resolves against `127.0.0.11`, which does not route inside the gateway container. Run `fix-coredns.sh` to point CoreDNS at the container gateway IP instead, then recreate the sandbox. ### `k3s` cannot find a freshly built image -After building a new sandbox image, `k3s` inside the gateway container sometimes fails to pull it even though the image exists on the host. -Remove the gateway registration, then resume onboarding. -If a privileged host gateway remains, do not use a host-wide process match. -Verify its live owner, gateway name and port, command line, PID file, runtime marker, and loaded sandbox namespace immediately before you stop it. +After building a new sandbox image, `k3s` inside the gateway container sometimes fails to pull it even though the image exists on the host. Remove the gateway registration, then resume onboarding. If a privileged host gateway remains, do not use a host-wide process match. Verify its live owner, exact gateway name and port, command line, PID file, runtime marker, and loaded sandbox namespace immediately before you stop it. ```bash openshell gateway remove nemoclaw @@ -3407,26 +2718,11 @@ $$nemoclaw onboard --resume ### GPU passthrough on Spark -GPU passthrough is not CI-tested on DGX Spark. -It is expected to work when you pass `--gpu` and the NVIDIA Container Toolkit is configured. -Verify the toolkit is configured by running `docker run --rm --runtime=nvidia --gpus all nvidia/cuda:12.8.0-base-ubuntu24.04 nvidia-smi` from the host. -If `nvidia-smi` works on the host but onboarding says GPU passthrough was not enabled, install or repair the NVIDIA Container Toolkit, then run `sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker`. -If a reusable gateway was previously started without GPU passthrough, NemoClaw replaces it automatically only when no other registered sandboxes depend on it, or when `--recreate-sandbox` is recreating the only registered sandbox with the same name. -When shared gateway cleanup would be unsafe, follow the targeted destroy or gateway-removal commands printed by onboarding. +GPU passthrough is not CI-tested on DGX Spark. It is expected to work when you pass `--gpu` and the NVIDIA Container Toolkit is configured. Verify the toolkit is configured by running `docker run --rm --runtime=nvidia --gpus all nvidia/cuda:12.8.0-base-ubuntu24.04 nvidia-smi` from the host. If `nvidia-smi` works on the host but onboarding says GPU passthrough was not enabled, install or repair the NVIDIA Container Toolkit, then run `sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker`. If a reusable gateway was previously started without GPU passthrough, NemoClaw replaces it automatically only when no other registered sandboxes depend on it, or when `--recreate-sandbox` is recreating the only registered sandbox with the same name. When shared gateway cleanup would be unsafe, follow the targeted destroy or gateway-removal commands printed by onboarding. ### `unresolvable CDI devices nvidia.com/gpu=all` during gateway start -Recent NVIDIA Container Toolkit installs configure the Docker daemon for Container Device Interface (CDI) device injection, which a GPU-enabled gateway start then auto-selects. -If no `nvidia.com/gpu` CDI spec has been generated on the host yet, gateway start fails with `Docker responded with status code 500: CDI device injection failed: unresolvable CDI devices nvidia.com/gpu=all`. -Outside Station Express, the standard NemoClaw installer detects this gap before onboarding, first tries to enable the NVIDIA CDI refresh systemd units, and can fall back to generating the spec directly with `nvidia-ctk`. -Station Express never falls back to direct CDI generation. -The generic Ubuntu, Colossus BaseOS, and AI Developer Tools paths require the packaged refresh lifecycle to work; if it fails or omits `nvidia.com/gpu=all`, inspect `nvidia-cdi-refresh.service`, repair it, and rerun the printed install command pinned to that commit. -Other factory-runtime profiles stop when the CDI device is missing without enabling or restarting the refresh units. -If you run `$$nemoclaw onboard` directly, preflight prints the manual remediation instead. -The native Linux fix is the same on Docker hosts whose `docker info` advertises a non-empty `CDISpecDirs`. -On WSL with Docker Desktop, Docker may advertise CDI directories even though `--device nvidia.com/gpu=all` is not usable from the WSL distro. -For that runtime, NemoClaw skips Linux CDI repair and uses Docker's `--gpus` compatibility path for sandbox GPU access. -This compatibility path can be retired once Docker Desktop exposes usable `nvidia.com/gpu` CDI specs inside WSL, or once OpenShell no longer requires host-visible CDI specs for Docker Desktop WSL GPU passthrough. +Recent NVIDIA Container Toolkit installs configure the Docker daemon for Container Device Interface (CDI) device injection, which a GPU-enabled gateway start then auto-selects. If no `nvidia.com/gpu` CDI spec has been generated on the host yet, gateway start fails with `Docker responded with status code 500: CDI device injection failed: unresolvable CDI devices nvidia.com/gpu=all`. Outside Station Express, the standard NemoClaw installer detects this gap before onboarding, first tries to enable the NVIDIA CDI refresh systemd units, and can fall back to generating the spec directly with `nvidia-ctk`. Station Express never falls back to direct CDI generation. The generic Ubuntu, Colossus BaseOS, and exact AI Developer Tools paths require the packaged refresh lifecycle to work; if it fails or omits `nvidia.com/gpu=all`, inspect `nvidia-cdi-refresh.service`, repair it, and rerun the printed exact-commit install command. Other factory-runtime profiles stop when the CDI device is missing without enabling or restarting the refresh units. If you run `$$nemoclaw onboard` directly, preflight prints the manual remediation instead. The native Linux fix is the same on Docker hosts whose `docker info` advertises a non-empty `CDISpecDirs`. On WSL with Docker Desktop, Docker may advertise CDI directories even though `--device nvidia.com/gpu=all` is not usable from the WSL distro. For that runtime, NemoClaw skips Linux CDI repair and uses Docker's `--gpus` compatibility path for sandbox GPU access. This compatibility path can be retired once Docker Desktop exposes usable `nvidia.com/gpu` CDI specs inside WSL, or once OpenShell no longer requires host-visible CDI specs for Docker Desktop WSL GPU passthrough. Enable the refresh units, verify they list `nvidia.com/gpu` entries, then rerun onboarding: @@ -3454,12 +2750,9 @@ If GPU passthrough is not required on this host, rerun onboarding with `--no-gpu ### GPU routing or compatibility patch failed -The route depends on the host environment and the operator control. -Identify the matching path before applying the recovery guidance. +The route depends on the host environment and the operator control. Identify the matching path before applying the recovery guidance. -Do not apply this compatibility guidance to portable onboarding. -Portable onboarding requires native OpenShell GPU injection for every agent and does not use `NEMOCLAW_DOCKER_GPU_PATCH`. -Do not set `fallback`, `1`, or another legacy nonzero value for a portable run. +Do not apply this compatibility guidance to portable onboarding. Portable onboarding requires native OpenShell GPU injection for every agent and does not use `NEMOCLAW_DOCKER_GPU_PATCH`. Do not set `fallback`, `1`, or another legacy nonzero value for a portable run. | Symptom | Route or stage | Recovery | | --- | --- | --- | @@ -3468,58 +2761,23 @@ Do not set `fallback`, `1`, or another legacy nonzero value for a portable run. | The patched container exits or the compatibility attempt fails | Compatibility recreation | Inspect the saved diagnostics and the rollback outcome, then repair the NVIDIA Container Toolkit/CDI configuration. Keep the sandbox when the pre-patch container was restored. Use only a container-specific cleanup command printed after rollback. If no command was printed, inspect the sandbox and its labeled containers before removing anything. Then rerun onboarding. | | A recreated container inherits only a loopback DNS stub and no usable upstream | Compatibility DNS fallback | Repair the host's `systemd-resolved` upstream configuration, then rerun onboarding. | -For bridge-networked compatibility recreation without an explicit container DNS setting, NemoClaw selects a usable IPv4 upstream from `systemd-resolved` and probes that `--dns` path before it stops the original container. -If the probe confirms that the resolver is unreachable, recreation stops and leaves the original container in place. -An IPv6-only upstream list does not become a compatibility override; NemoClaw preserves Docker's default resolver path instead. -Containers with explicit DNS settings or host networking keep their existing DNS path and do not use the fallback probe. +For bridge-networked compatibility recreation without an explicit container DNS setting, NemoClaw selects a usable IPv4 upstream from `systemd-resolved` and probes that exact `--dns` path before it stops the original container. If the probe confirms that the resolver is unreachable, recreation stops and leaves the original container in place. An IPv6-only upstream list does not become a compatibility override; NemoClaw preserves Docker's default resolver path instead. Containers with explicit DNS settings or host networking keep their existing DNS path and do not use the fallback probe. #### Ordinary native Linux bounded fallback -Ordinary Linux GPU onboarding uses native OpenShell GPU injection and stops on failure by default. -Unset, `auto`, and `0` all preserve this native-only confinement boundary. -`NEMOCLAW_DOCKER_GPU_PATCH=fallback` is the explicit operator authorization for one bounded retry. -With that control set, if sandbox creation rejects the native GPU flag before progress, the OpenShell-managed container labeled for that sandbox records a host runtime GPU-injection error, or an explicit `nvidia-smi` driver proof fails while that container's immutable host configuration confirms that no GPU was attached, NemoClaw captures redacted diagnostics, deletes the incomplete sandbox, verifies that no OpenShell-managed Docker container labeled for that sandbox remains, and retries exactly once through the compatibility path. -Free-form build/list text and sandbox-reported CUDA output never independently authorize the broader retry. -Without corroborating host evidence, onboarding fails closed even when `fallback` is set and directs the operator to clean up and explicitly select compatibility with `NEMOCLAW_DOCKER_GPU_PATCH=1` if desired. -Before the authorized retry, NemoClaw warns that the legacy GPU compatibility envelope recreates the OpenShell-managed Docker container and may relax container confinement compared with native injection. -Specifically, compatibility recreation adds `SYS_PTRACE`, adds `apparmor=unconfined` when the original container has no AppArmor option, and uses a compatibility policy that makes `/proc` writable for the NVIDIA runtime's process-name initialization. -These broader settings are why onboarding warns before the swap and retains a native-only opt-out. -NemoClaw verifies cleanup with two stable checks (that sandbox is absent from the gateway list and no OpenShell-managed Docker containers labeled for that sandbox remain) before retrying through the compatibility path. -Cleanup is polled at most five times, one second apart, and both conditions must pass twice consecutively; otherwise onboarding stops before the retry. -These fail-closed safety limits are the internal constants `STABLE_ABSENCE_CHECKS` (2), `MAX_CLEANUP_ATTEMPTS` (5), and `CLEANUP_POLL_INTERVAL_MS` (1,000 ms); they are not configurable through environment variables. -The first observation is immediate, so the default bound performs at most four one-second sleeps plus the five gateway/container queries. -The bounds are intentionally fixed. -Allowing environment input to weaken or extend the cleanup proof would make a security gate deployment-dependent. -On a host that cannot prove absence within the bound, onboarding fails closed; select compatibility from the outset with `NEMOCLAW_DOCKER_GPU_PATCH=1` instead of weakening the handoff proof. -If deletion or container cleanup cannot be proven safe, onboarding stops before the retry and prints manual cleanup guidance. -Image build, upload, TLS, provider, policy, dashboard, and inference failures stay on their existing error paths and do not trigger the GPU compatibility fallback. -Set `NEMOCLAW_DOCKER_GPU_PATCH=1` to use only the compatibility path for diagnostics or older host compatibility. -Other legacy nonzero values keep that behavior through the `v0.0.x` release line and will be removed in `v0.1.0`. +Ordinary Linux GPU onboarding uses native OpenShell GPU injection and stops on failure by default. Unset, `auto`, and `0` all preserve this native-only confinement boundary. `NEMOCLAW_DOCKER_GPU_PATCH=fallback` is the explicit operator authorization for one bounded retry. With that control set, if sandbox creation rejects the native GPU flag before progress, the exact OpenShell-managed container labeled for that sandbox records a host runtime GPU-injection error, or an explicit `nvidia-smi` driver proof fails while that container's immutable host configuration confirms that no GPU was attached, NemoClaw captures redacted diagnostics, deletes the incomplete sandbox, verifies that no OpenShell-managed Docker container labeled for that sandbox remains, and retries exactly once through the compatibility path. Free-form build/list text and sandbox-reported CUDA output never independently authorize the broader retry. Without corroborating host evidence, onboarding fails closed even when `fallback` is set and directs the operator to clean up and explicitly select compatibility with `NEMOCLAW_DOCKER_GPU_PATCH=1` if desired. Before the authorized retry, NemoClaw warns that the legacy GPU compatibility envelope recreates the OpenShell-managed Docker container and may relax container confinement compared with native injection. Specifically, compatibility recreation adds `SYS_PTRACE`, adds `apparmor=unconfined` when the original container has no AppArmor option, and uses a compatibility policy that makes `/proc` writable for the NVIDIA runtime's process-name initialization. These broader settings are why onboarding warns before the swap and retains a native-only opt-out. NemoClaw verifies cleanup with two stable checks (that sandbox is absent from the gateway list and no OpenShell-managed Docker containers labeled for that sandbox remain) before retrying through the compatibility path. Cleanup is polled at most five times, one second apart, and both conditions must pass twice consecutively; otherwise onboarding stops before the retry. These fail-closed safety limits are the internal constants `STABLE_ABSENCE_CHECKS` (2), `MAX_CLEANUP_ATTEMPTS` (5), and `CLEANUP_POLL_INTERVAL_MS` (1,000 ms); they are not configurable through environment variables. The first observation is immediate, so the default bound performs at most four one-second sleeps plus the five gateway/container queries. The bounds are intentionally fixed. Allowing environment input to weaken or extend the cleanup proof would make a security gate deployment-dependent. On a host that cannot prove absence within the bound, onboarding fails closed; select compatibility from the outset with `NEMOCLAW_DOCKER_GPU_PATCH=1` instead of weakening the handoff proof. If deletion or container cleanup cannot be proven safe, onboarding stops before the retry and prints manual cleanup guidance. Image build, upload, TLS, provider, policy, dashboard, and inference failures stay on their existing error paths and do not trigger the GPU compatibility fallback. Set `NEMOCLAW_DOCKER_GPU_PATCH=1` to use only the compatibility path for diagnostics or older host compatibility. Other legacy nonzero values keep that behavior through the `v0.0.x` release line and will be removed in `v0.1.0`. #### Docker Desktop WSL compatibility route -Automatic GPU onboarding uses the compatibility path directly; it does not make a native attempt first. -The path creates the sandbox and then recreates the OpenShell-managed Docker container with NVIDIA GPU flags. -`NEMOCLAW_DOCKER_GPU_PATCH=0` is ignored because this runtime requires the compatibility patch for GPU passthrough, and onboarding logs a warning when it is set. -To skip GPU passthrough entirely, rerun with `--no-gpu` or set `NEMOCLAW_SANDBOX_GPU=0`. +Automatic GPU onboarding uses the compatibility path directly; it does not make a native attempt first. The path creates the sandbox and then recreates the OpenShell-managed Docker container with NVIDIA GPU flags. `NEMOCLAW_DOCKER_GPU_PATCH=0` is ignored because this runtime requires the compatibility patch for GPU passthrough, and onboarding logs a warning when it is set. To skip GPU passthrough entirely, rerun with `--no-gpu` or set `NEMOCLAW_SANDBOX_GPU=0`. #### Jetson and Tegra compatibility default -Automatic GPU onboarding uses the compatibility path directly; it does not make a native attempt first. -The path recreates the OpenShell-managed Docker container with NVIDIA GPU flags and propagates eligible host group IDs for the supported Jetson GPU device nodes. -Use `NEMOCLAW_DOCKER_GPU_PATCH=0` only for troubleshooting because it bypasses that group propagation and CUDA may not initialize. +Automatic GPU onboarding uses the compatibility path directly; it does not make a native attempt first. The path recreates the OpenShell-managed Docker container with NVIDIA GPU flags and propagates eligible host group IDs for the supported Jetson GPU device nodes. Use `NEMOCLAW_DOCKER_GPU_PATCH=0` only for troubleshooting because it bypasses that group propagation and CUDA may not initialize. #### Common compatibility-path recovery -After compatibility recreation starts, onboarding keeps the pre-patch container as a rollback backup until the replacement passes the Ready, direct GPU, and applicable local-inference checks. -If a later check fails, onboarding prints failure diagnostics and attempts to restore the pre-patch container before it exits. -When rollback succeeds, the pre-patch sandbox remains available. -If the failed replacement may remain and NemoClaw retains its validated container ID, it prints only a container-specific `docker rm -f` command. -When a replacement is not stably running, the failure diagnostic includes its runtime ID, inspected state, and a bounded redacted log tail when available. -If replacement cleanup cannot be confirmed without a validated ID, onboarding reports cleanup as unknown and prints no deletion command. -When rollback fails, onboarding reports that the sandbox and container state is uncertain and prints no deletion command. -A diagnostic bundle captured before rollback records cleanup as pending and contains no deletion command. -Inspect the diagnostics, the sandbox, and its labeled Docker containers before removing anything. +After compatibility recreation starts, onboarding keeps the pre-patch container as a rollback backup until the replacement passes the Ready, direct GPU, and applicable local-inference checks. If a later check fails, onboarding prints failure diagnostics and attempts to restore the pre-patch container before it exits. When rollback succeeds, the pre-patch sandbox remains available. If the failed replacement may remain and NemoClaw retains its validated exact container ID, it prints only an exact-container `docker rm -f` command. When a replacement is not stably running, the failure diagnostic includes its exact runtime ID, inspected state, and a bounded redacted log tail when available. If replacement cleanup cannot be confirmed without a validated exact ID, onboarding reports cleanup as unknown and prints no deletion command. When rollback fails, onboarding reports that the sandbox and container state is uncertain and prints no deletion command. A diagnostic bundle captured before rollback records cleanup as pending and contains no deletion command. Inspect the diagnostics, the sandbox, and its labeled Docker containers before removing anything. Starting with NemoClaw v0.0.43, the standard installer handles the `/proc//task//comm` permission case during this patch path. @@ -3532,27 +2790,15 @@ When inspection confirms that the failed sandbox remains, delete it with a comma openshell sandbox delete ``` -Fix the NVIDIA Container Toolkit or CDI configuration reported in the diagnostics. -Run the deletion command only after confirming that the pre-patch sandbox was not restored, then rerun onboarding. -If you do not need GPU access inside the sandbox, rerun with `--no-sandbox-gpu`. +Fix the NVIDIA Container Toolkit or CDI configuration reported in the diagnostics. Run the deletion command only after confirming that the pre-patch sandbox was not restored, then rerun onboarding. If you do not need GPU access inside the sandbox, rerun with `--no-sandbox-gpu`. -If sandbox creation fails with `CDI device injection failed: unresolvable CDI devices nvidia.com/gpu=all`, the OpenShell gateway tried `docker create --device nvidia.com/gpu=all` and Docker could not resolve the CDI spec. -This injection happens inside the gateway, so `NEMOCLAW_DOCKER_GPU_PATCH=0` does not bypass it. -Rerun with `--no-gpu`, or set `NEMOCLAW_SANDBOX_GPU=0` and resume onboarding. +If sandbox creation fails with `CDI device injection failed: unresolvable CDI devices nvidia.com/gpu=all`, the OpenShell gateway tried `docker create --device nvidia.com/gpu=all` and Docker could not resolve the CDI spec. This injection happens inside the gateway, so `NEMOCLAW_DOCKER_GPU_PATCH=0` does not bypass it. Rerun with `--no-gpu`, or set `NEMOCLAW_SANDBOX_GPU=0` and resume onboarding. -If onboarding reports `OpenShell supervisor did not reconnect to the GPU-enabled container.` even though the diagnostic bundle shows the patched container is running and healthy, the supervisor-reconnect wait is treating a transient Error phase (reported while the OpenShell host re-registers the new container) as fatal. -The reconnect wait debounces consecutive Error-phase polls before fast-failing, defaulting to 60 consecutive polls of about 120 seconds in total. -Increase the debounce window with `NEMOCLAW_DOCKER_GPU_SUPERVISOR_RECONNECT_ERROR_DEBOUNCE` if your host needs more time to re-register the patched container, for example slow WSL2 + Docker Desktop setups. -Set it to an integer above the default of 60, such as `120` (about 240 seconds), and rerun onboarding; the value is clamped to a minimum of `1`. -If reconnect still fails after the GPU patch, NemoClaw attempts to restore the pre-patch CPU container before exiting. -When rollback succeeds, the output says the pre-patch sandbox was restored. -When rollback fails, the error says rollback failed and the pre-patch container was not restored, so inspect Docker state before retrying. +If onboarding reports `OpenShell supervisor did not reconnect to the GPU-enabled container.` even though the diagnostic bundle shows the patched container is running and healthy, the supervisor-reconnect wait is treating a transient Error phase (reported while the OpenShell host re-registers the new container) as fatal. The reconnect wait debounces consecutive Error-phase polls before fast-failing, defaulting to 60 consecutive polls of about 120 seconds in total. Increase the debounce window with `NEMOCLAW_DOCKER_GPU_SUPERVISOR_RECONNECT_ERROR_DEBOUNCE` if your host needs more time to re-register the patched container, for example slow WSL2 + Docker Desktop setups. Set it to an integer above the default of 60, such as `120` (about 240 seconds), and rerun onboarding; the value is clamped to a minimum of `1`. If reconnect still fails after the GPU patch, NemoClaw attempts to restore the pre-patch CPU container before exiting. When rollback succeeds, the output says the pre-patch sandbox was restored. When rollback fails, the error says rollback failed and the pre-patch container was not restored, so inspect Docker state before retrying. ### `pip install` fails with a system-packages error -Recent Ubuntu releases (including DGX Spark's Ubuntu 24.04) mark the system Python install as externally managed, so `pip install` without a virtual environment fails. -Use a venv instead. -Avoid `--break-system-packages` unless you understand the risk, since it can break host tooling. +Recent Ubuntu releases (including DGX Spark's Ubuntu 24.04) mark the system Python install as externally managed, so `pip install` without a virtual environment fails. Use a venv instead. Avoid `--break-system-packages` unless you understand the risk, since it can break host tooling. ```bash python3 -m venv ~/.venvs/nemoclaw @@ -3562,8 +2808,7 @@ pip install ... ### Port 3000 conflict with AI Workbench -NVIDIA AI Workbench's Traefik proxy binds ports 3000 and 10000. -If you run other services on Spark that expect port 3000, bind them to a different port. +NVIDIA AI Workbench's Traefik proxy binds ports 3000 and 10000. If you run other services on Spark that expect port 3000, bind them to a different port. ## Windows Subsystem for Linux @@ -3572,37 +2817,21 @@ For environment setup steps, refer to [Windows Prerequisites](../get-started/add ### `wsl --install --no-distribution` returns Forbidden (403) -Check your network connectivity. -If you are behind a VPN, try reconnecting or switching to a different network. -If your network or Windows image blocks the online WSL installer, install WSL manually with Microsoft's [offline install guidance](https://learn.microsoft.com/en-us/windows/wsl/install#offline-install). -Download the latest official WSL `.msi` package from the [Microsoft WSL releases page](https://github.com/microsoft/WSL/releases/latest), choose the matching `.x64.msi` or `.arm64.msi`, install it, reboot if Windows requests it, then rerun `wsl --status`. +Check your network connectivity. If you are behind a VPN, try reconnecting or switching to a different network. If your network or Windows image blocks the online WSL installer, install WSL manually with Microsoft's [offline install guidance](https://learn.microsoft.com/en-us/windows/wsl/install#offline-install). Download the latest official WSL `.msi` package from the [Microsoft WSL releases page](https://github.com/microsoft/WSL/releases/latest), choose the matching `.x64.msi` or `.arm64.msi`, install it, reboot if Windows requests it, then rerun `wsl --status`. ### Bootstrap says "Windows Subsystem for Linux is not fully installed" -The bootstrap script checks `wsl --status` before it installs or opens Ubuntu. -If Windows reports that the WSL runtime is not installed, the script attempts `wsl --install --no-distribution` automatically. -If the repair command succeeds and WSL reports that the changes require a reboot, reboot and let the bootstrap resume after sign-in. -If the repair command succeeds but WSL still cannot be verified and the output does not request a reboot, follow the printed repair guidance instead of rebooting by default. -If the repair command fails, follow the printed repair steps. -If the repair command returns `Forbidden (403)` or remains blocked, install WSL manually with Microsoft's [offline install guidance](https://learn.microsoft.com/en-us/windows/wsl/install#offline-install). -Download the latest official WSL `.msi` package from the [Microsoft WSL releases page](https://github.com/microsoft/WSL/releases/latest), choose the matching `.x64.msi` or `.arm64.msi`, install it, reboot if Windows requests it, then rerun the bootstrap script. -Use the same repair flow if the bootstrap says "Windows Subsystem for Linux could not be verified" and reports a nonzero `wsl --status` exit code. +The bootstrap script checks `wsl --status` before it installs or opens Ubuntu. If Windows reports that the WSL runtime is not installed, the script attempts `wsl --install --no-distribution` automatically. If the repair command succeeds and WSL reports that the changes require a reboot, reboot and let the bootstrap resume after sign-in. If the repair command succeeds but WSL still cannot be verified and the output does not request a reboot, follow the printed repair guidance instead of rebooting by default. If the repair command fails, follow the printed repair steps. If the repair command returns `Forbidden (403)` or remains blocked, install WSL manually with Microsoft's [offline install guidance](https://learn.microsoft.com/en-us/windows/wsl/install#offline-install). Download the latest official WSL `.msi` package from the [Microsoft WSL releases page](https://github.com/microsoft/WSL/releases/latest), choose the matching `.x64.msi` or `.arm64.msi`, install it, reboot if Windows requests it, then rerun the bootstrap script. Use the same repair flow if the bootstrap says "Windows Subsystem for Linux could not be verified" and reports a nonzero `wsl --status` exit code. ### Bootstrap says "Windows reports that WSL 2 cannot start yet" -The bootstrap script attempts `wsl --install --no-distribution` automatically when `wsl --status` reports that WSL 2 cannot start. -If the repair command succeeds, reboot when prompted and let the bootstrap resume after sign-in. -If the message persists after repair and reboot, enable virtualization in firmware and confirm that the Virtual Machine Platform optional component is enabled. -Manual WSL installation only helps when the WSL runtime is missing or the online installer is blocked. +The bootstrap script attempts `wsl --install --no-distribution` automatically when `wsl --status` reports that WSL 2 cannot start. If the repair command succeeds, reboot when prompted and let the bootstrap resume after sign-in. If the message persists after repair and reboot, enable virtualization in firmware and confirm that the Virtual Machine Platform optional component is enabled. Manual WSL installation only helps when the WSL runtime is missing or the online installer is blocked. ### `wsl -d Ubuntu` says "There is no distribution with the supplied name" -The Ubuntu package was installed with `--no-launch` but never registered, or Windows finished the install command before the distribution appeared in `wsl -l`. -When this happens during the NemoClaw bootstrap, the script prints a sanitized `WSL install output` block. -PowerShell transcript headers, footers, temporary transcript paths, and status-file paths are redacted before display so you can paste the useful WSL output into a bug report with less local machine metadata. +The Ubuntu package was installed with `--no-launch` but never registered, or Windows finished the install command before the distribution appeared in `wsl -l`. When this happens during the NemoClaw bootstrap, the script prints a sanitized `WSL install output` block. PowerShell transcript headers, footers, temporary transcript paths, and status-file paths are redacted before display so you can paste the useful WSL output into a bug report with less local machine metadata. -If the sanitized output says a reboot is required, reboot and rerun the bootstrap. -If it does not request a reboot, register the distro manually or reinstall without `--no-launch`: +If the sanitized output says a reboot is required, reboot and rerun the bootstrap. If it does not request a reboot, register the distro manually or reinstall without `--no-launch`: ```bash wsl --unregister Ubuntu @@ -3611,20 +2840,13 @@ wsl --install -d Ubuntu ### Bootstrap says a Docker executable "is not signed by a trusted publisher" -The bootstrap script runs elevated, so before it launches `Docker Desktop.exe` or uses `docker.exe`, it checks the resolved executable's Authenticode signature and refuses to run one that is not validly signed by Docker. -The script accepts `Docker Inc` as the certificate subject common name. -If Docker changes the signer identity, the script refuses the executable until maintainers verify the signer on an official Docker download and update the allowlist. -For a current-user installation, the administrator child completes the system changes and returns to the original non-elevated PowerShell process before the script starts Docker Desktop or uses its CLI. -If you started the script from an elevated PowerShell window, rerun it from a normal PowerShell window so it can use the current-user installation without administrator privileges. -Reinstall Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop/) or `winget install --id Docker.DockerDesktop`, then rerun the bootstrap script. -If reinstalling does not clear the warning, treat the existing executable as untrusted and do not run it manually either. +The bootstrap script runs elevated, so before it launches `Docker Desktop.exe` or uses `docker.exe`, it checks the resolved executable's Authenticode signature and refuses to run one that is not validly signed by Docker. The script accepts `Docker Inc` as the certificate subject common name. If Docker changes the signer identity, the script refuses the executable until maintainers verify the signer on an official Docker download and update the allowlist. For a current-user installation, the administrator child completes the system changes and returns to the original non-elevated PowerShell process before the script starts Docker Desktop or uses its CLI. If you started the script from an elevated PowerShell window, rerun it from a normal PowerShell window so it can use the current-user installation without administrator privileges. Reinstall Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop/) or `winget install --id Docker.DockerDesktop`, then rerun the bootstrap script. If reinstalling does not clear the warning, treat the existing executable as untrusted and do not run it manually either. The script continues after this warning instead of stopping, so the [`docker info` fails inside WSL](#docker-info-fails-inside-wsl) symptom below can appear a few minutes later even though the real cause is the untrusted executable, not WSL integration. ### `docker info` fails inside WSL -Confirm that Docker Desktop is running and that WSL integration is enabled for Ubuntu (Settings > Resources > WSL integration). -Then restart WSL: +Confirm that Docker Desktop is running and that WSL integration is enabled for Ubuntu (Settings > Resources > WSL integration). Then restart WSL: ```bash wsl --shutdown @@ -3634,8 +2856,7 @@ docker info ### Windows-host Ollama is installed but not shown during onboarding -When NemoClaw runs inside WSL, it checks both the Windows-host Ollama HTTP endpoint and the Windows `ollama.exe` process. -If Ollama is installed but the daemon is not reachable through `host.docker.internal:11434`, the wizard should still offer a start or restart action. +When NemoClaw runs inside WSL, it checks both the Windows-host Ollama HTTP endpoint and the Windows `ollama.exe` process. If Ollama is installed but the daemon is not reachable through `host.docker.internal:11434`, the wizard should still offer a start or restart action. If the Windows-host option does not appear, confirm that PowerShell interop is enabled in WSL and that Windows can locate Ollama: @@ -3643,8 +2864,7 @@ If the Windows-host option does not appear, confirm that PowerShell interop is e powershell.exe -NoProfile -Command "Get-Process ollama -ErrorAction SilentlyContinue" ``` -If the process is missing, start Ollama from Windows and rerun onboarding. -If the process exists but the endpoint is unreachable, use the restart action when the wizard offers it, or restart Ollama from Windows with `OLLAMA_HOST=0.0.0.0:11434`. +If the process is missing, start Ollama from Windows and rerun onboarding. If the process exists but the endpoint is unreachable, use the restart action when the wizard offers it, or restart Ollama from Windows with `OLLAMA_HOST=0.0.0.0:11434`. ### Ollama inference fails or hangs in WSL @@ -3652,10 +2872,7 @@ Ollama configures context length based on your hardware. -On some GPUs (for example RTX 3500), the default context length is not sufficient for OpenClaw. -During onboarding, NemoClaw raises loaded-model context lengths below `16384` to `16384` when `NEMOCLAW_CONTEXT_WINDOW` is unset. -Set the variable manually when you need a different value or when you run Ollama outside the managed onboarding path. -Force a larger context length: +On some GPUs (for example RTX 3500), the default context length is not sufficient for OpenClaw. During onboarding, NemoClaw raises loaded-model context lengths below `16384` to `16384` when `NEMOCLAW_CONTEXT_WINDOW` is unset. Set the variable manually when you need a different value or when you run Ollama outside the managed onboarding path. Force a larger context length: ```bash pkill -f 'ollama serve' @@ -3666,19 +2883,7 @@ OLLAMA_CONTEXT_LENGTH=16384 ollama serve -Hermes requires at least `64000` tokens. -During onboarding, NemoClaw verifies the loaded model's actual `context_length` through Ollama's `/api/ps` endpoint. -Resumed onboarding and sandbox rebuilds warm the recorded Ollama model and repeat this check before reusing its route. -Resume stops when that recorded model is missing, Ollama is unreachable, model warm-up fails, or the runtime context cannot be verified. -If you set `NEMOCLAW_CONTEXT_WINDOW` above `64000`, the loaded model must provide at least that larger value. -If the runtime value is below the requirement, NemoClaw queries Ollama's `/api/show` endpoint for the selected model's native context window. -If the model's native context window is below the requirement, onboarding stops and tells you to select a model that meets the reported requirement. -`OLLAMA_CONTEXT_LENGTH` cannot raise a model above its native context window. -If the model can meet the requirement, or NemoClaw cannot read its native context window, onboarding instead shows the required `OLLAMA_CONTEXT_LENGTH` value for restarting the host daemon. -A missing or malformed runtime value also produces the daemon restart guidance. -`NEMOCLAW_CONTEXT_WINDOW` controls Hermes prompt budgeting; it does not raise the model's native context window or the Ollama daemon's runtime context, and it does not bypass this check. -Use the daemon restart command only when onboarding shows a required `OLLAMA_CONTEXT_LENGTH` value. -The example uses the Hermes minimum; replace `64000` with the larger value from onboarding when applicable. +Hermes requires at least `64000` tokens. During onboarding, NemoClaw verifies the loaded model's actual `context_length` through Ollama's `/api/ps` endpoint. Resumed onboarding and sandbox rebuilds warm the exact recorded Ollama model and repeat this check before reusing its route. Resume stops when that recorded model is missing, Ollama is unreachable, model warm-up fails, or the runtime context cannot be verified. If you set `NEMOCLAW_CONTEXT_WINDOW` above `64000`, the loaded model must provide at least that larger value. If the runtime value is below the requirement, NemoClaw queries Ollama's `/api/show` endpoint for the selected model's native context window. If the model's native context window is below the requirement, onboarding stops and tells you to select a model that meets the reported requirement. `OLLAMA_CONTEXT_LENGTH` cannot raise a model above its native context window. If the model can meet the requirement, or NemoClaw cannot read its native context window, onboarding instead shows the required `OLLAMA_CONTEXT_LENGTH` value for restarting the host daemon. A missing or malformed runtime value also produces the daemon restart guidance. `NEMOCLAW_CONTEXT_WINDOW` controls Hermes prompt budgeting; it does not raise the model's native context window or the Ollama daemon's runtime context, and it does not bypass this check. Use the daemon restart command only when onboarding shows a required `OLLAMA_CONTEXT_LENGTH` value. The example uses the Hermes minimum; replace `64000` with the larger value from onboarding when applicable. ```bash pkill -f 'ollama serve' @@ -3722,22 +2927,19 @@ OLLAMA_CONTEXT_LENGTH=64000 ollama serve For additional troubleshooting, refer to the [Windows Setup](../get-started/additional-setup/windows-preparation) page. + -For first-time OpenClaw setup, refer to the [Quickstart](../get-started/quickstart). + For first-time OpenClaw setup, refer to the [Quickstart](../get-started/quickstart). -For first-time Hermes setup, refer to [Quickstart with Hermes](../get-started/quickstart). + For first-time Hermes setup, refer to [Quickstart with Hermes](../get-started/quickstart). ## Podman -Podman is not a tested runtime. -OpenShell officially documents Docker-based runtimes only. -If you encounter issues with Podman, switch to a tested runtime (Docker Engine, Docker Desktop, or Colima) and rerun onboarding. +Podman is not a tested runtime. OpenShell officially documents Docker-based runtimes only. If you encounter issues with Podman, switch to a tested runtime (Docker Engine, Docker Desktop, or Colima) and rerun onboarding. -The portable experimental profile uses the `docker` command to drive rootless Podman. -Before you run this profile, make sure a Docker-compatible CLI is available on `PATH`. -On a Podman-only host, install the `podman-docker` shim for your distribution: +The portable experimental profile uses the `docker` command to drive rootless Podman. Before you run this profile, make sure a Docker-compatible CLI is available on `PATH`. On a Podman-only host, install the `podman-docker` shim for your distribution: ```bash # Debian or Ubuntu @@ -5025,18 +4227,11 @@ The output must not list any NemoClaw CPU-controller drop-in. Another administra ### Portable Podman Readiness Fails -Portable commands use the current user's rootless Podman socket authority recorded in NemoClaw state. -They ignore ambient Docker and Podman runtime selectors, including named connections. -Do not export another `DOCKER_HOST`, `DOCKER_CONTEXT`, `CONTAINER_HOST`, or `CONTAINER_CONNECTION` to bypass a readiness failure. +Portable commands use the current user's rootless Podman socket authority recorded in NemoClaw state. They ignore ambient Docker and Podman runtime selectors, including named connections. Do not export another `DOCKER_HOST`, `DOCKER_CONTEXT`, `CONTAINER_HOST`, or `CONTAINER_CONNECTION` to bypass a readiness failure. -When `podman.service` reports inactive and the recorded socket exists, NemoClaw first makes one 10-second API request through the guarded recorded authority. -A valid server version classifies the endpoint as warm and avoids starting another socket service. -A missing socket or a response without a valid server version enters bounded cold activation. -Any socket authority change during this precheck fails at the socket authority stage and is not eligible for inode requalification. +When `podman.service` reports inactive and the recorded socket exists, NemoClaw first makes one 10-second API request through the guarded recorded authority. A valid server version classifies the endpoint as warm and avoids starting another socket service. A missing socket or a response without a valid server version enters bounded cold activation. Any socket authority change during this precheck fails at the socket authority stage and is not eligible for inode requalification. -During cold activation, the first API probe can cause systemd to replace the socket inode. -NemoClaw requalifies one such replacement and repeats the probe only when the socket path, device, mode, owner, and complete directory authority remain unchanged. -Any other authority change or a second inode replacement fails at the socket authority stage. +During cold activation, the first API probe can cause systemd to replace the socket inode. NemoClaw requalifies one such replacement and repeats the probe only when the socket path, device, mode, owner, and complete directory authority remain unchanged. Any other authority change or a second inode replacement fails at the socket authority stage. A portable readiness failure identifies the stage that did not complete: @@ -5047,8 +4242,7 @@ A portable readiness failure identifies the stage that did not complete: | Startup API health | The service was activated, but the recorded endpoint did not return a real Podman API response within the startup period. | Inspect the user-unit logs and run the explicit API request below against the reported socket path. Raise `NEMOCLAW_PORTABLE_PODMAN_STARTUP_TIMEOUT_MS` only when valid cold activation needs more than 60,000 ms. | | Steady-state API health | An endpoint that completed activation did not answer the later shorter health check. | Inspect host load and the user-unit logs, then rerun the NemoClaw command. | -The remaining service and API inspection applies only when the failure reports a recorded socket path. -If the failure does not report one, follow the recovery in the table and do not try another endpoint. +The remaining service and API inspection applies only when the failure reports a recorded socket path. If the failure does not report one, follow the recovery in the table and do not try another endpoint. When a recorded socket path is reported, inspect the current user's units without changing them: @@ -5075,9 +4269,7 @@ podman --remote \ --format 'Server Version: {{.Server.Version}}' ``` -Continue only when the command exits with status `0` and prints a nonempty server version. -The request and the readiness report contain no credentials. -Rerun the original NemoClaw command without exporting a Docker or Podman runtime selector. +Continue only when the command exits with status `0` and prints a nonempty server version. The request and the readiness report contain no credentials. Rerun the original NemoClaw command without exporting a Docker or Podman runtime selector. If valid cold activation needs a larger budget, set an integer from `15000` through `300000` milliseconds: @@ -5086,15 +4278,11 @@ export NEMOCLAW_PORTABLE_PODMAN_STARTUP_TIMEOUT_MS=120000 $$nemoclaw ``` -The default cold-start budget is 60,000 ms. -The later steady-state API deadline is fixed at 10,000 ms and does not use this setting. -A successful cold path uses the `cold` timing label and reports activation, API, and total time in milliseconds. -A successful warm path uses the `warm` timing label and reports steady-state API and total time in milliseconds. +The default cold-start budget is 60,000 ms. The later steady-state API deadline is fixed at 10,000 ms and does not use this setting. A successful cold path uses the `cold` timing label and reports activation, API, and total time in milliseconds. A successful warm path uses the `warm` timing label and reports steady-state API and total time in milliseconds. ### Portable Host Gateway Is Unreachable -The portable experimental profile maps `host.openshell.internal` to the OpenShell Podman host gateway. -Do not apply the Docker bridge UFW command when portable onboarding reports this route as unreachable. +The portable experimental profile maps `host.openshell.internal` to the OpenShell Podman host gateway. Do not apply the Docker bridge UFW command when portable onboarding reports this route as unreachable. Use the same procedure when onboarding reports that the Podman service is unreachable for the portable gateway probe. @@ -5105,18 +4293,14 @@ Portable onboarding reports output like this: The probe mapped host.openshell.internal to the OpenShell Podman host gateway. ``` -If `podman.service` is active, restart it. -Then start the user-scoped Podman socket for the current user session: +If `podman.service` is active, restart it. Then start the user-scoped Podman socket for the current user session: ```bash systemctl --user try-restart podman.service systemctl --user start podman.socket ``` -The first command does not start an inactive service. -The second command starts the current user's Podman API socket without enabling it for later user sessions. -These commands affect only the current user's Podman units. -They do not read or write credentials. +The first command does not start an inactive service. The second command starts the current user's Podman API socket without enabling it for later user sessions. These commands affect only the current user's Podman units. They do not read or write credentials. Verify that the socket is active: @@ -5130,8 +4314,7 @@ Expected output: active ``` -An active socket alone does not establish API health. -Run the explicit Podman API request from [Portable Podman Readiness Fails](#portable-podman-readiness-fails) before you rerun onboarding. +An active socket alone does not establish API health. Run the explicit Podman API request from [Portable Podman Readiness Fails](#portable-podman-readiness-fails) before you rerun onboarding. Then rerun portable onboarding: @@ -5151,14 +4334,11 @@ Continue only when onboarding no longer reports that the Podman service or OpenS ## Hermes -The issues below are common problems you may encounter when running Hermes through `nemohermes`. -For setup, refer to [Quickstart with Hermes](../../hermes/get-started/quickstart). +The issues below are common problems you may encounter when running Hermes through `nemohermes`. For setup, refer to [Quickstart with Hermes](../../hermes/get-started/quickstart). ### Hermes dashboard config did not converge -`nemohermes inference set` updates the OpenShell route, registry, and `/sandbox/.hermes/config.yaml` before it refreshes the separate dashboard profile. -If the dashboard profile exists but NemoClaw cannot confirm that `/sandbox/.hermes/profiles/dashboard-home/config.yaml` was updated, the command exits nonzero without printing `Inference route synced`. -The committed route and main Hermes config are not rolled back. +`nemohermes inference set` updates the OpenShell route, registry, and `/sandbox/.hermes/config.yaml` before it refreshes the separate dashboard profile. If the dashboard profile exists but NemoClaw cannot confirm that `/sandbox/.hermes/profiles/dashboard-home/config.yaml` was updated, the command exits nonzero without printing `Inference route synced`. The committed route and main Hermes config are not rolled back. Restart the sandbox so startup mirrors the committed model route into the dashboard profile: @@ -5167,17 +4347,11 @@ nemohermes stop nemohermes start ``` -Then run `nemohermes inference get` and verify Dashboard Chat uses the selected model. -If the command succeeds because the dashboard profile is missing, the dashboard is disabled and no dashboard recovery is required. +Then run `nemohermes inference get` and verify Dashboard Chat uses the selected model. If the command succeeds because the dashboard profile is missing, the dashboard is disabled and no dashboard recovery is required. ### Shields Reports Drift for the Hermes Configuration Root -The Hermes configuration root holds the agent's top-level runtime state, not only its configuration. -The configuration root stores `auth.json`, the drain request, and temporary files for atomic `gateway_state.json` and `gateway.pid` replacement. -Lockdown moves that directory to `root:sandbox` mode `3770`, which preserves the set-group-ID and sticky bits. -The gateway can still manage its runtime files. -The sticky bit prevents the sandbox identity from unlinking or renaming sealed root-owned configuration. -Run the following command to inspect a locked root: +The Hermes configuration root holds the agent's top-level runtime state, not only its configuration. The configuration root stores `auth.json`, the drain request, and temporary files for atomic `gateway_state.json` and `gateway.pid` replacement. Lockdown moves that directory to `root:sandbox` mode `3770`, which preserves the set-group-ID and sticky bits. The gateway can still manage its runtime files. The sticky bit prevents the sandbox identity from unlinking or renaming sealed root-owned configuration. Run the following command to inspect a locked root: ```bash $$nemoclaw exec -- stat -c '%a %U:%G' /sandbox/.hermes @@ -5189,46 +4363,32 @@ Expected output: 3770 root:sandbox ``` -A sandbox locked by an older release carries a `755 root:root` root instead. -The gateway cannot write its runtime state there. -`$$nemoclaw shields status` reports the stale posture as drift. -Before restarting the gateway, repair the posture: +A sandbox locked by an older release carries a `755 root:root` root instead. The gateway cannot write its runtime state there. `$$nemoclaw shields status` reports the stale posture as drift. Before restarting the gateway, repair the posture: ```bash $$nemoclaw shields up ``` -If the command refuses the repair, follow its recovery guidance and do not restart the gateway. -Otherwise, verify the repaired posture: +If the command refuses the repair, follow its recovery guidance and do not restart the gateway. Otherwise, verify the repaired posture: ```bash $$nemoclaw shields status $$nemoclaw exec -- stat -c '%a %U:%G' /sandbox/.hermes ``` -Continue only when status no longer reports drift and `stat` prints the expected output above. -Then restart the gateway: +Continue only when status no longer reports drift and `stat` prints the expected output above. Then restart the gateway: ```bash $$nemoclaw gateway restart ``` -The restart must exit zero and report that the health check passed. -If restart or recovery already reports `relaunch quarantined`, repairing the directory cannot clear the supervisor quarantine. -Follow [Restart or recovery reports `relaunch quarantined`](#restart-or-recovery-reports-relaunch-quarantined) to rebuild the sandbox. -The sealed files are unchanged by the repair and stay `444 root:root`. +The restart must exit zero and report that the health check passed. If restart or recovery already reports `relaunch quarantined`, repairing the directory cannot clear the supervisor quarantine. Follow [Restart or recovery reports `relaunch quarantined`](#restart-or-recovery-reports-relaunch-quarantined) to rebuild the sandbox. The sealed files are unchanged by the repair and stay `444 root:root`. ### Hermes restart reports `config hash mismatch` -A Hermes restart reports `config hash mismatch` when a strict root-owned hash is available and `/sandbox/.hermes/config.yaml` or `/sandbox/.hermes/.env` does not match it. -The direct root-entrypoint supervisor always uses the strict hash at `/etc/nemoclaw/hermes.config-hash`. -The OpenShell-managed controller uses that hash when both config inputs are root-owned and locked. -Mutable config in the managed topology has no durable root-owned hash anchor, so restart retains the same trust and time-of-check/time-of-use limits as managed cold start and cannot use this error to prove direct drift. -Both controllers validate the secret boundary and supervisor runtime environment before they stop the tracked gateway. -They do not recompute a trusted strict hash to adopt direct edits made inside the sandbox. +A Hermes restart reports `config hash mismatch` when a strict root-owned hash is available and `/sandbox/.hermes/config.yaml` or `/sandbox/.hermes/.env` does not match it. The direct root-entrypoint supervisor always uses the strict hash at `/etc/nemoclaw/hermes.config-hash`. The OpenShell-managed controller uses that hash when both config inputs are root-owned and locked. Mutable config in the managed topology has no durable root-owned hash anchor, so restart retains the same trust and time-of-check/time-of-use limits as managed cold start and cannot use this error to prove direct drift. Both controllers validate the secret boundary and supervisor runtime environment before they stop the tracked gateway. They do not recompute a trusted strict hash to adopt direct edits made inside the sandbox. -For intended changes, use supported host commands such as `nemohermes config set` and `nemohermes inference set` so NemoClaw updates the config and its strict and compatibility hashes together. -Do not edit either hash file manually. +For intended changes, use supported host commands such as `nemohermes config set` and `nemohermes inference set` so NemoClaw updates the config and its strict and compatibility hashes together. Do not edit either hash file manually. If direct edits have already caused a mismatch, restore the original config and environment files or rebuild from the registered configuration: @@ -5236,20 +4396,13 @@ If direct edits have already caused a mismatch, restore the original config and nemohermes rebuild --yes ``` -If the command instead reports `secret-boundary refusal`, inspect `/sandbox/.hermes/.env` for raw secret-shaped values. -Replace them through the supported credential flow so the file contains `openshell:resolve:env:` placeholders, then run `nemohermes recover`. -The Hermes entrypoint supervisor remains responsible for the gateway, dashboard, internal API relay, dashboard relay, and gateway log stream throughout recovery. -In the OpenShell-managed topology, that nonroot supervisor repairs failed auxiliaries continuously, recovers a gateway after four consecutive failed listener or HTTP health checks, and quarantines relaunch after five exits within 60 seconds until the sandbox is recreated. -The host only repairs the host-side OpenShell forwards after the supervised processes pass health checks. +If the command instead reports `secret-boundary refusal`, inspect `/sandbox/.hermes/.env` for raw secret-shaped values. Replace them through the supported credential flow so the file contains `openshell:resolve:env:` placeholders, then run `nemohermes recover`. The Hermes entrypoint supervisor remains responsible for the gateway, dashboard, internal API relay, dashboard relay, and gateway log stream throughout recovery. In the OpenShell-managed topology, that nonroot supervisor repairs failed auxiliaries continuously, recovers a gateway after four consecutive failed listener or HTTP health checks, and quarantines relaunch after five exits within 60 seconds until the sandbox is recreated. The host only repairs the host-side OpenShell forwards after the supervised processes pass health checks. ### Restart or recovery reports `relaunch quarantined` -In the OpenShell-managed topology the strict root-owned hash is not a trust anchor for mutable config, so a direct edit of `/sandbox/.hermes/config.yaml` or `/sandbox/.hermes/.env` is not refused by the host controller. -The in-sandbox supervisor still refuses to start a gateway on configuration it cannot match to the persisted managed state, and it stops attempting relaunch once that refusal or repeated gateway exits exhaust its crash budget. -`$$nemoclaw gateway restart`, `$$nemoclaw recover`, and `$$nemoclaw connect` then report the `relaunch quarantined` failure layer. +In the OpenShell-managed topology the strict root-owned hash is not a trust anchor for mutable config, so a direct edit of `/sandbox/.hermes/config.yaml` or `/sandbox/.hermes/.env` is not refused by the host controller. The in-sandbox supervisor still refuses to start a gateway on configuration it cannot match to the persisted managed state, and it stops attempting relaunch once that refusal or repeated gateway exits exhaust its crash budget. `$$nemoclaw gateway restart`, `$$nemoclaw recover`, and `$$nemoclaw connect` then report the `relaunch quarantined` failure layer. -The refusal is deterministic, so retrying any of those commands cannot clear it. -Restore the registered configuration and refresh its integrity metadata in one transaction: +The refusal is deterministic, so retrying any of those commands cannot clear it. Restore the registered configuration and refresh its integrity metadata in one transaction: ```bash nemohermes rebuild --yes @@ -5259,9 +4412,7 @@ After the rebuild, make the intended change through a supported command such as ### Port 8642 in a browser shows a blank page or `Cannot GET /` -`nemohermes onboard` forwards the sandbox's API port, which is `8642` when no other sandbox or host listener already holds it. -Hermes serves an OpenAI-compatible API at that port, not a chat dashboard. -A browser visit to `http://127.0.0.1:8642/` (or any non-API path) returns nothing renderable. +`nemohermes onboard` forwards the sandbox's API port, which is `8642` when no other sandbox or host listener already holds it. Hermes serves an OpenAI-compatible API at that port, not a chat dashboard. A browser visit to `http://127.0.0.1:8642/` (or any non-API path) returns nothing renderable. Confirm the agent is healthy with the API health endpoint instead: @@ -5272,31 +4423,23 @@ curl -sf http://127.0.0.1:8642/health Expected output: ```json -{"status":"ok","platform":"hermes-agent"} +{ "status": "ok", "platform": "hermes-agent" } ``` -Point an OpenAI-compatible client at `http://127.0.0.1:8642/v1` for chat completions. -For terminal use, run `nemohermes launch `. +Point an OpenAI-compatible client at `http://127.0.0.1:8642/v1` for chat completions. For terminal use, run `nemohermes launch `. ### Onboarding Reports Hermes Is Not Ready With an Unreachable API Port -Deployment verification probes the OpenAI-compatible API inside the sandbox and through its host-side API port forward. -When the API answers inside the sandbox but its host-side API port forward is unreachable, verification reports the API port forward as failed. -Onboarding then prints `Hermes is not ready` and exits with a nonzero status: +Deployment verification probes the OpenAI-compatible API inside the sandbox and through its host-side API port forward. When the API answers inside the sandbox but its host-side API port forward is unreachable, verification reports the API port forward as failed. Onboarding then prints `Hermes is not ready` and exits with a nonzero status: ```text ✗ api: port forward not working (connection refused) The OpenAI-compatible API on port 8642 is not reachable from the host. Run: openshell forward start --background 8642 ``` -Use the forward recovery below only when the in-sandbox `gateway` check passed. -The output omits passing checks, so confirm that it contains no `gateway` failure. -If the output contains a `gateway` failure, follow that diagnostic first. -Do not restart the host forward until the in-sandbox API responds. +Use the forward recovery below only when the in-sandbox `gateway` check passed. The output omits passing checks, so confirm that it contains no `gateway` failure. If the output contains a `gateway` failure, follow that diagnostic first. Do not restart the host forward until the in-sandbox API responds. -When the `gateway` check passed and only the `api` check failed, the API remains reachable inside the sandbox. -Each Hermes sandbox owns an API port allocated from `8642` through `8652`, so use the port from the `api` diagnostic instead of assuming the default. -First, list the active OpenShell port forwards: +When the `gateway` check passed and only the `api` check failed, the API remains reachable inside the sandbox. Each Hermes sandbox owns an API port allocated from `8642` through `8652`, so use the port from the `api` diagnostic instead of assuming the default. First, list the active OpenShell port forwards: ```bash openshell forward list @@ -5308,11 +4451,7 @@ If no row owns the API port, start its port forward: openshell forward start --background ``` -Continue only when the command exits with status `0`. -If the command reports that the port is in use, follow [Port Already in Use](#port-already-in-use) immediately. -Replace that section's example port `18789` with the API port from the `api` diagnostic. -Apply its process-ownership, service-manager, and active-work conditions before you stop a listener. -Do not run the health probe after `openshell forward start` fails. +Continue only when the command exits with status `0`. If the command reports that the port is in use, follow [Port Already in Use](#port-already-in-use) immediately. Replace that section's example port `18789` with the API port from the `api` diagnostic. Apply its process-ownership, service-manager, and active-work conditions before you stop a listener. Do not run the health probe after `openshell forward start` fails. After `openshell forward start` exits with status `0`, probe the health endpoint: @@ -5320,14 +4459,11 @@ After `openshell forward start` exits with status `0`, probe the health endpoint curl -sS -o /dev/null -w '%{http_code}\n' --max-time 3 http://127.0.0.1:/health ``` -An HTTP status of `200` or `401` means the host port forward is reachable. -Any other status, `000`, or connection failure requires fresh diagnostics. -Rerun `$$nemoclaw onboard` and follow the reported `gateway` or `api` failure. +An HTTP status of `200` or `401` means the host port forward is reachable. Any other status, `000`, or connection failure requires fresh diagnostics. Rerun `$$nemoclaw onboard` and follow the reported `gateway` or `api` failure. ### `docker port` shows no mapping for 8642 even though forwarding works -OpenShell port forwards are host-side relays managed by the OpenShell gateway process, not Docker `-p` publish mappings on the sandbox container. -`docker port openshell-hermes-` reflects only Docker-published ports, so it returns nothing for OpenShell-managed forwards even when the host bind is live. +OpenShell port forwards are host-side relays managed by the OpenShell gateway process, not Docker `-p` publish mappings on the sandbox container. `docker port openshell-hermes-` reflects only Docker-published ports, so it returns nothing for OpenShell-managed forwards even when the host bind is live. Use OpenShell's own view as the supported acceptance signal: @@ -5336,34 +4472,21 @@ openshell forward list # shows the host bind for each forw curl -sf http://127.0.0.1:8642/health # confirms the relayed endpoint answers ``` -If `openshell forward list` does not show the sandbox's API port, run `nemohermes connect --probe-only` (or `nemohermes recover`) to ask the recovery path to re-establish every manifest-declared agent forward port that has gone missing. -Recovery targets each sandbox's own ports. -A second Hermes sandbox on the same host receives the next free API port, so check which sandbox owns each row before assuming a missing `8642` row belongs to the sandbox you are debugging. -A Hermes sandbox onboarded before the API port became per-sandbox carries no allocated port and keeps `8642`. -A Hermes sandbox created after that change receives its own port during onboarding, so a second sandbox needs no further action. -To move a sandbox that predates the change onto its own port, set `NEMOCLAW_HERMES_API_PORT=` and rerun onboarding with `--recreate-sandbox`. -Set `` to a free port from `8642` through `8652`. -Onboarding rejects a value outside that range. -It also rejects an in-range value that another sandbox or host listener already holds. -A recreate keeps the sandbox's registry entry, so `--recreate-sandbox` without the variable keeps the recorded port. +If `openshell forward list` does not show the sandbox's API port, run `nemohermes connect --probe-only` (or `nemohermes recover`) to ask the recovery path to re-establish every manifest-declared agent forward port that has gone missing. Recovery targets each sandbox's own ports. A second Hermes sandbox on the same host receives the next free API port, so check which sandbox owns each row before assuming a missing `8642` row belongs to the sandbox you are debugging. A Hermes sandbox onboarded before the API port became per-sandbox carries no allocated port and keeps `8642`. A Hermes sandbox created after that change receives its own port during onboarding, so a second sandbox needs no further action. To move a sandbox that predates the change onto its own port, set `NEMOCLAW_HERMES_API_PORT=` and rerun onboarding with `--recreate-sandbox`. Set `` to a free port from `8642` through `8652`. Onboarding rejects a value outside that range. It also rejects an in-range value that another sandbox or host listener already holds. A recreate keeps the sandbox's registry entry, so `--recreate-sandbox` without the variable keeps the recorded port. ### Install reports `Could not restore the Hermes forward` -The installer reads the sandbox's recorded API port from the sandbox registry before it restores the API forward. -It exits with that message when any of these conditions is true: +The installer reads the sandbox's recorded API port from the sandbox registry before it restores the API forward. It exits with that message when any of these conditions is true: - The `node` binary is missing. - The registry file is missing, does not parse, or records no entry for the sandbox. - The recorded port is not an integer from `8642` through `8652`. -A sandbox registered before the API port became per-sandbox records no port, and the installer uses `8642` for it. -Install `node`, or rerun `nemohermes onboard` to register the sandbox again. -Then run `nemohermes recover` to re-establish the forward. +A sandbox registered before the API port became per-sandbox records no port, and the installer uses `8642` for it. Install `node`, or rerun `nemohermes onboard` to register the sandbox again. Then run `nemohermes recover` to re-establish the forward. ### `nemohermes` reports `Sandbox 'X' already exists as OpenClaw` -Each sandbox name maps to exactly one agent type. -If a sandbox named `X` was created with the default OpenClaw agent, a later `nemohermes onboard` for the same name exits with: +Each sandbox name maps to exactly one agent type. If a sandbox named `X` was created with the default OpenClaw agent, a later `nemohermes onboard` for the same name exits with: ```text Sandbox 'X' already exists as OpenClaw. @@ -5371,8 +4494,7 @@ nemohermes is onboarding Hermes for this sandbox name. Side-by-side agents are supported, but each sandbox name has one agent type. ``` -Pick a distinct sandbox name (the Hermes default is `hermes`; a common pattern is `my-hermes`) so Hermes and OpenClaw sandboxes can coexist on the same host. -To convert an existing sandbox to Hermes instead, destroy and re-onboard: +Pick a distinct sandbox name (the Hermes default is `hermes`; a common pattern is `my-hermes`) so Hermes and OpenClaw sandboxes can coexist on the same host. To convert an existing sandbox to Hermes instead, destroy and re-onboard: ```bash $$nemoclaw destroy @@ -5381,8 +4503,7 @@ NEMOCLAW_AGENT=hermes nemohermes onboard ### `nemohermes: command not found` immediately after install -`nemohermes` is a thin shim installed alongside `$$nemoclaw` that pre-selects the Hermes agent. -The installer drops the shim in the same directory as `$$nemoclaw`; if `$$nemoclaw` is on `PATH` but `nemohermes` is not, the shim symlink was skipped. +`nemohermes` is a thin shim installed alongside `$$nemoclaw` that pre-selects the Hermes agent. The installer drops the shim in the same directory as `$$nemoclaw`; if `$$nemoclaw` is on `PATH` but `nemohermes` is not, the shim symlink was skipped. Verify the install: @@ -5402,8 +4523,7 @@ Equivalently, every `nemohermes ` invocation is `NEMOCLAW_AGENT=hermes nemo ### Choosing between OAuth and API key for the Hermes Provider -The Hermes Provider supports two authentication paths during onboarding. -Pick OAuth when you have a Nous Portal account and an interactive terminal; pick API key when you have a long-lived `NOUS_API_KEY` and want a non-interactive flow. +The Hermes Provider supports two authentication paths during onboarding. Pick OAuth when you have a Nous Portal account and an interactive terminal; pick API key when you have a long-lived `NOUS_API_KEY` and want a non-interactive flow. Set the method explicitly so the wizard skips the prompt: @@ -5418,19 +4538,15 @@ export NOUS_API_KEY=nous_... nemohermes onboard --non-interactive ``` -`NEMOCLAW_HERMES_AUTH_METHOD` accepts `oauth`, `nous-portal-oauth`, `api-key`, and `nous-api-key`. -The `NEMOCLAW_HERMES_AUTH` and `NEMOCLAW_NOUS_AUTH_METHOD` variables are back-compatible aliases. +`NEMOCLAW_HERMES_AUTH_METHOD` accepts `oauth`, `nous-portal-oauth`, `api-key`, and `nous-api-key`. The `NEMOCLAW_HERMES_AUTH` and `NEMOCLAW_NOUS_AUTH_METHOD` variables are back-compatible aliases. -If OAuth is selected and onboarding cannot open the host's default browser (a headless host or SSH session), the device-code prompt still prints the verification URL and user code to the terminal. -Copy them to a browser on any other machine to complete the flow. +If OAuth is selected and onboarding cannot open the host's default browser (a headless host or SSH session), the device-code prompt still prints the verification URL and user code to the terminal. Copy them to a browser on any other machine to complete the flow. ### API client returns `401 Unauthorized` against port 8642 -Hermes uses bearer-token header authentication for client requests, not an OpenClaw-style URL fragment. -A request without an `Authorization: Bearer ` header (or with an OpenClaw `#token=` fragment appended to the URL) is rejected with `401`. +Hermes uses bearer-token header authentication for client requests, not an OpenClaw-style URL fragment. A request without an `Authorization: Bearer ` header (or with an OpenClaw `#token=` fragment appended to the URL) is rejected with `401`. -Configure your OpenAI-compatible client to pass the Hermes API key in the `Authorization` header. -Stored credentials (including `NOUS_API_KEY` and `OPENAI_API_KEY`) are listed by: +Configure your OpenAI-compatible client to pass the Hermes API key in the `Authorization` header. Stored credentials (including `NOUS_API_KEY` and `OPENAI_API_KEY`) are listed by: ```bash nemohermes credentials list @@ -5440,9 +4556,7 @@ Reset a specific provider's credentials with `nemohermes credentials reset \ nemohermes onboard --recreate-sandbox ``` -NemoClaw writes `web.backend: tavily`, applies the `tavily` policy preset, and configures request-body credential rewriting for Hermes. -If the same onboarding run selected the Nous-managed web gateway, Tavily replaces `nous-web` while selected Nous image, audio, browser, and code tools remain enabled. +NemoClaw writes `web.backend: tavily`, applies the `tavily` policy preset, and configures request-body credential rewriting for Hermes. If the same onboarding run selected the Nous-managed web gateway, Tavily replaces `nous-web` while selected Nous image, audio, browser, and code tools remain enabled. ### Re-onboarding asks every messaging prompt again -`nemohermes onboard --resume` against a Hermes sandbox that was originally onboarded with Telegram, Discord, and Slack credentials re-prompts for each channel's bot token and per-channel settings rather than reusing the stored values. -This is tracked in [#3581](https://github.com/NVIDIA/NemoClaw/issues/3581). -For unattended re-onboards, export the messaging env vars first so the wizard skips the prompts: +`nemohermes onboard --resume` against a Hermes sandbox that was originally onboarded with Telegram, Discord, and Slack credentials re-prompts for each channel's bot token and per-channel settings rather than reusing the stored values. This is tracked in [#3581](https://github.com/NVIDIA/NemoClaw/issues/3581). For unattended re-onboards, export the messaging env vars first so the wizard skips the prompts: ```bash export TELEGRAM_BOT_TOKEN=... diff --git a/nemoclaw/src/blueprint/runner-error-paths.test.ts b/nemoclaw/src/blueprint/runner-error-paths.test.ts new file mode 100644 index 00000000000..61dc64311db --- /dev/null +++ b/nemoclaw/src/blueprint/runner-error-paths.test.ts @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type fs from "node:fs"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; + +import { + createRunnerFsStore, + createStdoutCapture, + FAKE_HOME, + FIXED_RUN_UUID, + inMemoryFsMethods, + resolvedEndpointFor, +} from "./runner-mock-fixtures.js"; +import { + minimalBlueprint, + resultWithBlueprintPolicy, + TEST_SANDBOX_POLICY, + TEST_SANDBOX_POLICY_PATH, +} from "./runner-test-fixtures.js"; + +const { store, addFile, addDir } = createRunnerFsStore(); +const mockExeca = vi.fn(); + +vi.mock("node:os", () => ({ homedir: () => FAKE_HOME })); +vi.mock("node:crypto", async (importOriginal) => ({ + ...(await importOriginal()), + randomUUID: () => FIXED_RUN_UUID, +})); +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + const memory = inMemoryFsMethods(store, { spy: vi.fn }); + return { + ...original, + existsSync: memory.existsSync, + closeSync: memory.closeSync, + fsyncSync: memory.fsyncSync, + mkdirSync: memory.mkdirSync, + openSync: memory.openSync, + readFileSync: memory.readFileSync, + renameSync: memory.renameSync, + unlinkSync: memory.unlinkSync, + writeFileSync: memory.writeFileSync, + readdirSync: memory.readdirSync, + }; +}); +vi.mock("execa", () => ({ execa: (...args: unknown[]) => mockExeca(...args) })); +vi.mock("./ssrf.js", async (importOriginal) => ({ + ...(await importOriginal()), + validateEndpointUrl: vi.fn(async (url: string) => resolvedEndpointFor(url)), +})); + +const { actionApply, actionReconcile, actionStatus, loadBlueprint, main } = + await import("./runner.js"); +const stdoutCapture = createStdoutCapture(); + +function captureStdout(): void { + vi.spyOn(process.stdout, "write").mockImplementation(stdoutCapture.write); +} + +describe("runner error paths", () => { + beforeEach(() => { + store.clear(); + addFile(TEST_SANDBOX_POLICY_PATH, TEST_SANDBOX_POLICY); + vi.stubEnv("OPENSHELL_SANDBOX_POLICY", TEST_SANDBOX_POLICY_PATH); + stdoutCapture.reset(); + vi.clearAllMocks(); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + resultWithBlueprintPolicy(args, { exitCode: 0, stdout: "", stderr: "" }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it.each([ + ["sandbox", { components: { sandbox: "invalid" } }], + ["router", { components: { router: "invalid" } }], + ["policy", { components: { policy: "invalid" } }], + ["policy additions", { components: { policy: { additions: [] } } }], + ])("rejects an invalid %s component shape", (_name, blueprint) => { + addFile("blueprint.yaml", YAML.stringify(blueprint)); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it.each([ + [ + "a failed status command", + { exitCode: 1, stdout: "", stderr: "gateway unavailable" }, + /Failed to inspect the active OpenShell gateway/, + ], + [ + "an ambiguous active gateway", + { exitCode: 0, stdout: "Status: Disconnected\n", stderr: "" }, + /Failed to prove the active OpenShell gateway identity/, + ], + ])("fails before mutation for %s", async (_name, statusResult, expected) => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "status" + ? statusResult + : resultWithBlueprintPolicy(args, { exitCode: 0, stdout: "", stderr: "" }), + ); + + await expect(actionApply("default", minimalBlueprint())).rejects.toThrow(expected); + expect(mockExeca.mock.calls.some(([, args]) => args?.[0] === "sandbox")).toBe(false); + }); + + it("rejects an explicitly blank configured sandbox policy", async () => { + vi.stubEnv("OPENSHELL_SANDBOX_POLICY", " "); + + await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( + /configured NemoClaw sandbox policy is required/, + ); + expect(mockExeca.mock.calls.some(([, args]) => args?.[0] === "sandbox")).toBe(false); + }); + + it("rejects an unreadable configured sandbox policy", async () => { + vi.stubEnv("OPENSHELL_SANDBOX_POLICY", "/missing-policy.yaml"); + await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( + /configured NemoClaw sandbox policy could not be read/, + ); + }); + + it("rejects an invalid configured sandbox policy", async () => { + addFile("/invalid-policy.yaml", "version: [unterminated"); + vi.stubEnv("OPENSHELL_SANDBOX_POLICY", "/invalid-policy.yaml"); + await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( + /configured NemoClaw sandbox policy is invalid/, + ); + }); + + it("prints unknown status when the gateway binding is invalid", () => { + const rid = "nc-run-invalid"; + const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/${rid}`; + addDir(runDir); + addFile(`${runDir}/plan.json`, JSON.stringify({ run_id: rid, gateway: "wrong" })); + captureStdout(); + + actionStatus(rid); + + expect(stdoutCapture.jsonOutput()).toMatchObject({ + run_id: rid, + status: "unknown", + receipt_error_kind: "invalid", + }); + }); + + it("rejects a missing reconciliation run", async () => { + await expect(actionReconcile("nc-missing")).rejects.toThrow(/nc-missing not found/); + }); + + it.each([ + ["non-object plan", "[]", /JSON object/], + [ + "invalid gateway", + JSON.stringify({ sandbox_name: "sb", gateway: "wrong", policy_additions: {} }), + /gateway binding is invalid/, + ], + [ + "invalid policy additions", + JSON.stringify({ + sandbox_name: "sb", + gateway: { name: "test-gateway", host: "127.0.0.1", port: 8080 }, + policy_additions: [], + }), + /policy additions are invalid/, + ], + ])("rejects a reconciliation receipt with %s", async (_name, contents, expected) => { + const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/nc-invalid`; + addDir(runDir); + addFile(`${runDir}/plan.json`, contents); + + await expect(actionReconcile("nc-invalid")).rejects.toThrow(expected); + }); + + it("accepts an empty policy requirement without a policy mutation", async () => { + const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/nc-empty`; + addDir(runDir); + addFile( + `${runDir}/plan.json`, + JSON.stringify({ + sandbox_name: "sb", + gateway: { name: "test-gateway", host: "127.0.0.1", port: 8080 }, + policy_additions: {}, + }), + ); + captureStdout(); + + await actionReconcile("nc-empty"); + + expect(stdoutCapture.text()).toContain("requirements for run nc-empty are present"); + expect(mockExeca.mock.calls.some(([, args]) => args?.[0] === "policy")).toBe(false); + }); + + it("rejects a gateway binding that changed after the run", async () => { + const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/nc-drifted`; + addDir(runDir); + addFile( + `${runDir}/plan.json`, + JSON.stringify({ + sandbox_name: "sb", + gateway: { name: "test-gateway", host: "127.0.0.1", port: 8080 }, + policy_additions: {}, + }), + ); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "gateway info -g test-gateway" + ? { exitCode: 0, stdout: "Gateway endpoint: http://127.0.0.1:9090\n", stderr: "" } + : resultWithBlueprintPolicy(args, { exitCode: 0, stdout: "", stderr: "" }), + ); + + await expect(actionReconcile("nc-drifted")).rejects.toThrow(/gateway binding changed/); + }); + + it("throws when reconcile has no --run-id", async () => { + await expect(main(["reconcile"])).rejects.toThrow(/--run-id is required for reconcile/); + }); + + it.each(["--profile", "--plan", "--run-id", "--endpoint-url"])( + "rejects a missing value for %s", + async (flag) => { + await expect(main(["plan", flag])).rejects.toThrow(`${flag} requires a value`); + }, + ); +}); diff --git a/nemoclaw/src/blueprint/runner-external-target.test.ts b/nemoclaw/src/blueprint/runner-external-target.test.ts index 5d2445c24bd..5171b7376eb 100644 --- a/nemoclaw/src/blueprint/runner-external-target.test.ts +++ b/nemoclaw/src/blueprint/runner-external-target.test.ts @@ -54,7 +54,7 @@ vi.mock("./ssrf.js", async (importOriginal) => { const { validateEndpointUrl } = await import("./ssrf.js"); const mockedValidateEndpoint = vi.mocked(validateEndpointUrl); -const { main } = await import("./runner.js"); +const { actionExternalOpenShellTargetPlan, main } = await import("./runner.js"); const EXTERNAL_CA_FILE = "/var/run/openshell-target/private-ca.pem"; const EXTERNAL_AUTHENTICATION_FILE = "/var/run/openshell-target/private-authentication"; @@ -104,6 +104,15 @@ describe("Blueprint Runner external OpenShell target", () => { vi.restoreAllMocks(); }); + it("requires an external target and a complete OpenShell version range", () => { + expect(() => actionExternalOpenShellTargetPlan({})).toThrow( + /does not declare an external OpenShell target/, + ); + expect(() => + actionExternalOpenShellTargetPlan({ openshell_target: {} }), + ).toThrow(/requires blueprint min_openshell_version and max_openshell_version/); + }); + it("emits only the sanitized plan without subprocess or network calls (#9872)", async () => { vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://ambient-gateway.invalid"); vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", "/private/ambient-gateway-management.json"); diff --git a/nemoclaw/src/blueprint/runner-identity.test.ts b/nemoclaw/src/blueprint/runner-identity.test.ts index 7d7b8f9cccf..5e566b6f441 100644 --- a/nemoclaw/src/blueprint/runner-identity.test.ts +++ b/nemoclaw/src/blueprint/runner-identity.test.ts @@ -19,9 +19,7 @@ import { MATCHING_INFERENCE_ROUTE_LISTING, MATCHING_RUNTIME_PROVIDER_LISTING, providersV2EnabledResult, - resultWithBlueprintPolicyAuthority, - sandboxIdentityResult, - sequentialCommandResult, + resultWithBlueprintPolicy, successResult, TEST_SANDBOX_POLICY, TEST_SANDBOX_POLICY_PATH, @@ -51,6 +49,7 @@ vi.mock("node:fs", async (importOriginal) => { openSync: memory.openSync, readFileSync: memory.readFileSync, renameSync: memory.renameSync, + unlinkSync: memory.unlinkSync, writeFileSync: memory.writeFileSync, readdirSync: memory.readdirSync, realpathSync: memory.realpathSync, @@ -75,18 +74,6 @@ const matchingInferenceRoute = MATCHING_INFERENCE_ROUTE_LISTING; const success = successResult(); const providersV2Enabled = providersV2EnabledResult(); -const POLICY_BOUNDARY_COMMAND = "policy get -g test-gateway --full --output json test-sandbox"; - -function expectPolicyBoundaryImmediatelyBefore( - commands: readonly string[], - mutation: string | ((command: string) => boolean), -): void { - const index = commands.findIndex((command) => - typeof mutation === "string" ? command === mutation : mutation(command), - ); - expect(index).toBeGreaterThan(0); - expect(commands[index - 1]).toBe(POLICY_BOUNDARY_COMMAND); -} function responseQueue( overrides: Array<[string, Array<{ exitCode?: number; stdout: string; stderr: string }>]>, @@ -110,7 +97,7 @@ function responseQueue( const fallback = responses.get(command)?.shift() ?? fallbacks.get(command) ?? success; return fallback.exitCode === undefined ? fallback - : resultWithBlueprintPolicyAuthority(args, { + : resultWithBlueprintPolicy(args, { ...fallback, exitCode: fallback.exitCode ?? 1, }); @@ -148,29 +135,6 @@ function oktaIdentity(profilePath = "provider-profiles/okta-runtime-v1.yaml") { }; } -function managedPolicyAuthorityReceipt(sandboxName = "test-sandbox") { - return { - authority: "nemoclaw-managed", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - scope: "sandbox", - sandbox_name: sandboxName, - policy_creation_receipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "test-gateway", - gatewayPort: 8080, - sandboxName, - lifecycleGeneration: FIXED_RUN_UUID, - sandboxIdentityFingerprint: - "52aad66e236c4a522e5a9b5adb8234b8bbf780d3e4120ccffb0c3dd35ad63aab", - policyHash: "sha256:test-policy", - policyVersion: 1, - }, - }; -} - describe("blueprint identity wrapper", () => { beforeEach(() => { store.clear(); @@ -179,7 +143,7 @@ describe("blueprint identity wrapper", () => { realpaths.clear(); vi.clearAllMocks(); mockExeca.mockImplementation(async (_command: string, args: string[]) => - resultWithBlueprintPolicyAuthority( + resultWithBlueprintPolicy( args, args.join(" ") === "settings get --global --json" ? providersV2Enabled : success, ), @@ -359,32 +323,6 @@ describe("blueprint identity wrapper", () => { "provider refresh rotate acme-okta-runtime --credential-key OKTA_ACCESS_TOKEN", ), ); - expectPolicyBoundaryImmediatelyBefore(commands, (command) => - command.startsWith("provider profile import --file "), - ); - expectPolicyBoundaryImmediatelyBefore( - commands, - "provider create --name acme-okta-runtime --type okta-runtime-v1 --runtime-credentials", - ); - expectPolicyBoundaryImmediatelyBefore(commands, (command) => - command.startsWith("provider refresh configure "), - ); - expectPolicyBoundaryImmediatelyBefore( - commands, - "provider create --name test-provider --type openai --config OPENAI_BASE_URL=https://api.example.com/v1", - ); - expectPolicyBoundaryImmediatelyBefore( - commands, - "inference set --provider test-provider --model test-model", - ); - expectPolicyBoundaryImmediatelyBefore( - commands, - "sandbox provider attach test-sandbox acme-okta-runtime", - ); - expectPolicyBoundaryImmediatelyBefore( - commands, - "provider refresh rotate acme-okta-runtime --credential-key OKTA_ACCESS_TOKEN", - ); }); it("fails closed when an identity subprocess has no exit code", async () => { @@ -409,113 +347,24 @@ describe("blueprint identity wrapper", () => { ).not.toContain("refresh configure"); }); - it("establishes the policy receipt before the first identity mutation", async () => { - process.env.OKTA_CLIENT_ID = "client-id"; - process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; - process.env.OKTA_CLIENT_SECRET = "client-secret"; - responseQueue([ - [ - "provider get acme-okta-runtime", - [ - failureResult("provider not found"), - ...Array.from({ length: 4 }, () => ({ - exitCode: 0, - stdout: matchingProvider, - stderr: "", - })), - ], - ], - ]); - - await actionApply("default", blueprint({ identity: oktaIdentity() })); - - const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - const createIndex = commands.indexOf( - "sandbox create -g test-gateway --from openclaw --name test-sandbox --policy /tmp/nemoclaw-test-policy.yaml --forward 18789", - ); - const firstReceiptValidation = commands.indexOf( - "sandbox get -g test-gateway test-sandbox", - createIndex + 1, - ); - const firstIdentityMutation = commands.indexOf( - "provider create --name acme-okta-runtime --type okta-runtime-v1 --runtime-credentials", - ); - expect(createIndex).toBeGreaterThan(-1); - expect(firstReceiptValidation).toBeGreaterThan(createIndex); - expect(firstIdentityMutation).toBeGreaterThan(firstReceiptValidation); - }); - - it("stops before identity mutation when the receipt sandbox identity changes", async () => { - process.env.OKTA_CLIENT_ID = "client-id"; - process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; - process.env.OKTA_CLIENT_SECRET = "client-secret"; - const identityResult = sequentialCommandResult("sandbox get -g test-gateway test-sandbox", [ - sandboxIdentityResult("test-sandbox"), - sandboxIdentityResult("test-sandbox", "replacement-id"), - ]); - mockExeca.mockImplementation( - async (_command: string, args: string[]) => - identityResult(args) ?? - resultWithBlueprintPolicyAuthority( - args, - args.join(" ") === "settings get --global --json" ? providersV2Enabled : success, - ), - ); - - await expect(actionApply("default", blueprint({ identity: oktaIdentity() }))).rejects.toThrow( - /receipt does not match the live sandbox policy/u, - ); - const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - expect(commands).not.toContain( - "provider create --name acme-okta-runtime --type okta-runtime-v1 --runtime-credentials", - ); - expect(commands).not.toContain("sandbox provider attach test-sandbox acme-okta-runtime"); - }); - - it("validates the policy receipt before inference-provider reuse inspection", async () => { - process.env.OKTA_CLIENT_ID = "client-id"; - process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; - process.env.OKTA_CLIENT_SECRET = "client-secret"; - responseQueue([ - ["provider get test-provider", [failureResult("gateway configuration not found")]], - ]); - - await expect(actionApply("default", blueprint({ identity: oktaIdentity() }))).rejects.toThrow( - /Failed to inspect inference provider 'test-provider'.*gateway configuration not found/u, - ); - const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - const receiptValidation = commands.indexOf("sandbox get -g test-gateway test-sandbox"); - const providerInspection = commands.indexOf("provider get test-provider"); - expect(receiptValidation).toBeGreaterThanOrEqual(0); - expect(providerInspection).toBeGreaterThanOrEqual(0); - expect(receiptValidation).toBeLessThan(providerInspection); - expect(commands).not.toContain( - "provider create --name acme-okta-runtime --type okta-runtime-v1 --runtime-credentials", - ); - }); - it.each([ - ["not configured", "Gateway inference:\n\n Not configured\n"], - [ - "OpenShell v0.0.99 ANSI not configured", - [ - "\u001b[1mInference:\u001b[0m", - "", - " Not configured", - "", - "\u001b[1mSystem inference:\u001b[0m", - "", - " Not configured", - "", - ].join("\n"), - ], - [ - "configured for a different model", - matchingInferenceRoute.replace("Model: test-model", "Model: other-model"), - ], - ])("sets the requested route when the reused route is %s", async (_label, routeOutput) => { + it("reuses the ANSI-formatted OpenShell v0.0.99 inference route", async () => { process.env.OKTA_CLIENT_ID = "client-id"; process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; process.env.OKTA_CLIENT_SECRET = "client-secret"; + const routeOutput = [ + "\u001b[1mInference:\u001b[0m", + "", + " Workspace: default", + " Provider: test-provider", + " Model: test-model", + " Version: 1", + " Timeout: 180s", + "", + "\u001b[1mSystem inference:\u001b[0m", + "", + " Not configured", + "", + ].join("\n"); responseQueue([ [ "sandbox get test-sandbox", @@ -542,40 +391,29 @@ describe("blueprint identity wrapper", () => { await actionApply("default", blueprint({ identity: oktaIdentity() })); const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - expect(commands).toContain("inference set --provider test-provider --model test-model"); - expect( - commands.indexOf("inference set --provider test-provider --model test-model"), - ).toBeLessThan(commands.indexOf("sandbox provider attach test-sandbox acme-okta-runtime")); + expect(commands).not.toContain("inference set --provider test-provider --model test-model"); + expect(commands).toContain("sandbox provider attach test-sandbox acme-okta-runtime"); }); - it("reuses the ANSI-formatted OpenShell v0.0.99 inference route", async () => { + it("sets the route when OpenShell reports inference is not configured", async () => { process.env.OKTA_CLIENT_ID = "client-id"; process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; process.env.OKTA_CLIENT_SECRET = "client-secret"; - const routeOutput = [ - "\u001b[1mInference:\u001b[0m", - "", - " Workspace: default", - " Provider: test-provider", - " Model: test-model", - " Version: 1", - " Timeout: 180s", - "", - "\u001b[1mSystem inference:\u001b[0m", - "", - " Not configured", - "", - ].join("\n"); responseQueue([ - [ - "sandbox get test-sandbox", - [{ exitCode: 0, stdout: "Name: test-sandbox\nPhase: Ready", stderr: "" }], - ], [ "provider get test-provider", [{ exitCode: 0, stdout: matchingInferenceProvider, stderr: "" }], ], - ["inference get", [{ exitCode: 0, stdout: routeOutput, stderr: "" }]], + [ + "inference get", + [ + { + exitCode: 0, + stdout: "Inference:\n Not configured\nSystem inference:\n Not configured\n", + stderr: "", + }, + ], + ], [ "provider get acme-okta-runtime", [ @@ -592,8 +430,7 @@ describe("blueprint identity wrapper", () => { await actionApply("default", blueprint({ identity: oktaIdentity() })); const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - expect(commands).not.toContain("inference set --provider test-provider --model test-model"); - expect(commands).toContain("sandbox provider attach test-sandbox acme-okta-runtime"); + expect(commands).toContain("inference set --provider test-provider --model test-model"); }); it("sets an exact reused route when the requested timeout differs", async () => { @@ -982,7 +819,6 @@ describe("blueprint identity wrapper", () => { content: JSON.stringify({ sandbox_name: "owned-sandbox", sandbox_created_by_apply: true, - policy_authority: managedPolicyAuthorityReceipt("owned-sandbox"), }), }); responseQueue([ @@ -1005,7 +841,6 @@ describe("blueprint identity wrapper", () => { sandbox_name: "test-sandbox", inference_provider_created_by_apply: true, inference: { provider_name: "test-provider", provider_type: "openai" }, - policy_authority: managedPolicyAuthorityReceipt(), }), }); responseQueue([ @@ -1023,7 +858,7 @@ describe("blueprint identity wrapper", () => { expect(store.get(`${stateDir}/rolled_back`)).toBeUndefined(); }); - it("preserves rollback resources without using a separate policy read to authorize deletion (#9833)", async () => { + it("preserves rollback resources when the plan has no exact resource identity (#9833)", async () => { const stateDir = "/fakehome/.nemoclaw/state/runs/provider-authority-drift"; store.set(stateDir, { type: "dir" }); store.set(`${stateDir}/plan.json`, { @@ -1032,28 +867,12 @@ describe("blueprint identity wrapper", () => { sandbox_name: "test-sandbox", inference_provider_created_by_apply: true, inference: { provider_name: "test-provider", provider_type: "openai" }, - policy_authority: managedPolicyAuthorityReceipt(), }), }); - const policyResult = sequentialCommandResult(POLICY_BOUNDARY_COMMAND, [ - resultWithBlueprintPolicyAuthority(POLICY_BOUNDARY_COMMAND.split(" "), success), - { - ...resultWithBlueprintPolicyAuthority(POLICY_BOUNDARY_COMMAND.split(" "), success), - stdout: JSON.stringify({ - scope: "sandbox", - sandbox: "test-sandbox", - status: "effective", - policy_source: "sandbox", - hash: "sha256:replacement-policy", - active_version: 2, - policy: { version: 1, network_policies: {} }, - }), - }, - ]); mockExeca.mockImplementation(async (_command: string, args: string[]) => args.join(" ") === "provider get test-provider" ? { exitCode: 0, stdout: matchingInferenceProvider, stderr: "" } - : (policyResult(args) ?? resultWithBlueprintPolicyAuthority(args, success)), + : resultWithBlueprintPolicy(args, success), ); await expect(actionRollback("provider-authority-drift")).rejects.toThrow( @@ -1073,34 +892,8 @@ describe("blueprint identity wrapper", () => { content: JSON.stringify({ sandbox_name: "test-sandbox", sandbox_created_by_apply: true, - policy_authority: managedPolicyAuthorityReceipt(), }), }); - const matchingPolicy = resultWithBlueprintPolicyAuthority( - POLICY_BOUNDARY_COMMAND.split(" "), - success, - ); - const policyResult = sequentialCommandResult(POLICY_BOUNDARY_COMMAND, [ - matchingPolicy, - matchingPolicy, - { - ...matchingPolicy, - stdout: JSON.stringify({ - scope: "sandbox", - sandbox: "test-sandbox", - status: "effective", - policy_source: "sandbox", - hash: "sha256:replacement-policy", - active_version: 2, - policy: { version: 1, network_policies: {} }, - }), - }, - ]); - mockExeca.mockImplementation( - async (_command: string, args: string[]) => - policyResult(args) ?? resultWithBlueprintPolicyAuthority(args, success), - ); - await expect(actionRollback("sandbox-authority-drift")).rejects.toThrow( /mutable sandbox and provider names/u, ); @@ -1167,7 +960,9 @@ describe("blueprint identity wrapper", () => { attachment_created: false, }); - await expect(actionRollback(plan.run_id)).rejects.toThrow(/mutable sandbox and provider names/u); + await expect(actionRollback(plan.run_id)).rejects.toThrow( + /mutable sandbox and provider names/u, + ); expect(store.get(`/fakehome/.nemoclaw/state/runs/${plan.run_id}/rolled_back`)).toBeUndefined(); }); @@ -1209,7 +1004,9 @@ describe("blueprint identity wrapper", () => { attachment_created: true, }); - await expect(actionRollback(plan.run_id)).rejects.toThrow(/mutable sandbox and provider names/u); + await expect(actionRollback(plan.run_id)).rejects.toThrow( + /mutable sandbox and provider names/u, + ); expect(store.get(`/fakehome/.nemoclaw/state/runs/${plan.run_id}/rolled_back`)).toBeUndefined(); }); @@ -1232,7 +1029,6 @@ describe("blueprint identity wrapper", () => { inference_provider_created_by_apply: true, inference: { provider_name: "test-provider", provider_type: "openai" }, identity: receipt, - policy_authority: managedPolicyAuthorityReceipt(), }), }); responseQueue([ diff --git a/nemoclaw/src/blueprint/runner-mock-fixtures.test.ts b/nemoclaw/src/blueprint/runner-mock-fixtures.test.ts index 13993ab6ac0..be4a31d44d6 100644 --- a/nemoclaw/src/blueprint/runner-mock-fixtures.test.ts +++ b/nemoclaw/src/blueprint/runner-mock-fixtures.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from "vitest"; -import { createRunnerFsStore, inMemoryFsMethods } from "./runner-mock-fixtures.js"; +import { + createRunnerFsStore, + inMemoryFsMethods, + throwOnCall, +} from "./runner-mock-fixtures.js"; +import { sandboxIdentityResult, sequentialCommandResult } from "./runner-test-fixtures.js"; describe("blueprint runner mock fixtures", () => { it("uses direct filesystem methods when no spy wrapper is supplied", () => { @@ -29,4 +34,30 @@ describe("blueprint runner mock fixtures", () => { expect(() => memory.fsyncSync(fd)).toThrow(`EBADF: ${fd}`); expect(() => memory.closeSync(fd)).toThrow(`EBADF: ${fd}`); }); + + it("throws only on the selected callback invocation", () => { + const failure = new Error("selected failure"); + const callback = throwOnCall(2, failure); + + expect(callback).not.toThrow(); + expect(callback).toThrow(failure); + expect(callback).not.toThrow(); + }); + + it("models sandbox identity and sequential command responses", () => { + expect(sandboxIdentityResult("alpha", "sandbox-7", "Stopped")).toEqual({ + exitCode: 0, + stdout: "Name: alpha\nId: sandbox-7\nPhase: Stopped\n", + stderr: "", + }); + + const first = { exitCode: 1, stdout: "", stderr: "not ready" }; + const second = { exitCode: 0, stdout: "ready", stderr: "" }; + const nextResult = sequentialCommandResult("sandbox get alpha", [first, second]); + + expect(nextResult(["sandbox", "get", "other"])).toBeUndefined(); + expect(nextResult(["sandbox", "get", "alpha"])).toBe(first); + expect(nextResult(["sandbox", "get", "alpha"])).toBe(second); + expect(nextResult(["sandbox", "get", "alpha"])).toBe(second); + }); }); diff --git a/nemoclaw/src/blueprint/runner-mock-fixtures.ts b/nemoclaw/src/blueprint/runner-mock-fixtures.ts index f38caab9f46..cc8d17b3624 100644 --- a/nemoclaw/src/blueprint/runner-mock-fixtures.ts +++ b/nemoclaw/src/blueprint/runner-mock-fixtures.ts @@ -89,6 +89,9 @@ export function inMemoryFsMethods(store: Map, options?: I store.set(destination, entry); store.delete(source); }), + unlinkSync: spy((target: string) => { + if (!store.delete(target)) return missingEntry(target); + }), readdirSync: (p: string) => { const prefix = p.endsWith("/") ? p : `${p}/`; const entries = new Set( diff --git a/nemoclaw/src/blueprint/runner-name-validation.test.ts b/nemoclaw/src/blueprint/runner-name-validation.test.ts index 48edd614989..257f3b5c88a 100644 --- a/nemoclaw/src/blueprint/runner-name-validation.test.ts +++ b/nemoclaw/src/blueprint/runner-name-validation.test.ts @@ -18,7 +18,7 @@ import { } from "./runner-mock-fixtures.js"; import { minimalBlueprint, - resultWithBlueprintPolicyAuthority, + resultWithBlueprintPolicy, successResult, TEST_SANDBOX_POLICY, TEST_SANDBOX_POLICY_PATH, @@ -47,6 +47,7 @@ vi.mock("node:fs", async (importOriginal) => { openSync: memory.openSync, readFileSync: memory.readFileSync, renameSync: memory.renameSync, + unlinkSync: memory.unlinkSync, writeFileSync: memory.writeFileSync, readdirSync: memory.readdirSync, }; @@ -128,7 +129,7 @@ describe("blueprint name validation (fail-closed integration)", () => { vi.clearAllMocks(); vi.spyOn(process.stdout, "write").mockImplementation(stdout.write); mockExeca.mockImplementation(async (_command: string, args: string[]) => - resultWithBlueprintPolicyAuthority(args, successResult()), + resultWithBlueprintPolicy(args, successResult()), ); }); diff --git a/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts b/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts index 99036121250..09d90e4c8d7 100644 --- a/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts +++ b/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts @@ -1,202 +1,144 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash } from "node:crypto"; import type fs from "node:fs"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRunnerFsStore, - createStdoutCapture, FAKE_HOME, FIXED_RUN_UUID, inMemoryFsMethods, resolvedEndpointFor, - throwOnCall, } from "./runner-mock-fixtures.js"; import { - createMutableSandboxPolicyResult, + absentGlobalPolicyHistoryResult, gatewayInfoResult, gatewayStatusResult, - globalPolicyAuthorityResult, + globalPolicyResult, minimalBlueprint, - sandboxIdentityResult, - sandboxPolicyAuthorityResult, - sequentialCommandResult, + sandboxPolicyResult, + successResult, TEST_SANDBOX_POLICY, TEST_SANDBOX_POLICY_PATH, } from "./runner-test-fixtures.js"; const { store } = createRunnerFsStore(); const mockExeca = vi.fn(); -const stdoutCapture = createStdoutCapture(); vi.mock("node:crypto", async (importOriginal) => ({ ...(await importOriginal()), randomUUID: () => FIXED_RUN_UUID, })); - -vi.mock("node:os", () => ({ - homedir: () => FAKE_HOME, -})); - +vi.mock("node:os", () => ({ homedir: () => FAKE_HOME })); vi.mock("node:fs", async (importOriginal) => { const original = await importOriginal(); const memory = inMemoryFsMethods(store, { spy: vi.fn }); return { ...original, - existsSync: memory.existsSync, closeSync: memory.closeSync, + existsSync: memory.existsSync, fsyncSync: memory.fsyncSync, mkdirSync: memory.mkdirSync, openSync: memory.openSync, readFileSync: memory.readFileSync, readdirSync: memory.readdirSync, renameSync: memory.renameSync, + unlinkSync: memory.unlinkSync, writeFileSync: memory.writeFileSync, }; }); - -vi.mock("execa", () => ({ - execa: (...args: unknown[]) => mockExeca(...args), +vi.mock("execa", () => ({ execa: (...args: unknown[]) => mockExeca(...args) })); +vi.mock("./ssrf.js", async (importOriginal) => ({ + ...(await importOriginal()), + validateEndpointUrl: vi.fn(async (url: string) => resolvedEndpointFor(url)), })); -vi.mock("./ssrf.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - validateEndpointUrl: vi.fn(async (url: string) => resolvedEndpointFor(url)), - }; -}); - -const { actionApply, actionReconcile, actionRollback, actionStatus, main } = - await import("./runner.js"); -const { fsyncSync } = await import("node:fs"); -const mockedFsyncSync = vi.mocked(fsyncSync); - -const BASE_POLICY = `version: 1 -future_policy: - opaque_setting: - keep: true -filesystem_policy: - default: deny - roots: [/sandbox] -metadata: - future_schema: opaque - preserve: true -network_policies: - existing_mcp: - endpoints: - - host: mcp.example.com - port: 443 - path: /mcp - protocol: mcp - enforcement: enforce - mcp: - allow_all_known_mcp_methods: true - max_body_bytes: 131072 - strict_tool_names: true - rules: - - allow: - method: tools/call - tool: - any: [search_web, list_tools] - - allow: - method: resources/read - deny_rules: - - method: tools/call - tool: - any: [send_email, delete_resource] - existing_json_rpc: - endpoints: - - host: rpc.example.com - port: 443 - path: /rpc - protocol: json-rpc - enforcement: enforce - json_rpc: { max_body_bytes: 131072 } - rules: - - allow: - method: { any: [reports.search, reports.get] } -`; - -const FULL_POLICY = `${BASE_POLICY} _provider_nvidia-inference: {} -`; - -function policyOutput(policy: string): string { - return ["Version: 1", "Hash: sha256:test", "---", policy].join("\n"); -} - -function policySetCalls(): unknown[][] { - return mockExeca.mock.calls.filter( - (call) => Array.isArray(call[1]) && call[1][0] === "policy" && call[1][1] === "set", - ); -} +const { actionApply, actionReconcile, actionStatus } = await import("./runner.js"); -const readMergedPolicy = (): Record => { - const merged = [...store.entries()].find(([path]) => path.endsWith("/merged-policy.yaml")); - return YAML.parse(merged?.[1].content ?? TEST_SANDBOX_POLICY); +const additions = { + nim_service: { + name: "nim_service", + endpoints: [{ host: "integrate.api.nvidia.com", port: 443, access: "full" as const }], + }, }; -let defaultCommandResult = createMutableSandboxPolicyResult(readMergedPolicy); -function mergedPolicy(): Record { - const key = [...store.keys()].find((candidate) => candidate.endsWith("/merged-policy.yaml")); - expect(key).toBeDefined(); - return YAML.parse(store.get(key ?? "")?.content ?? ""); +function blueprint() { + const value = minimalBlueprint(); + const components = value.components as Record; + return { + ...value, + components: { ...components, policy: { additions } }, + } as Parameters[1]; } -function blueprint(): Parameters[1] { - return { - version: "1.0", - components: { - inference: { - profiles: { - default: { - provider_type: "openai", - provider_name: "my-provider", - endpoint: "https://api.example.com/v1", - model: "gpt-4", - credential_env: "MY_API_KEY", - }, - }, - }, - sandbox: { - image: "openclaw", - name: "test-sandbox", - forward_ports: [18789], - }, - policy: { - additions: { - nim_service: { - name: "nim_service", - endpoints: [{ host: "integrate.api.nvidia.com", port: 443, access: "full" }], - }, - }, - }, - }, - }; +function runDirectory(runId: string): string { + return `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; } -describe("OpenShell 0.0.72 blueprint policy round-trip", () => { +describe("blueprint policy convenience", () => { + let livePolicy: Record; + let globalActive: boolean; + let basePolicyFailure: string | null; + let basePolicyOutput: string | null; + beforeEach(() => { store.clear(); - defaultCommandResult = createMutableSandboxPolicyResult(readMergedPolicy); store.set(TEST_SANDBOX_POLICY_PATH, { type: "file", content: TEST_SANDBOX_POLICY }); vi.stubEnv("OPENSHELL_SANDBOX_POLICY", TEST_SANDBOX_POLICY_PATH); - stdoutCapture.reset(); - mockExeca.mockReset(); - vi.spyOn(process.stdout, "write").mockImplementation(stdoutCapture.write); - const policyByCommand = new Map([ - ["policy get -g test-gateway --base test-sandbox", policyOutput(BASE_POLICY)], - ["policy get -g test-gateway --full test-sandbox", policyOutput(FULL_POLICY)], - ]); - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { - const policy = policyByCommand.get(args.join(" ")); - return policy === undefined - ? defaultCommandResult(args) - : { exitCode: 0, stdout: policy, stderr: "" }; + livePolicy = { + version: 1, + future_section: { preserve: true }, + network_policies: { host_added: { endpoints: [{ host: "host.example", port: 443 }] } }, + }; + globalActive = false; + basePolicyFailure = null; + basePolicyOutput = null; + mockExeca.mockReset().mockImplementation(async (_command: string, args: string[]) => { + const joined = args.join(" "); + switch (joined) { + case "status": + return gatewayStatusResult(); + case "gateway info -g test-gateway": + return gatewayInfoResult(); + case "policy list -g test-gateway --global --limit 1": + return globalActive + ? { exitCode: 0, stdout: "revision 1", stderr: "" } + : absentGlobalPolicyHistoryResult(); + case "policy get -g test-gateway --global --full --output json": + return globalPolicyResult(livePolicy.network_policies as Record); + case "policy get -g test-gateway --full --output json test-sandbox": + return sandboxPolicyResult( + "test-sandbox", + globalActive ? "global" : "sandbox", + livePolicy.network_policies as Record, + livePolicy, + ); + case "policy get -g test-gateway --base test-sandbox": + return basePolicyFailure + ? { exitCode: 1, stdout: "", stderr: basePolicyFailure } + : { + exitCode: 0, + stdout: basePolicyOutput ?? YAML.stringify(livePolicy), + stderr: "", + }; + } + switch (`${args[0]} ${args[1]}`) { + case "policy set": { + const path = args[args.indexOf("--policy") + 1]; + livePolicy = YAML.parse(String(store.get(path)?.content ?? "")); + return successResult(); + } + case "provider get": + return { + exitCode: 0, + stdout: `Name: ${args[2]}\nType: openai\nCredential keys: OPENAI_API_KEY\nConfig keys: OPENAI_BASE_URL\n`, + stderr: "", + }; + default: + return successResult(); + } }); }); @@ -205,1283 +147,206 @@ describe("OpenShell 0.0.72 blueprint policy round-trip", () => { vi.unstubAllEnvs(); }); - it("preserves MCP, JSON-RPC, and unknown mapping sections without provider entries", async () => { + it("preserves host-side entries while applying blueprint additions", async () => { await actionApply("default", blueprint()); - - expect(mockExeca).toHaveBeenCalledWith( - "openshell", - ["policy", "get", "-g", "test-gateway", "--base", "test-sandbox"], - expect.objectContaining({ maxBuffer: 1024 * 1024, reject: false, timeout: 30_000 }), - ); - expect(mockExeca).toHaveBeenCalledWith( - "openshell", - ["sandbox", "get", "-g", "test-gateway", "test-sandbox"], - expect.objectContaining({ maxBuffer: 1024 * 1024, reject: false, timeout: 30_000 }), - ); - expect(mockExeca).toHaveBeenCalledWith( - "openshell", - ["gateway", "info", "-g", "test-gateway"], - expect.objectContaining({ maxBuffer: 1024 * 1024, reject: false, timeout: 30_000 }), - ); - expect(mockExeca).not.toHaveBeenCalledWith( - "openshell", - ["policy", "get", "-g", "test-gateway", "--full", "test-sandbox"], - expect.anything(), - ); - - const merged = mergedPolicy() as { - future_policy: { opaque_setting: { keep: boolean } }; - filesystem_policy: { default: string; roots: string[] }; - metadata: { future_schema: string; preserve: boolean }; - network_policies: Record; - }; - expect(merged.future_policy).toEqual({ opaque_setting: { keep: true } }); - expect(merged.filesystem_policy).toEqual({ default: "deny", roots: ["/sandbox"] }); - expect(merged.metadata).toEqual({ future_schema: "opaque", preserve: true }); - expect(merged.network_policies).toEqual({ - ...YAML.parse(BASE_POLICY).network_policies, - nim_service: expect.any(Object), - }); - expect(merged.network_policies).not.toHaveProperty("_provider_nvidia-inference"); - const planEntry = [...store.values()].find((entry) => - entry.content?.includes('"policy_transition"'), - ); - expect(JSON.parse(planEntry?.content ?? "{}")).toMatchObject({ - policy_transition: { - status: "complete", - sandbox_name: "test-sandbox", - gateway: "test-gateway", - expected_authority: "nemoclaw-managed", - policy_addition_names: ["nim_service"], - }, - policy_authority: { - policy_creation_receipt: { - policyHash: "sha256:updated-policy", - policyVersion: 2, - }, - }, - }); - }); - - it.each([ - ["scalar", "future_mode", "future_mode: strict\n"], - ["sequence", "future_features", "future_features: [audit, attribution]\n"], - ])("fails closed for an unknown top-level %s", async (_shape, key, fragment) => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --base test-sandbox" - ? { exitCode: 0, stdout: policyOutput(`${fragment}${BASE_POLICY}`), stderr: "" } - : defaultCommandResult(args), - ); - - await expect(actionApply("default", blueprint())).rejects.toThrow( - `Current policy top-level field "${key}" must be a YAML mapping`, - ); - expect(policySetCalls()).toEqual([]); - }); - - it("fails closed when policy get --base fails", async () => { - const diagnostic = `MY_API_KEY=super-secret ${"policy details ".repeat(80)}`; - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --base test-sandbox" - ? { exitCode: 1, stdout: "", stderr: diagnostic } - : defaultCommandResult(args), - ); - const error = await actionApply("default", blueprint()).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toMatch( - /^OpenShell policy receipt inspection failed\.; automatic cleanup was refused/u, - ); - expect((error as Error).message).not.toContain("MY_API_KEY"); - expect((error as Error).message).not.toContain("super-secret"); - expect((error as Error).message.length).toBeLessThan(600); - expect(policySetCalls()).toEqual([]); - }); - - it("fails closed when policy get --base returns metadata without a policy document", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --base test-sandbox" - ? { exitCode: 0, stdout: "Version: 1\nHash: sha256:test\n", stderr: "" } - : defaultCommandResult(args), - ); - - await expect(actionApply("default", blueprint())).rejects.toThrow( - /does not contain a policy YAML document/, + expect(livePolicy).toEqual( + expect.objectContaining({ + future_section: { preserve: true }, + network_policies: expect.objectContaining({ + host_added: expect.any(Object), + nim_service: additions.nim_service, + }), + }), ); - expect(policySetCalls()).toEqual([]); + expect([...store.keys()].some((path) => path.endsWith("policy-update.yaml"))).toBe(false); }); - it("filters a malformed provider-composed entry returned by --base", async () => { - const malformedBase = YAML.parse(BASE_POLICY); - malformedBase.network_policies["_provider_unexpected"] = { - endpoints: [{ host: "provider.invalid", port: 443, access: "full" }], + it("skips the convenience mutation when OpenShell already contains the requirement", async () => { + livePolicy.network_policies = { + ...(livePolicy.network_policies as Record), + nim_service: additions.nim_service, }; - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --base test-sandbox" - ? { exitCode: 0, stdout: policyOutput(YAML.stringify(malformedBase)), stderr: "" } - : defaultCommandResult(args), - ); await actionApply("default", blueprint()); - const merged = mergedPolicy() as { - network_policies: Record; - }; - expect(merged.network_policies).not.toHaveProperty("_provider_unexpected"); - expect(merged.network_policies).toHaveProperty("existing_mcp"); - expect(merged.network_policies).toHaveProperty("existing_json_rpc"); - }); - - it("filters reserved provider entries from the final blueprint mutation payload", async () => { - const blueprintWithReservedAddition = blueprint(); - blueprintWithReservedAddition.components!.policy!.additions!._provider_injected = { - name: "must-not-submit", - endpoints: [{ host: "provider.invalid", port: 443, access: "full" }], - }; - - await actionApply("default", blueprintWithReservedAddition); - - const merged = mergedPolicy() as { - network_policies: Record; - }; - expect(merged.network_policies).not.toHaveProperty("_provider_injected"); - expect(merged.network_policies).toHaveProperty("nim_service"); - }); - - it("fails closed for a legacy network_policies array instead of dropping it", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --base test-sandbox" - ? { - exitCode: 0, - stdout: policyOutput("version: 1\nnetwork_policies:\n - name: legacy\n"), - stderr: "", - } - : defaultCommandResult(args), - ); - - await expect(actionApply("default", blueprint())).rejects.toThrow( - /network_policies must be a YAML mapping/, - ); - expect(policySetCalls()).toEqual([]); - }); - - it("keeps a verified global policy read-only before blueprint effects (#9833)", async () => { - const bp = blueprint(); - const additions = bp.components!.policy!.additions!; - vi.stubEnv("OPENSHELL_SANDBOX_POLICY", "/tmp/caller-policy.yaml"); - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "status" - ? gatewayStatusResult("recorded-gateway") - : args.join(" ") === "gateway info -g recorded-gateway" - ? gatewayInfoResult() - : args.join(" ") === "policy list -g recorded-gateway --global --limit 1" - ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } - : args.join(" ") === "policy get -g recorded-gateway --global --full --output json" - ? globalPolicyAuthorityResult(additions) - : args.join(" ") === "sandbox get -g recorded-gateway test-sandbox" - ? sandboxIdentityResult("test-sandbox") - : args.join(" ") === - "policy get -g recorded-gateway --full --output json test-sandbox" - ? sandboxPolicyAuthorityResult("test-sandbox", "externally-managed", additions) - : { exitCode: 0, stdout: "", stderr: "" }, - ); - - await actionApply("default", bp); - - const policyCalls = mockExeca.mock.calls.filter( - (call) => Array.isArray(call[1]) && call[1][0] === "policy", - ); - expect(policyCalls.every((call) => call[1].includes("recorded-gateway"))).toBe(true); - expect(policySetCalls()).toEqual([]); - const sandboxCreate = mockExeca.mock.calls.find( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ); - expect(sandboxCreate?.[2].env).not.toHaveProperty("OPENSHELL_SANDBOX_POLICY"); - }); - - it("refuses missing external additions before creating a sandbox (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "status" - ? gatewayStatusResult() - : args.join(" ") === "gateway info -g test-gateway" - ? gatewayInfoResult() - : args.join(" ") === "policy list -g test-gateway --global --limit 1" - ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } - : args.join(" ") === "policy get -g test-gateway --global --full --output json" - ? globalPolicyAuthorityResult() - : { exitCode: 0, stdout: "", stderr: "" }, - ); - - await expect(actionApply("default", blueprint())).rejects.toThrow( - /missing entries "nim_service"/, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); - }); - it("fails closed on malformed global authority before sandbox creation (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "status" - ? gatewayStatusResult() - : args.join(" ") === "gateway info -g test-gateway" - ? gatewayInfoResult() - : args.join(" ") === "policy list -g test-gateway --global --limit 1" - ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } - : args.join(" ") === "policy get -g test-gateway --global --full --output json" - ? { exitCode: 0, stdout: "{", stderr: "" } - : { exitCode: 0, stdout: "", stderr: "" }, - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /malformed global policy authority metadata/, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it("fails closed when global metadata does not name its policy source (#9833)", async () => { - const globalResult = globalPolicyAuthorityResult(); - const metadata = JSON.parse(globalResult.stdout) as Record; - delete metadata.policy_source; - const results = new Map([ - ["status", gatewayStatusResult()], - ["gateway info -g test-gateway", gatewayInfoResult()], - [ - "policy list -g test-gateway --global --limit 1", - { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" }, - ], - [ - "policy get -g test-gateway --global --full --output json", - { ...globalResult, stdout: JSON.stringify(metadata) }, - ], - ]); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => - results.get(args.join(" ")) ?? { exitCode: 0, stdout: "", stderr: "" }, - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /invalid global policy authority metadata/u, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it.each([ - [ - "a failed status read", - (args: string[]) => - args.join(" ") === "status" - ? { exitCode: 1, stdout: "", stderr: "gateway unavailable" } - : defaultCommandResult(args), - /Failed to inspect the active OpenShell gateway/u, - ], - [ - "a disconnected status", - (args: string[]) => - args.join(" ") === "status" - ? { exitCode: 0, stdout: "Status: Disconnected\nGateway: test-gateway\n", stderr: "" } - : defaultCommandResult(args), - /Failed to prove the active OpenShell gateway identity/u, - ], - [ - "a missing gateway endpoint", - (args: string[]) => - args.join(" ") === "gateway info -g test-gateway" - ? { exitCode: 0, stdout: "Gateway ready\n", stderr: "" } - : defaultCommandResult(args), - /did not report one gateway endpoint/u, - ], - [ - "an invalid gateway endpoint", - (args: string[]) => - args.join(" ") === "gateway info -g test-gateway" - ? { exitCode: 0, stdout: "Gateway endpoint: not-a-url\n", stderr: "" } - : defaultCommandResult(args), - /reported an invalid gateway endpoint/u, - ], - [ - "an unsupported gateway endpoint protocol", - (args: string[]) => - args.join(" ") === "gateway info -g test-gateway" - ? { exitCode: 0, stdout: "Gateway endpoint: ftp:\/\/127.0.0.1:8080\n", stderr: "" } - : defaultCommandResult(args), - /unsupported gateway endpoint protocol/u, - ], - [ - "an invalid gateway endpoint port", - (args: string[]) => - args.join(" ") === "gateway info -g test-gateway" - ? { exitCode: 0, stdout: "Gateway endpoint: http:\/\/127.0.0.1:0\n", stderr: "" } - : defaultCommandResult(args), - /reported an invalid gateway port/u, - ], - [ - "a non-loopback gateway endpoint", - (args: string[]) => - args.join(" ") === "gateway info -g test-gateway" - ? { exitCode: 0, stdout: "Gateway endpoint: http:\/\/192.0.2.10:8080\n", stderr: "" } - : defaultCommandResult(args), - /reported an unsupported local gateway endpoint/u, - ], - ])("stops before effects for %s (#9833)", async (_caseName, result, expected) => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => result(args)); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow(expected); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it("redacts a thrown gateway receipt inspection before effects (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "gateway info -g test-gateway" - ? Promise.reject(new Error("GATEWAY_TOKEN=must-not-appear")) - : defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - "OpenShell gateway receipt inspection failed.", - ); expect( mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", + ([, args]) => Array.isArray(args) && args[0] === "policy" && args[1] === "set", ), ).toBe(false); }); - it.each([ - [ - "throws", - async () => { - throw new Error("POLICY_TOKEN=must-not-appear"); - }, - ], - [ - "returns a failure", - async () => ({ exitCode: 1, stdout: "", stderr: "POLICY_TOKEN=must-not-appear" }), - ], - ])("redacts a global authority command that %s (#9833)", async (_caseName, result) => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy list -g test-gateway --global --limit 1" - ? result() - : defaultCommandResult(args), - ); - - const error = await actionApply("default", minimalBlueprint()).catch( - (caught: unknown) => caught, - ); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe( - "OpenShell global policy authority inspection failed. Policy-dependent operations must stop.", - ); - expect((error as Error).message).not.toContain("must-not-appear"); - }); + it("rejects a scalar future policy section before writing", async () => { + livePolicy.future_section = "unreviewed-scalar"; - it.each([ - ["empty", "", /empty global policy authority metadata/u], - [ - "invalid identity", - JSON.stringify({ - scope: "global", - status: "loaded", - policy_source: "global", - hash: "", - active_version: 0, - policy: {}, - }), - /invalid global policy authority metadata/u, - ], - [ - "non-mapping policy", - JSON.stringify({ - scope: "global", - status: "loaded", - policy_source: "global", - hash: "sha256:global-policy", - active_version: 1, - policy: [], - }), - /invalid global policy authority metadata/u, - ], - ])("fails closed on %s active global metadata (#9833)", async (_caseName, stdout, expected) => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy list -g test-gateway --global --limit 1" - ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } - : args.join(" ") === "policy get -g test-gateway --global --full --output json" - ? { exitCode: 0, stdout, stderr: "" } - : defaultCommandResult(args), + await expect(actionApply("default", blueprint())).rejects.toThrow( + /future_section.*must be a YAML mapping/, ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow(expected); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); }); - it("treats a superseded global revision as absent and supplies the managed policy (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy list -g test-gateway --global --limit 1" - ? { exitCode: 0, stdout: "VERSION STATUS\n1 superseded\n", stderr: "" } - : args.join(" ") === "policy get -g test-gateway --global --full --output json" - ? { - exitCode: 0, - stdout: JSON.stringify({ - scope: "global", - status: "superseded", - policy_source: "global", - }), - stderr: "", - } - : defaultCommandResult(args), + it("surfaces a failed OpenShell policy write", async () => { + const implementation = mockExeca.getMockImplementation(); + expect(implementation).toBeDefined(); + mockExeca.mockImplementation(async (command: string, args: string[]) => + args[0] === "policy" && args[1] === "set" + ? { exitCode: 1, stdout: "", stderr: "write rejected" } + : implementation!(command, args), ); - await actionApply("default", minimalBlueprint()); - - expect(mockExeca).toHaveBeenCalledWith( - "openshell", - expect.arrayContaining(["sandbox", "create", "--policy", TEST_SANDBOX_POLICY_PATH]), - expect.anything(), + await expect(actionApply("default", blueprint())).rejects.toThrow( + /Failed to apply policy additions: write rejected/, ); }); - it.each([ - ["cannot be read", "/tmp/missing-policy.yaml", () => {}, /could not be read/u], - [ - "is invalid", - TEST_SANDBOX_POLICY_PATH, - () => store.set(TEST_SANDBOX_POLICY_PATH, { type: "file", content: "version: [" }), - /is invalid/u, - ], - ])( - "stops before create when the configured policy %s (#9833)", - async (_caseName, path, prepare, expected) => { - vi.stubEnv("OPENSHELL_SANDBOX_POLICY", path); - prepare(); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow(expected); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); - }, - ); - - it("stops before provider and policy mutation when sandbox authority is malformed (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + it("fails closed when policy inspection throws", async () => { + const implementation = mockExeca.getMockImplementation(); + expect(implementation).toBeDefined(); + mockExeca.mockImplementation(async (command: string, args: string[]) => args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" - ? { exitCode: 0, stdout: "{", stderr: "" } - : defaultCommandResult(args), + ? Promise.reject(new Error("transport interrupted")) + : implementation!(command, args), ); - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /malformed sandbox policy authority metadata/, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][1] === "create", - ), - ).toBe(false); - expect(policySetCalls()).toEqual([]); - }); - - function policyCreationReceipt(overrides: Record = {}) { - return { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "test-gateway", - gatewayPort: 8080, - sandboxName: "test-sandbox", - lifecycleGeneration: FIXED_RUN_UUID, - sandboxIdentityFingerprint: createHash("sha256").update("sandbox-id-1").digest("hex"), - policyHash: "sha256:test-policy", - policyVersion: 1, - ...overrides, - }; - } - - function targetPolicy(additions = blueprint().components!.policy!.additions!) { - return { version: 1, network_policies: additions }; - } - - function targetPolicyDigest(policy: Record): string { - const stable = (value: unknown): unknown => - Array.isArray(value) - ? value.map(stable) - : value !== null && typeof value === "object" - ? Object.fromEntries( - Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => [key, stable(child)]), - ) - : value; - return createHash("sha256") - .update(JSON.stringify(stable(policy))) - .digest("hex"); - } - - function managedPolicyAuthority() { - return { - authority: "nemoclaw-managed", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - scope: "sandbox", - sandbox_name: "test-sandbox", - policy_creation_receipt: policyCreationReceipt(), - }; - } - - function validReconciliationPlan() { - const policy = targetPolicy(); - return { - run_id: "reconcile-run", - sandbox_name: "test-sandbox", - sandbox_created_by_apply: true, - policy_additions: blueprint().components!.policy!.additions!, - policy_authority: managedPolicyAuthority(), - policy_transition: { - status: "incomplete", - sandbox_name: "test-sandbox", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - expected_authority: "nemoclaw-managed", - policy_addition_names: ["nim_service"], - target_policy_digest: targetPolicyDigest(policy), - }, - }; - } - - it("records ownership only for an explicit policy create on the exact gateway (#9833)", async () => { - await actionApply("default", minimalBlueprint()); - - expect(mockExeca).toHaveBeenCalledWith( - "openshell", - [ - "sandbox", - "create", - "-g", - "test-gateway", - "--from", - "openclaw", - "--name", - "test-sandbox", - "--policy", - TEST_SANDBOX_POLICY_PATH, - "--forward", - "18789", - ], - expect.objectContaining({ - reject: false, - env: expect.not.objectContaining({ OPENSHELL_SANDBOX_POLICY: expect.anything() }), - }), - ); - const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); - const plan = JSON.parse(planEntry?.[1].content ?? "{}"); - expect(plan).toMatchObject({ - sandbox_created_by_apply: true, - policy_authority: { - authority: "nemoclaw-managed", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - sandbox_name: "test-sandbox", - policy_creation_receipt: { - origin: "sandbox-create", - gatewayName: "test-gateway", - gatewayPort: 8080, - sandboxName: "test-sandbox", - policyHash: "sha256:test-policy", - policyVersion: 1, - }, - }, - }); - expect(plan).not.toHaveProperty("policy_creation_transition"); - expect(JSON.stringify(plan.policy_authority)).not.toContain(TEST_SANDBOX_POLICY_PATH); - expect(JSON.stringify(plan.policy_authority)).not.toContain("network_policies"); - }); - - it("stops before create when no configured policy or verified global policy exists (#9833)", async () => { - vi.stubEnv("OPENSHELL_SANDBOX_POLICY", ""); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /configured NemoClaw sandbox policy is required/u, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it("does not mint ownership when sandbox create reports already exists (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args[0] === "sandbox" && args[1] === "create" - ? { exitCode: 1, stdout: "", stderr: "sandbox already exists" } - : defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /already exists.*cannot establish NemoClaw policy ownership/u, - ); - const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); - const plan = JSON.parse(planEntry?.[1].content ?? "{}"); - expect(plan).toMatchObject({ - sandbox_created_by_apply: false, - policy_creation_transition: { - status: "incomplete", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - sandbox_name: "test-sandbox", - }, - }); - expect(plan).not.toHaveProperty("policy_authority"); - expect(policySetCalls()).toEqual([]); - expect( - mockExeca.mock.calls.some((call) => Array.isArray(call[1]) && call[1][0] === "provider"), - ).toBe(false); - }); - - it("refuses conflicting sandbox identity fields before provider effects (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "sandbox get -g test-gateway test-sandbox" - ? { - exitCode: 0, - stdout: "Name: test-sandbox\nId: sandbox-id\nId: replacement-id\nPhase: Ready\n", - stderr: "", - } - : defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /did not prove the immutable identity/u, - ); - expect( - mockExeca.mock.calls.some((call) => Array.isArray(call[1]) && call[1][0] === "provider"), - ).toBe(false); - }); - - it("stops before provider effects when the live sandbox identity changes (#9833)", async () => { - const identityResult = sequentialCommandResult("sandbox get -g test-gateway test-sandbox", [ - sandboxIdentityResult("test-sandbox"), - sandboxIdentityResult("test-sandbox", "replacement-id"), - ]); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => identityResult(args) ?? defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /receipt does not match the live sandbox policy/u, - ); - expect( - mockExeca.mock.calls.some((call) => Array.isArray(call[1]) && call[1][0] === "provider"), - ).toBe(false); - const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); - expect(JSON.parse(planEntry?.[1].content ?? "{}")).toMatchObject({ - sandbox_created_by_apply: true, - policy_authority: { authority: "nemoclaw-managed" }, - }); - }); - - it("stops before provider effects when the gateway binding changes (#9833)", async () => { - const gatewayResult = sequentialCommandResult("gateway info -g test-gateway", [ - gatewayInfoResult(), - gatewayInfoResult(), - gatewayInfoResult(9090), - ]); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => gatewayResult(args) ?? defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /gateway endpoint no longer matches the durable policy receipt/u, - ); - expect( - mockExeca.mock.calls.some((call) => Array.isArray(call[1]) && call[1][0] === "provider"), - ).toBe(false); - }); - - it("rejects a non-loopback gateway substitution on the receipt port (#9833)", async () => { - const gatewayResult = sequentialCommandResult("gateway info -g test-gateway", [ - gatewayInfoResult(), - gatewayInfoResult(), - gatewayInfoResult(8080, "gateway.example.com"), - ]); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => gatewayResult(args) ?? defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /unsupported local gateway endpoint/u, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it("stops before provider effects when the live policy identity changes (#9833)", async () => { - const policyResult = sequentialCommandResult( - "policy get -g test-gateway --full --output json test-sandbox", - [ - sandboxPolicyAuthorityResult( - "test-sandbox", - "nemoclaw-managed", - {}, - { version: 1, network_policies: {} }, - ), - sandboxPolicyAuthorityResult( - "test-sandbox", - "nemoclaw-managed", - {}, - { version: 1, network_policies: {} }, - "sha256:replacement-policy", - ), - ], - ); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => policyResult(args) ?? defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /receipt does not match the live sandbox policy/u, + await expect(actionApply("default", blueprint())).rejects.toThrow( + /sandbox policy inspection failed/, ); - expect( - mockExeca.mock.calls.some((call) => Array.isArray(call[1]) && call[1][0] === "provider"), - ).toBe(false); }); - it("rejects a created sandbox whose effective policy was not supplied by NemoClaw (#9833)", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + it("fails closed on malformed sandbox policy metadata", async () => { + const implementation = mockExeca.getMockImplementation(); + expect(implementation).toBeDefined(); + mockExeca.mockImplementation(async (command: string, args: string[]) => args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" - ? sandboxPolicyAuthorityResult("test-sandbox", "externally-managed") - : defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /did not prove the exact policy supplied by this NemoClaw create transaction/u, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it("rejects managed receipt reuse after the policy becomes global (#9833)", async () => { - const policyResult = sequentialCommandResult( - "policy get -g test-gateway --full --output json test-sandbox", - [ - sandboxPolicyAuthorityResult("test-sandbox"), - sandboxPolicyAuthorityResult("test-sandbox", "externally-managed"), - ], - ); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => policyResult(args) ?? defaultCommandResult(args), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /live sandbox policy is no longer sandbox-scoped/u, - ); - expect( - mockExeca.mock.calls.some( - (call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][1] === "create", - ), - ).toBe(false); - }); - - it("rejects a verified external boundary after its gateway binding changes (#9833)", async () => { - const additions = blueprint().components!.policy!.additions!; - const gatewayResult = sequentialCommandResult("gateway info -g test-gateway", [ - gatewayInfoResult(), - gatewayInfoResult(), - gatewayInfoResult(9090), - ]); - mockExeca.mockImplementation( - async (_cmd: string, args: string[]) => - gatewayResult(args) ?? - (args.join(" ") === "policy list -g test-gateway --global --limit 1" - ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } - : args.join(" ") === "policy get -g test-gateway --global --full --output json" - ? globalPolicyAuthorityResult(additions) - : args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" - ? sandboxPolicyAuthorityResult("test-sandbox", "externally-managed", additions) - : defaultCommandResult(args)), + ? { exitCode: 0, stdout: "{}", stderr: "" } + : implementation!(command, args), ); await expect(actionApply("default", blueprint())).rejects.toThrow( - /verified external policy boundary no longer matches/u, + /invalid sandbox policy metadata.*must stop/, ); - expect(policySetCalls()).toEqual([]); }); - it("reports incomplete creation when receipt directory durability fails (#9833)", async () => { - mockedFsyncSync.mockImplementation( - throwOnCall(6, new Error("simulated receipt directory fsync failure")), + it("fails closed on malformed active global policy metadata", async () => { + globalActive = true; + const implementation = mockExeca.getMockImplementation(); + expect(implementation).toBeDefined(); + mockExeca.mockImplementation(async (command: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --global --full --output json" + ? { exitCode: 0, stdout: "{}", stderr: "" } + : implementation!(command, args), ); - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /simulated receipt directory fsync failure/u, + await expect(actionApply("default", blueprint())).rejects.toThrow( + /invalid global policy metadata.*must stop/, ); - expect(stdoutCapture.text()).not.toContain("Apply complete"); - const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); - const plan = JSON.parse(planEntry?.[1].content ?? "{}"); - expect(plan).toMatchObject({ - sandbox_created_by_apply: true, - policy_creation_transition: { status: "incomplete" }, - }); - expect(plan).not.toHaveProperty("policy_authority"); }); - it("reports incomplete mutation when receipt rotation durability fails (#9833)", async () => { - mockedFsyncSync.mockImplementation( - throwOnCall(16, new Error("simulated rotated receipt directory fsync failure")), + it("fails closed on ambiguous global policy history", async () => { + const implementation = mockExeca.getMockImplementation(); + expect(implementation).toBeDefined(); + mockExeca.mockImplementation(async (command: string, args: string[]) => + args.join(" ") === "policy list -g test-gateway --global --limit 1" + ? { exitCode: 0, stdout: "", stderr: "unexpected diagnostic" } + : implementation!(command, args), ); await expect(actionApply("default", blueprint())).rejects.toThrow( - /simulated rotated receipt directory fsync failure/u, + /invalid global policy history.*must stop/, ); - expect(stdoutCapture.text()).not.toContain("Apply complete"); - const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); - expect(JSON.parse(planEntry?.[1].content ?? "{}")).toMatchObject({ - policy_authority: { - policy_creation_receipt: { - policyHash: "sha256:updated-policy", - policyVersion: 2, - }, - }, - policy_transition: { status: "incomplete" }, - }); }); - it("keeps the policy transition incomplete when policy set fails (#9833)", async () => { - const diagnostic = `POLICY_TOKEN=super-secret ${"policy details ".repeat(80)}`; - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args[0] === "policy" && args[1] === "set" - ? { exitCode: 1, stdout: "", stderr: diagnostic } - : args.join(" ") === "policy get -g test-gateway --base test-sandbox" - ? { exitCode: 0, stdout: policyOutput(TEST_SANDBOX_POLICY), stderr: "" } - : defaultCommandResult(args), + it("accepts a global OpenShell policy and still uses the same convenience mutation", async () => { + globalActive = true; + await actionApply("default", blueprint()); + expect((livePolicy.network_policies as Record).nim_service).toEqual( + additions.nim_service, ); + }); - const error = await actionApply("default", blueprint()).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("Failed to apply policy additions"); - expect((error as Error).message).toContain("POLICY_TOKEN="); - expect((error as Error).message).not.toContain("super-secret"); + it("persists gateway and requested additions without policy ownership state", async () => { + await actionApply("default", blueprint()); const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); const plan = JSON.parse(planEntry?.[1].content ?? "{}"); - expect(plan).toMatchObject({ - policy_transition: { - status: "pending", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - policy_addition_names: ["nim_service"], - }, - }); - }); - - it.each([ - ["a non-object plan", []], - ["a missing authority receipt", { ...validReconciliationPlan(), policy_authority: undefined }], - [ - "an external authority receipt", - { - ...validReconciliationPlan(), - policy_authority: { - authority: "externally-managed", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - scope: "global", - }, - }, - ], - [ - "an invalid transition receipt", - { ...validReconciliationPlan(), policy_transition: undefined }, - ], - [ - "a transition for another sandbox", - { - ...validReconciliationPlan(), - policy_transition: { - ...validReconciliationPlan().policy_transition, - sandbox_name: "replacement-sandbox", - }, - }, - ], - [ - "a transition for another gateway", - { - ...validReconciliationPlan(), - policy_transition: { - ...validReconciliationPlan().policy_transition, - gateway: "replacement-gateway", - }, - }, - ], - ["invalid policy additions", { ...validReconciliationPlan(), policy_additions: [] }], - ["mismatched policy additions", { ...validReconciliationPlan(), policy_additions: {} }], - [ - "a policy target with a mismatched digest", - { - ...validReconciliationPlan(), - policy_transition: { - ...validReconciliationPlan().policy_transition, - target_policy_digest: "a".repeat(64), - }, - }, - ], - ])("refuses reconciliation from %s (#9833)", async (caseName, plan) => { - const runId = `invalid-reconciliation-${caseName.replaceAll(" ", "-")}`; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { type: "file", content: JSON.stringify(plan) }); - store.set(`${stateDir}/merged-policy.yaml`, { - type: "file", - content: YAML.stringify(targetPolicy()), - }); - - await expect(actionReconcile(runId)).rejects.toThrow( - new RegExp(`Cannot read reconciliation plan for run ${runId}`), - ); - expect(mockExeca).not.toHaveBeenCalled(); + expect(plan.gateway).toEqual({ name: "test-gateway", host: "127.0.0.1", port: 8080 }); + expect(plan.policy_additions).toEqual(additions); + expect(plan).not.toHaveProperty("policy_authority"); + expect(plan).not.toHaveProperty("policy_transition"); }); - it("reconciles only the exact intended policy and rotates the receipt (#9833)", async () => { - const runId = "incomplete-transition"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - const policy = targetPolicy(); - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/merged-policy.yaml`, { - type: "file", - content: YAML.stringify(policy), - }); - store.set(`${stateDir}/plan.json`, { + it("reconcile rereads OpenShell and applies missing requirements", async () => { + const runId = "reconcile-live"; + const directory = runDirectory(runId); + store.set(directory, { type: "dir" }); + store.set(`${directory}/plan.json`, { type: "file", content: JSON.stringify({ run_id: runId, sandbox_name: "test-sandbox", - sandbox_created_by_apply: true, - inference_provider_created_by_apply: false, - policy_additions: blueprint().components!.policy!.additions!, - policy_authority: managedPolicyAuthority(), - policy_transition: { - status: "incomplete", - sandbox_name: "test-sandbox", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - expected_authority: "nemoclaw-managed", - policy_addition_names: ["nim_service"], - target_policy_digest: targetPolicyDigest(policy), - }, + gateway: { name: "test-gateway", host: "127.0.0.1", port: 8080 }, + policy_additions: additions, }), }); - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" - ? sandboxPolicyAuthorityResult( - "test-sandbox", - "nemoclaw-managed", - policy.network_policies, - policy, - ) - : defaultCommandResult(args), + await actionReconcile(runId); + expect((livePolicy.network_policies as Record).nim_service).toEqual( + additions.nim_service, ); - await main(["reconcile", "--run-id", runId]); - expect(JSON.parse(store.get(`${stateDir}/plan.json`)?.content ?? "{}")).toMatchObject({ - policy_authority: { - policy_creation_receipt: { - policyHash: "sha256:test-policy", - policyVersion: 1, - }, - }, - policy_transition: { status: "complete" }, - }); }); - it("refuses reconcile when the durable target does not match live policy (#9833)", async () => { - const runId = "mismatched-transition"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - const policy = targetPolicy(); - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/merged-policy.yaml`, { + it("status omits legacy policy lifecycle fields", async () => { + const runId = "status-live"; + const directory = runDirectory(runId); + store.set(directory, { type: "dir" }); + store.set(`${directory}/plan.json`, { type: "file", - content: YAML.stringify(policy), - }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - sandbox_name: "test-sandbox", - sandbox_created_by_apply: true, - policy_additions: blueprint().components!.policy!.additions!, - policy_authority: managedPolicyAuthority(), - policy_transition: { - status: "incomplete", - sandbox_name: "test-sandbox", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - expected_authority: "nemoclaw-managed", - policy_addition_names: ["nim_service"], - target_policy_digest: targetPolicyDigest(policy), - }, - }), + content: JSON.stringify({ run_id: runId, policy_additions: additions }), }); - - await expect(actionReconcile(runId)).rejects.toThrow(/exact intended policy/u); - expect(JSON.parse(store.get(`${stateDir}/plan.json`)?.content ?? "{}")).toMatchObject({ - policy_transition: { status: "incomplete" }, - }); - }); - - it("rejects an invalid persisted policy creation transition (#9833)", async () => { - const runId = "invalid-creation"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - sandbox_name: "test-sandbox", - policy_creation_transition: { - status: "complete", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - sandbox_name: "test-sandbox", - lifecycle_generation: FIXED_RUN_UUID, - }, - }), + const writes: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + writes.push(String(chunk)); + return true; }); - - stdoutCapture.reset(); actionStatus(runId); - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: runId, - status: "unknown", - receipt_error_kind: "invalid", - }); - await expect(actionRollback(runId)).rejects.toThrow(/policy creation transition is invalid/u); - expect(mockExeca).not.toHaveBeenCalled(); + const output = writes.join(""); + expect(output).toContain('"policy_additions"'); + expect(output).not.toContain("policy_authority"); + expect(output).not.toContain("policy_transition"); }); - it("reports a non-object run receipt as invalid (#9833)", () => { - const runId = "non-object-receipt"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { type: "file", content: "[]" }); - - stdoutCapture.reset(); - actionStatus(runId); - - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: runId, - status: "unknown", - receipt_error_kind: "invalid", - }); - }); - - it("reports a pending policy creation checkpoint without granting authority (#9833)", () => { - const runId = "pending-policy-creation"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - policy_creation_transition: { - status: "pending", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - sandbox_name: "test-sandbox", - lifecycle_generation: FIXED_RUN_UUID, - }, - }), - }); - stdoutCapture.reset(); - actionStatus(runId); - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: runId, - policy_creation_transition: { status: "pending" }, - }); - expect(stdoutCapture.jsonOutput()).not.toHaveProperty("policy_authority"); - }); - - it("reports an invalid policy transition receipt without exposing it (#9833)", () => { - const runId = "invalid-policy-transition"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - policy_transition: { status: "pending" }, - }), - }); - - stdoutCapture.reset(); - actionStatus(runId); - - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: runId, - status: "unknown", - receipt_error_kind: "invalid", - }); + it("fails closed when OpenShell cannot return a base policy", async () => { + basePolicyFailure = "gateway unavailable"; + await expect(actionApply("default", blueprint())).rejects.toThrow(); + expect((livePolicy.network_policies as Record).nim_service).toBeUndefined(); }); - it("reports the exact recovery action for an incomplete policy transition (#9833)", () => { - const runId = "incomplete-policy-transition"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ ...validReconciliationPlan(), run_id: runId }), - }); - stdoutCapture.reset(); - actionStatus(runId); - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: runId, - policy_transition: { - status: "incomplete", - reconciliation_required: true, - reconciliation_action: expect.stringMatching( - /blueprint runner integration.*reconcile.*incomplete-policy-transition.*no standalone/su, - ), + it.each([ + ["metadata only", "Version: 1\n---\n"], + ["malformed YAML", "version: [unterminated"], + ["array network policies", "version: 1\nnetwork_policies: []\n"], + ])("rejects an invalid base policy: %s", async (_case, output) => { + basePolicyOutput = output; + await expect(actionApply("default", blueprint())).rejects.toThrow(); + expect((livePolicy.network_policies as Record).nim_service).toBeUndefined(); + }); + + it("strips provider-composed entries from the mutation payload", async () => { + livePolicy = { + version: 1, + future_section: { preserve: true }, + network_policies: { + host_added: { endpoints: [{ host: "host.example", port: 443 }] }, + _provider_token: { endpoints: [{ host: "credential.internal", port: 443 }] }, }, - }); - }); - - it("rejects a malformed managed receipt before rollback mutation (#9833)", async () => { - const runId = "malformed-receipt"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - sandbox_name: "test-sandbox", - sandbox_created_by_apply: true, - policy_authority: { - ...managedPolicyAuthority(), - policy_creation_receipt: { status: "pending" }, - }, - }), - }); - - await expect(actionRollback(runId)).rejects.toThrow(/policy authority receipt is invalid/u); - expect(mockExeca).not.toHaveBeenCalled(); - }); - - it("rejects a receipt whose wrapper names a different sandbox (#9833)", async () => { - const runId = "mismatched-wrapper"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - sandbox_name: "replacement-sandbox", - sandbox_created_by_apply: true, - policy_authority: { - ...managedPolicyAuthority(), - sandbox_name: "replacement-sandbox", - }, - }), - }); - - await expect(actionRollback(runId)).rejects.toThrow(/policy authority receipt is invalid/u); - expect(mockExeca).not.toHaveBeenCalled(); - }); - - it("does not report an unexpected receipt field through status (#9833)", () => { - const runId = "extended-receipt"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - policy_authority: { - ...managedPolicyAuthority(), - credential_value: "must-not-appear", - }, - }), - }); + }; - stdoutCapture.reset(); - actionStatus(runId); - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: runId, - status: "unknown", - receipt_error_kind: "invalid", - }); - expect(stdoutCapture.text()).not.toContain("must-not-appear"); - }); + await actionApply("default", blueprint()); - it("revalidates a complete transition before reporting it complete (#9833)", async () => { - const runId = "complete-transition"; - const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; - const policy = targetPolicy(); - store.set(stateDir, { type: "dir" }); - store.set(`${stateDir}/merged-policy.yaml`, { - type: "file", - content: YAML.stringify(policy), - }); - store.set(`${stateDir}/plan.json`, { - type: "file", - content: JSON.stringify({ - run_id: runId, - sandbox_name: "test-sandbox", - sandbox_created_by_apply: true, - policy_additions: blueprint().components!.policy!.additions!, - policy_authority: managedPolicyAuthority(), - policy_transition: { - status: "complete", - sandbox_name: "test-sandbox", - gateway: "test-gateway", - gateway_host: "127.0.0.1", - gateway_port: 8080, - expected_authority: "nemoclaw-managed", - policy_addition_names: ["nim_service"], - target_policy_digest: targetPolicyDigest(policy), - }, + expect(livePolicy.future_section).toEqual({ preserve: true }); + expect(livePolicy.network_policies).toEqual( + expect.objectContaining({ + host_added: expect.any(Object), + nim_service: additions.nim_service, }), - }); - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" - ? sandboxPolicyAuthorityResult( - "test-sandbox", - "nemoclaw-managed", - policy.network_policies, - policy, - ) - : defaultCommandResult(args), - ); - - await actionReconcile(runId); - expect(stdoutCapture.text()).toContain( - `Policy transition for run ${runId} is already complete.`, ); - await expect(main(["reconcile"])).rejects.toThrow(/--run-id is required/u); + expect(livePolicy.network_policies).not.toHaveProperty("_provider_token"); }); }); diff --git a/nemoclaw/src/blueprint/runner-status-recovery.test.ts b/nemoclaw/src/blueprint/runner-status-recovery.test.ts deleted file mode 100644 index ba9841c3ce8..00000000000 --- a/nemoclaw/src/blueprint/runner-status-recovery.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type fs from "node:fs"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - createRunnerFsStore, - createStdoutCapture, - FAKE_HOME, - inMemoryFsMethods, -} from "./runner-mock-fixtures.js"; - -const { store, addDir, addFile } = createRunnerFsStore(); -const { mockExeca } = vi.hoisted(() => ({ mockExeca: vi.fn() })); - -vi.mock("node:os", () => ({ homedir: () => FAKE_HOME })); -vi.mock("execa", () => ({ execa: mockExeca })); -vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); - const memory = inMemoryFsMethods(store, { spy: vi.fn }); - return { - ...original, - existsSync: memory.existsSync, - readFileSync: memory.readFileSync, - readdirSync: memory.readdirSync, - }; -}); - -const { actionRollback, actionStatus } = await import("./runner.js"); -const stdoutCapture = createStdoutCapture(); - -describe("blueprint runner status recovery", () => { - beforeEach(() => { - store.clear(); - mockExeca.mockReset(); - stdoutCapture.reset(); - vi.spyOn(process.stdout, "write").mockImplementation(stdoutCapture.write); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("prints the maintainer recovery action for an incomplete policy transition (#9833)", () => { - const rid = "nc-run-incomplete"; - const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/${rid}`; - addDir(runDir); - addFile( - `${runDir}/plan.json`, - JSON.stringify({ - run_id: rid, - policy_transition: { - status: "incomplete", - sandbox_name: "alpha", - gateway: "nemoclaw", - gateway_host: "127.0.0.1", - gateway_port: 8080, - expected_authority: "nemoclaw-managed", - policy_addition_names: ["github"], - target_policy_digest: "a".repeat(64), - }, - }), - ); - - actionStatus(rid); - - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: rid, - policy_transition: { - status: "incomplete", - reconciliation_required: true, - reconciliation_action: expect.stringMatching( - /blueprint runner integration.*reconcile.*nc-run-incomplete.*no standalone/su, - ), - }, - }); - }); - - it("reports blocked recovery for incomplete policy creation without claiming an unavailable cleanup action (#9833)", async () => { - const rid = "nc-run-incomplete-create"; - const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/${rid}`; - const identity = "b".repeat(64); - addDir(runDir); - addFile( - `${runDir}/plan.json`, - JSON.stringify({ - run_id: rid, - sandbox_name: "alpha", - sandbox_created_by_apply: true, - policy_creation_transition: { - status: "incomplete", - gateway: "nemoclaw", - gateway_host: "127.0.0.1", - gateway_port: 8080, - sandbox_name: "alpha", - lifecycle_generation: "11111111-1111-4111-8111-111111111111", - sandbox_identity_fingerprint: identity, - }, - }), - ); - - actionStatus(rid); - - expect(stdoutCapture.jsonOutput()).toMatchObject({ - run_id: rid, - policy_creation_transition: { - status: "incomplete", - recovery_required: true, - recovery_action: expect.stringContaining( - "no safe automatic or manual cleanup action is currently supported", - ), - sandbox_identity_fingerprint: identity, - }, - }); - const recovery = ( - stdoutCapture.jsonOutput() as { - policy_creation_transition: { recovery_action: string }; - } - ).policy_creation_transition.recovery_action; - expect(recovery).toContain(rid); - expect(recovery).toContain("nemoclaw"); - expect(recovery).toContain("alpha"); - expect(recovery).toContain("11111111-1111-4111-8111-111111111111"); - expect(recovery).toContain(identity); - expect(recovery).toContain("Recovery is blocked"); - expect(recovery).not.toContain("perform identity-bound recovery"); - await expect(actionRollback(rid)).rejects.toThrow(/policy creation is incomplete/u); - expect(mockExeca).not.toHaveBeenCalled(); - }); -}); diff --git a/nemoclaw/src/blueprint/runner-test-fixtures.ts b/nemoclaw/src/blueprint/runner-test-fixtures.ts index af6785bfa85..27f66fbca3e 100644 --- a/nemoclaw/src/blueprint/runner-test-fixtures.ts +++ b/nemoclaw/src/blueprint/runner-test-fixtures.ts @@ -82,7 +82,7 @@ export function resultForCommandFailure( command: readonly [string, string], stderr: string, ): { exitCode: number; stdout: string; stderr: string } { - return resultWithBlueprintPolicyAuthority( + return resultWithBlueprintPolicy( args, args[0] === command[0] && args[1] === command[1] ? { exitCode: 1, stdout: "", stderr } @@ -146,9 +146,9 @@ export function sandboxIdentityResult( } /** Machine-readable effective policy metadata for one sandbox. */ -export function sandboxPolicyAuthorityResult( +export function sandboxPolicyResult( sandboxName: string, - authority: "nemoclaw-managed" | "externally-managed" = "nemoclaw-managed", + policySource: "sandbox" | "global" = "sandbox", networkPolicies: Record = {}, effectivePolicy: Record = { version: 1, network_policies: networkPolicies }, policyHash = "sha256:test-policy", @@ -160,7 +160,7 @@ export function sandboxPolicyAuthorityResult( scope: "sandbox", sandbox: sandboxName, status: "effective", - policy_source: authority === "nemoclaw-managed" ? "sandbox" : "global", + policy_source: policySource, hash: policyHash, active_version: policyVersion, policy: effectivePolicy, @@ -169,10 +169,8 @@ export function sandboxPolicyAuthorityResult( }; } -/** Machine-readable external global policy metadata. */ -export function globalPolicyAuthorityResult( - networkPolicies: Record = {}, -): CommandResult { +/** Machine-readable global policy metadata. */ +export function globalPolicyResult(networkPolicies: Record = {}): CommandResult { return { exitCode: 0, stdout: JSON.stringify({ @@ -187,8 +185,8 @@ export function globalPolicyAuthorityResult( }; } -/** Standard gateway and policy-authority responses for blueprint apply tests. */ -export function resultWithBlueprintPolicyAuthority( +/** Standard gateway and policy responses for blueprint apply tests. */ +export function resultWithBlueprintPolicy( args: readonly string[], fallback: CommandResult, gateway = "test-gateway", @@ -207,7 +205,7 @@ export function resultWithBlueprintPolicyAuthority( args[5] === "--output" && args[6] === "json" && typeof args[7] === "string" - ? sandboxPolicyAuthorityResult(args[7]) + ? sandboxPolicyResult(args[7]) : args[0] === "sandbox" && args[1] === "get" && args[2] === "-g" && @@ -246,9 +244,9 @@ export function createMutableSandboxPolicyResult( return successResult(); } if (args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox") { - return sandboxPolicyAuthorityResult( + return sandboxPolicyResult( "test-sandbox", - "nemoclaw-managed", + "sandbox", (livePolicy.network_policies as Record | undefined) ?? {}, livePolicy, livePolicyHash, @@ -268,7 +266,7 @@ export function createMutableSandboxPolicyResult( stderr: "", }; } - return resultWithBlueprintPolicyAuthority(args, successResult()); + return resultWithBlueprintPolicy(args, successResult()); }; } diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index d43b8f7b9ee..c01592bc9d7 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -17,7 +17,7 @@ import { createMutableSandboxPolicyResult, minimalBlueprint, resultForCommandFailure, - resultWithBlueprintPolicyAuthority, + resultWithBlueprintPolicy, routedBlueprint, TEST_SANDBOX_POLICY, TEST_SANDBOX_POLICY_PATH, @@ -48,6 +48,7 @@ vi.mock("node:fs", async (importOriginal) => { openSync: memory.openSync, readFileSync: vi.fn(memory.readFileSync), renameSync: memory.renameSync, + unlinkSync: memory.unlinkSync, writeFileSync: memory.writeFileSync, readdirSync: memory.readdirSync, }; @@ -93,7 +94,7 @@ function mockCurrentPolicy(stdout: string): void { if (args.join(" ") === "policy get -g test-gateway --base test-sandbox") { return { exitCode: 0, stdout, stderr: "" }; } - return resultWithBlueprintPolicyAuthority(args, { + return resultWithBlueprintPolicy(args, { exitCode: 0, stdout: "", stderr: "", @@ -389,6 +390,7 @@ describe("runner", () => { ); expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); }); + }); describe("actionPlan", () => { @@ -546,7 +548,7 @@ describe("runner", () => { beforeEach(() => { captureStdout(); mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - resultWithBlueprintPolicyAuthority(args, { + resultWithBlueprintPolicy(args, { exitCode: 0, stdout: "", stderr: "", @@ -581,7 +583,7 @@ describe("runner", () => { vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://ambient-gateway.invalid"); vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true"); const commandResult = createMutableSandboxPolicyResult(() => { - const merged = [...store.entries()].find(([path]) => path.endsWith("merged-policy.yaml")); + const merged = [...store.entries()].find(([path]) => path.endsWith("policy-update.yaml")); return YAML.parse(merged?.[1].content ?? TEST_SANDBOX_POLICY); }); mockExeca.mockImplementation(async (_cmd: string, args: string[]) => @@ -808,17 +810,6 @@ describe("runner", () => { expect(policyCalls.some((call) => call[1][1] === "set")).toBe(false); }); - it("refuses to claim policy ownership when sandbox already exists", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - resultForCommandFailure(args, ["sandbox", "create"], "already exists"), - ); - - await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( - /already exists.*cannot establish NemoClaw policy ownership/u, - ); - expect(stdoutText()).not.toContain("Apply complete"); - }); - it("throws when sandbox creation fails with other error", async () => { mockExeca.mockImplementation(async (_cmd: string, args: string[]) => resultForCommandFailure(args, ["sandbox", "create"], "disk full"), @@ -912,7 +903,7 @@ describe("runner", () => { "inference", "inference_provider_created_by_apply", "policy_additions", - "policy_authority", + "gateway", "profile", "run_id", "sandbox_created_by_apply", @@ -1423,7 +1414,7 @@ describe("runner", () => { beforeEach(() => { captureStdout(); mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - resultWithBlueprintPolicyAuthority(args, { + resultWithBlueprintPolicy(args, { exitCode: 0, stdout: "", stderr: "", diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index eace7de99cd..ed317e3dfb5 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -12,7 +12,7 @@ * - exit code 0 = success, non-zero = failure */ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { closeSync, existsSync, @@ -22,6 +22,7 @@ import { readdirSync, readFileSync, renameSync, + unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -66,14 +67,11 @@ const sourceOrGeneratedOpenShellPolicyBoundary = default?: typeof importedOpenShellPolicyBoundary; }; const { - assertExternalPolicyRequirementContainment, - assertNemoClawPolicyCreationReceiptMatches, assertPolicyRequirementContainment, classifyOpenShellGlobalPolicyHistory, - parseActiveGlobalPolicyAuthorityMetadata, - parseNemoClawPolicyCreationReceipt, + parseActiveGlobalPolicyMetadata, parseOpenShellPolicy, - parseSandboxPolicyAuthorityMetadata, + parseSandboxPolicyMetadata, withoutProviderComposedPolicies, } = sourceOrGeneratedOpenShellPolicyBoundary.default ?? sourceOrGeneratedOpenShellPolicyBoundary; @@ -108,13 +106,10 @@ type RollbackPlanSource = { inference_provider_created_by_apply?: unknown; inference?: unknown; identity?: unknown; - policy_authority?: unknown; - policy_creation_transition?: unknown; - policy_transition?: unknown; -}; -type ReconciliationPlanSource = RollbackPlanSource & { + gateway?: unknown; policy_additions?: unknown; }; +type ReconciliationPlanSource = RollbackPlanSource; type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; type RestProtocol = "rest"; type EndpointEnforcement = "enforce" | "audit"; @@ -144,10 +139,8 @@ interface PolicyAddition { type PolicyAdditions = { [name: string]: PolicyAddition }; -type BlueprintPolicyAuthorityInspection = - import("../shared/openshell-policy-boundary.cjs").SandboxPolicyAuthorityInspection; -type NemoClawPolicyCreationReceipt = - import("../shared/openshell-policy-boundary.cjs").NemoClawPolicyCreationReceipt; +type BlueprintPolicyInspection = + import("../shared/openshell-policy-boundary.cjs").OpenShellPolicyInspection; type GatewayBinding = { name: string; @@ -155,120 +148,14 @@ type GatewayBinding = { port: number; }; -type BlueprintPolicyAuthorityReceipt = - | { - authority: "externally-managed"; - gateway: string; - gateway_host: string; - gateway_port: number; - scope: "global" | "sandbox"; - sandbox_name?: string; - sandbox_identity_fingerprint?: string; - } - | { - authority: "nemoclaw-managed"; - gateway: string; - gateway_host: string; - gateway_port: number; - scope: "sandbox"; - sandbox_name: string; - policy_creation_receipt: NemoClawPolicyCreationReceipt; - }; - -type BlueprintPolicyCreationTransition = { - status: "pending" | "incomplete"; - gateway: string; - gateway_host: string; - gateway_port: number; - sandbox_name: string; - lifecycle_generation: string; - sandbox_identity_fingerprint?: string; -}; - -type BlueprintPolicyTransitionReceipt = { - status: "pending" | "incomplete" | "complete"; - sandbox_name: string; - gateway: string; - gateway_host: string; - gateway_port: number; - expected_authority: "nemoclaw-managed"; - policy_addition_names: string[]; - target_policy_digest: string; -}; - -type StatusPolicyTransition = BlueprintPolicyTransitionReceipt & { - reconciliation_required: boolean; - reconciliation_action?: string; -}; - -type StatusPolicyCreationTransition = BlueprintPolicyCreationTransition & { - recovery_required: true; - recovery_action: string; -}; - -function policyTransitionReconciliationAction(runId: string): string { - return `Do not retry \`apply\` or \`rollback\`. Through the NemoClaw blueprint runner integration that created this run, invoke its \`reconcile\` action with run ID ${runId}. There is no standalone \`reconcile\` host command.`; -} - -function policyCreationRecoveryAction( - runId: string, - transition: BlueprintPolicyCreationTransition, -): string { - const identity = transition.sandbox_identity_fingerprint - ? `The recorded immutable sandbox identity fingerprint is ${transition.sandbox_identity_fingerprint}.` - : "No immutable sandbox identity fingerprint was recorded; trusted gateway evidence must establish it before recovery can be reconsidered."; - return `Automated retry, rollback, detach, and cleanup are disabled for run ${runId}. OpenShell does not expose an atomic identity-bound delete or detach operation, so no safe automatic or manual cleanup action is currently supported. Recovery is blocked. Preserve the run receipt and retained resources. Through the NemoClaw blueprint runner integration, inspect status for run ${runId}. Give an OpenShell administrator that run receipt together with sandbox ${JSON.stringify(transition.sandbox_name)}, gateway ${JSON.stringify(transition.gateway)} (${transition.gateway_host}:${String(transition.gateway_port)}), and lifecycle generation ${transition.lifecycle_generation}. ${identity} The administrator must compare the receipt with trusted gateway evidence and leave the resources unchanged. Do not mutate any sandbox or provider by name. Cleanup may resume only through an OpenShell operation that atomically conditions the mutation on the exact immutable identity.`; -} - const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); const REST_PROTOCOLS = new Set(["rest"]); const ENDPOINT_ENFORCEMENT_MODES = new Set(["enforce", "audit"]); const ENDPOINT_TLS_MODES = new Set(["terminate", "passthrough", "skip"]); const MISSING_PROVIDER_INSPECTION_PATTERN = /(?:\bprovider\b[^\r\n]*\b(?:not found|does not exist)\b|\b(?:not found|does not exist)\b[^\r\n]*\bprovider\b|\bunknown provider\b)/i; -const POLICY_AUTHORITY_MAX_BYTES = 1024 * 1024; -const POLICY_AUTHORITY_TIMEOUT_MS = 30_000; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; -const MANAGED_POLICY_AUTHORITY_KEYS = [ - "authority", - "gateway", - "gateway_host", - "gateway_port", - "scope", - "sandbox_name", - "policy_creation_receipt", -] as const; -const EXTERNAL_GLOBAL_POLICY_AUTHORITY_KEYS = [ - "authority", - "gateway", - "gateway_host", - "gateway_port", - "scope", -] as const; -const EXTERNAL_SANDBOX_POLICY_AUTHORITY_KEYS = [ - ...EXTERNAL_GLOBAL_POLICY_AUTHORITY_KEYS, - "sandbox_name", - "sandbox_identity_fingerprint", -] as const; -const POLICY_CREATION_TRANSITION_KEYS = [ - "status", - "gateway", - "gateway_host", - "gateway_port", - "sandbox_name", - "lifecycle_generation", - "sandbox_identity_fingerprint", -] as const; -const POLICY_TRANSITION_KEYS = [ - "status", - "sandbox_name", - "gateway", - "gateway_host", - "gateway_port", - "expected_authority", - "policy_addition_names", - "target_policy_digest", -] as const; +const POLICY_INSPECTION_MAX_BYTES = 1024 * 1024; +const POLICY_INSPECTION_TIMEOUT_MS = 30_000; interface InferenceRouteBinding { provider: string; @@ -788,18 +675,18 @@ async function inspectActiveGatewayBinding(): Promise { type BlueprintInspectionFailure = | { - readonly kind: "policy-authority"; + readonly kind: "policy"; readonly subject: "global" | "sandbox"; } | { - readonly kind: "receipt"; + readonly kind: "state"; readonly subject: "gateway" | "policy" | "sandbox"; }; function blueprintInspectionFailureMessage(failure: BlueprintInspectionFailure): string { - return failure.kind === "policy-authority" - ? `OpenShell ${failure.subject} policy authority inspection failed. Policy-dependent operations must stop.` - : `OpenShell ${failure.subject} receipt inspection failed.`; + return failure.kind === "policy" + ? `OpenShell ${failure.subject} policy inspection failed. Policy-dependent operations must stop.` + : `OpenShell ${failure.subject} state inspection failed.`; } async function runBlueprintInspectionCommand( @@ -812,9 +699,9 @@ async function runBlueprintInspectionCommand( try { result = await runCmd(command, { gateway, - maxBuffer: POLICY_AUTHORITY_MAX_BYTES, + maxBuffer: POLICY_INSPECTION_MAX_BYTES, reject: false, - timeout: POLICY_AUTHORITY_TIMEOUT_MS, + timeout: POLICY_INSPECTION_TIMEOUT_MS, }); } catch { throw new Error(failureMessage); @@ -822,30 +709,28 @@ async function runBlueprintInspectionCommand( if ( result.exitCode !== 0 || Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(result.stderr, "utf8") > - POLICY_AUTHORITY_MAX_BYTES + POLICY_INSPECTION_MAX_BYTES ) { throw new Error(failureMessage); } return result; } -async function inspectBlueprintPolicyAuthority( - gateway: string, -): Promise; -async function inspectBlueprintPolicyAuthority( +async function inspectBlueprintPolicy(gateway: string): Promise; +async function inspectBlueprintPolicy( gateway: string, sandboxName: string, -): Promise; -async function inspectBlueprintPolicyAuthority( +): Promise; +async function inspectBlueprintPolicy( gateway: string, sandboxName?: string, -): Promise { +): Promise { const subject = sandboxName === undefined ? "global" : "sandbox"; if (sandboxName === undefined) { const history = await runBlueprintInspectionCommand( ["openshell", "policy", "list", "-g", gateway, "--global", "--limit", "1"], gateway, - { kind: "policy-authority", subject }, + { kind: "policy", subject }, ); const historyState = classifyOpenShellGlobalPolicyHistory(history.stdout, history.stderr); if (historyState === "absent") { @@ -862,12 +747,12 @@ async function inspectBlueprintPolicyAuthority( ? ["openshell", "policy", "get", "-g", gateway, "--global", "--full", "--output", "json"] : ["openshell", "policy", "get", "-g", gateway, "--full", "--output", "json", sandboxName]; const result = await runBlueprintInspectionCommand(command, gateway, { - kind: "policy-authority", + kind: "policy", subject, }); if (sandboxName === undefined) { try { - const activeGlobalPolicy = parseActiveGlobalPolicyAuthorityMetadata(result.stdout); + const activeGlobalPolicy = parseActiveGlobalPolicyMetadata(result.stdout); return activeGlobalPolicy.state === "active" ? activeGlobalPolicy.inspection : null; } catch (error) { const detail = error instanceof Error ? error.message : "OpenShell returned invalid metadata"; @@ -875,41 +760,91 @@ async function inspectBlueprintPolicyAuthority( } } try { - return parseSandboxPolicyAuthorityMetadata(result.stdout, sandboxName); + return parseSandboxPolicyMetadata(result.stdout, sandboxName); } catch (error) { const detail = error instanceof Error ? error.message : "OpenShell returned invalid metadata"; throw new Error(`${detail}. Policy-dependent operations must stop.`); } } -function assertBlueprintExternalPolicyRequirements( - inspection: BlueprintPolicyAuthorityInspection, +function assertBlueprintPolicyRequirements( + inspection: BlueprintPolicyInspection, additions: PolicyAdditions, ): void { try { - assertExternalPolicyRequirementContainment(inspection, { + assertPolicyRequirementContainment(inspection, { network_policies: additions, }); } catch (error) { const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; - throw new Error( - `Refusing to apply the blueprint: ${detail}. Ask the external policy authority to supply the exact required entries.`, - ); + throw new Error(`Cannot reconcile the blueprint policy transition: ${detail}.`); } } -function assertBlueprintPolicyRequirements( - inspection: BlueprintPolicyAuthorityInspection, +function blueprintPolicyRequirementsSatisfied( + inspection: BlueprintPolicyInspection, additions: PolicyAdditions, -): void { +): boolean { try { - assertPolicyRequirementContainment(inspection, { - network_policies: additions, - }); - } catch (error) { - const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; - throw new Error(`Cannot reconcile the blueprint policy transition: ${detail}.`); + assertBlueprintPolicyRequirements(inspection, additions); + return true; + } catch { + return false; + } +} + +async function applyBlueprintPolicyAdditions( + gateway: GatewayBinding, + sandboxName: string, + additions: PolicyAdditions, + temporaryDirectory: string, +): Promise { + if (Object.keys(additions).length === 0) return; + const current = await inspectBlueprintPolicy(gateway.name, sandboxName); + if (blueprintPolicyRequirementsSatisfied(current, additions)) return; + + const base = await runBlueprintInspectionCommand( + ["openshell", "policy", "get", "-g", gateway.name, "--base", sandboxName], + gateway.name, + { kind: "state", subject: "policy" }, + ); + const policyPath = join(temporaryDirectory, "policy-update.yaml"); + writeFileSync(policyPath, mergePolicyAdditions(base.stdout, additions), { + encoding: "utf-8", + mode: 0o600, + }); + try { + const result = await runCmd( + [ + "openshell", + "policy", + "set", + "-g", + gateway.name, + "--policy", + policyPath, + "--wait", + sandboxName, + ], + { gateway: gateway.name, reject: false }, + ); + if (result.exitCode !== 0) { + throw new Error(`Failed to apply policy additions: ${boundedCommandError(result.stderr)}`); + } + } finally { + try { + unlinkSync(policyPath); + } catch { + // The file contains policy material; surface cleanup failure even if set succeeded. + if (existsSync(policyPath)) { + throw new Error(`Temporary blueprint policy remains at ${policyPath}`); + } + } } + assertBlueprintPolicyRequirements( + await inspectBlueprintPolicy(gateway.name, sandboxName), + additions, + ); } function readConfiguredSandboxPolicy(): { @@ -931,162 +866,15 @@ function readConfiguredSandboxPolicy(): { } } -function stablePolicyValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(stablePolicyValue); - if (!isPlainObject(value)) return value; - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, stablePolicyValue(value[key])]), - ); -} - -function policyDigest(policy: unknown): string { - return createHash("sha256") - .update(JSON.stringify(stablePolicyValue(policy))) - .digest("hex"); -} - -function policyForOwnershipProof(policy: UnknownRecord): UnknownRecord { - const networkPolicies = isPlainObject(policy.network_policies) - ? withoutProviderComposedPolicies(policy.network_policies) - : policy.network_policies; - return { ...policy, network_policies: networkPolicies }; -} - async function inspectGatewayEndpoint(name: string): Promise<{ host: string; port: number }> { const info = await runBlueprintInspectionCommand( ["openshell", "gateway", "info", "-g", name], name, - { kind: "receipt", subject: "gateway" }, + { kind: "state", subject: "gateway" }, ); return parseSingleManagedGatewayEndpoint(`${info.stderr}\n${info.stdout}`); } -async function inspectSandboxIdentityFingerprint( - gateway: string, - sandboxName: string, - requireReady = true, -): Promise { - const result = await runBlueprintInspectionCommand( - ["openshell", "sandbox", "get", "-g", gateway, sandboxName], - gateway, - { kind: "receipt", subject: "sandbox" }, - ); - const output = `${result.stderr}\n${result.stdout}`; - const lines = output.replace(/\u001b\[[0-9;]*m/g, "").split(/\r?\n/); - const names = lines - .map((line) => /^\s*Name:\s*(.+?)\s*$/i.exec(line)?.[1]) - .filter((value): value is string => Boolean(value)); - const ids = lines - .map((line) => /^\s*Id:\s*(.+?)\s*$/i.exec(line)?.[1]) - .filter((value): value is string => Boolean(value)); - const phases = lines - .map((line) => /^\s*Phase:\s*(.+?)\s*$/i.exec(line)?.[1]) - .filter((value): value is string => Boolean(value)); - if ( - names.length !== 1 || - names[0] !== sandboxName || - ids.length !== 1 || - phases.length !== 1 || - (requireReady && phases[0] !== "Ready") - ) { - throw new Error( - `OpenShell did not prove the immutable identity of${requireReady ? " Ready" : ""} sandbox ${JSON.stringify(sandboxName)}`, - ); - } - return createHash("sha256").update(ids[0]).digest("hex"); -} - -function managedInspection( - inspection: BlueprintPolicyAuthorityInspection, -): BlueprintPolicyAuthorityInspection { - return { ...inspection, authority: "nemoclaw-managed" }; -} - -async function validateManagedPolicyReceipt( - value: unknown, - expectedGatewayEndpoint: { host: string; port: number }, - requireReady = true, -): Promise<{ - receipt: NemoClawPolicyCreationReceipt; - inspection: BlueprintPolicyAuthorityInspection; -}> { - const receipt = parseNemoClawPolicyCreationReceipt(value); - const gatewayEndpoint = await inspectGatewayEndpoint(receipt.gatewayName); - if ( - gatewayEndpoint.host !== expectedGatewayEndpoint.host || - gatewayEndpoint.port !== expectedGatewayEndpoint.port - ) { - throw new Error("The OpenShell gateway endpoint no longer matches the durable policy receipt"); - } - const sandboxIdentityFingerprint = await inspectSandboxIdentityFingerprint( - receipt.gatewayName, - receipt.sandboxName, - requireReady, - ); - const inspection = await inspectBlueprintPolicyAuthority( - receipt.gatewayName, - receipt.sandboxName, - ); - if (inspection.authority !== "owner-unknown") { - throw new Error("The live sandbox policy is no longer sandbox-scoped"); - } - assertNemoClawPolicyCreationReceiptMatches(receipt, { - origin: "sandbox-create", - gatewayName: receipt.gatewayName, - gatewayPort: gatewayEndpoint.port, - sandboxName: receipt.sandboxName, - lifecycleGeneration: receipt.lifecycleGeneration, - sandboxIdentityFingerprint, - policyHash: inspection.policyIdentity.hash, - policyVersion: inspection.policyIdentity.activeVersion, - }); - return { receipt, inspection: managedInspection(inspection) }; -} - -async function inspectReceiptSandboxBinding( - value: unknown, - expectedGatewayEndpoint: { host: string; port: number }, -): Promise<{ - receipt: NemoClawPolicyCreationReceipt; - inspection: BlueprintPolicyAuthorityInspection; -}> { - const receipt = parseNemoClawPolicyCreationReceipt(value); - const gatewayEndpoint = await inspectGatewayEndpoint(receipt.gatewayName); - if ( - gatewayEndpoint.host !== expectedGatewayEndpoint.host || - gatewayEndpoint.port !== expectedGatewayEndpoint.port - ) { - throw new Error("The OpenShell gateway endpoint no longer matches the durable policy receipt"); - } - const sandboxIdentityFingerprint = await inspectSandboxIdentityFingerprint( - receipt.gatewayName, - receipt.sandboxName, - ); - assertNemoClawPolicyCreationReceiptMatches(receipt, { - origin: "sandbox-create", - gatewayName: receipt.gatewayName, - gatewayPort: gatewayEndpoint.port, - sandboxName: receipt.sandboxName, - lifecycleGeneration: receipt.lifecycleGeneration, - sandboxIdentityFingerprint, - policyHash: receipt.policyHash, - policyVersion: receipt.policyVersion, - }); - const inspection = await inspectBlueprintPolicyAuthority( - receipt.gatewayName, - receipt.sandboxName, - ); - if (inspection.authority !== "owner-unknown") { - throw new Error("The live sandbox policy is no longer sandbox-scoped"); - } - return { - receipt, - inspection, - }; -} - async function runRuntimeIdentityCommand( args: string[], options?: RuntimeIdentityCommandOptions, @@ -1106,30 +894,9 @@ async function runRuntimeIdentityCommand( }; } -function isRuntimeIdentityMutationCommand(args: readonly string[]): boolean { - const command = args.slice(1).join(" "); - return ( - command.startsWith("provider profile import ") || - command.startsWith("provider create ") || - command.startsWith("provider delete ") || - command.startsWith("provider refresh configure ") || - command.startsWith("provider refresh rotate ") || - command.startsWith("sandbox provider attach ") || - command.startsWith("sandbox provider detach ") - ); -} - -function runtimeIdentityCommandDeps( - gateway: string, - validateBeforeMutation?: () => Promise, -): RuntimeIdentityCommandDeps { +function runtimeIdentityCommandDeps(gateway: string): RuntimeIdentityCommandDeps { return { - run: async (args, options) => { - if (validateBeforeMutation && isRuntimeIdentityMutationCommand(args)) { - await validateBeforeMutation(); - } - return runRuntimeIdentityCommand(args, options, gateway); - }, + run: (args, options) => runRuntimeIdentityCommand(args, options, gateway), formatError: boundedCommandError, }; } @@ -1138,10 +905,9 @@ function runtimeIdentityDeps( persistReceipt: (receipt: RuntimeIdentityReceipt) => void, gateway: string, profilePolicy?: RuntimeIdentityProfilePolicy, - validateBeforeMutation?: () => Promise, ): RuntimeIdentityDeps { return { - ...runtimeIdentityCommandDeps(gateway, validateBeforeMutation), + ...runtimeIdentityCommandDeps(gateway), validateEndpointUrl, persistReceipt, blueprintPath: process.env.NEMOCLAW_BLUEPRINT_PATH ?? ".", @@ -1257,9 +1023,7 @@ interface PersistedRunPlan { sandbox_created_by_apply: boolean; inference_provider_created_by_apply: boolean; policy_additions: PolicyAdditions; - policy_authority?: BlueprintPolicyAuthorityReceipt; - policy_creation_transition?: BlueprintPolicyCreationTransition; - policy_transition?: BlueprintPolicyTransitionReceipt; + gateway: GatewayBinding; inference: SafeInferencePlan; identity?: RuntimeIdentityReceipt; timestamp: string; @@ -1277,9 +1041,7 @@ type StatusRunPlan = { sandbox_created_by_apply?: boolean; inference_provider_created_by_apply?: boolean; policy_additions?: PolicyAdditions; - policy_authority?: BlueprintPolicyAuthorityReceipt; - policy_creation_transition?: StatusPolicyCreationTransition; - policy_transition?: StatusPolicyTransition; + gateway?: GatewayBinding; inference?: SafeInferencePlan; identity?: RuntimeIdentityReceipt; router?: { @@ -1295,127 +1057,14 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function isBlueprintPolicyAuthorityReceipt( - value: unknown, -): value is BlueprintPolicyAuthorityReceipt { - if (!isPlainObject(value)) return false; - if ( - (value.authority !== "nemoclaw-managed" && value.authority !== "externally-managed") || - (value.scope !== "global" && value.scope !== "sandbox") || - !isValidName(value.gateway) || - !isManagedGatewayEndpointHost(value.gateway_host) || - !isValidPort(value.gateway_port) - ) { - return false; - } - if (value.authority === "nemoclaw-managed") { - if ( - value.scope !== "sandbox" || - !isValidName(value.sandbox_name) || - !hasOnlyKeys(value, MANAGED_POLICY_AUTHORITY_KEYS) - ) { - return false; - } - try { - const receipt = parseNemoClawPolicyCreationReceipt(value.policy_creation_receipt); - return ( - receipt.gatewayName === value.gateway && - receipt.gatewayPort === value.gateway_port && - receipt.sandboxName === value.sandbox_name - ); - } catch { - return false; - } - } - return value.scope === "global" - ? hasOnlyKeys(value, EXTERNAL_GLOBAL_POLICY_AUTHORITY_KEYS) - : hasOnlyKeys(value, EXTERNAL_SANDBOX_POLICY_AUTHORITY_KEYS) && - isValidName(value.sandbox_name) && - typeof value.sandbox_identity_fingerprint === "string" && - /^[a-f0-9]{64}$/u.test(value.sandbox_identity_fingerprint); -} - -function isBlueprintPolicyCreationTransition( - value: unknown, -): value is BlueprintPolicyCreationTransition { +function isGatewayBinding(value: unknown): value is GatewayBinding { return ( isPlainObject(value) && - hasOnlyKeys(value, POLICY_CREATION_TRANSITION_KEYS) && - (value.status === "pending" || value.status === "incomplete") && - isValidName(value.gateway) && - isManagedGatewayEndpointHost(value.gateway_host) && - isValidPort(value.gateway_port) && - isValidName(value.sandbox_name) && - typeof value.lifecycle_generation === "string" && - UUID_PATTERN.test(value.lifecycle_generation) && - (value.sandbox_identity_fingerprint === undefined || - (typeof value.sandbox_identity_fingerprint === "string" && - /^[a-f0-9]{64}$/u.test(value.sandbox_identity_fingerprint))) - ); -} - -function isBlueprintPolicyTransitionReceipt( - value: unknown, -): value is BlueprintPolicyTransitionReceipt { - if (!isPlainObject(value)) return false; - if ( - !hasOnlyKeys(value, POLICY_TRANSITION_KEYS) || - (value.status !== "pending" && value.status !== "incomplete" && value.status !== "complete") || - !isValidName(value.sandbox_name) || - !isValidName(value.gateway) || - !isManagedGatewayEndpointHost(value.gateway_host) || - !isValidPort(value.gateway_port) || - value.expected_authority !== "nemoclaw-managed" || - typeof value.target_policy_digest !== "string" || - !/^[a-f0-9]{64}$/u.test(value.target_policy_digest) || - !Array.isArray(value.policy_addition_names) || - value.policy_addition_names.length === 0 || - !value.policy_addition_names.every( - (name): name is string => typeof name === "string" && name.length > 0, - ) - ) { - return false; - } - return new Set(value.policy_addition_names).size === value.policy_addition_names.length; -} - -async function validateBlueprintPolicyAuthorityReceipt( - value: unknown, - sandboxName: string, - requireReady = true, -): Promise { - if ( - !isBlueprintPolicyAuthorityReceipt(value) || - value.scope !== "sandbox" || - value.sandbox_name !== sandboxName - ) { - throw new Error("A complete sandbox policy boundary receipt is required"); - } - if (value.authority === "nemoclaw-managed") { - return ( - await validateManagedPolicyReceipt( - value.policy_creation_receipt, - { host: value.gateway_host, port: value.gateway_port }, - requireReady, - ) - ).inspection; - } - const liveEndpoint = await inspectGatewayEndpoint(value.gateway); - const liveFingerprint = await inspectSandboxIdentityFingerprint( - value.gateway, - sandboxName, - requireReady, + hasOnlyKeys(value, ["name", "host", "port"] as const) && + isValidName(value.name) && + isManagedGatewayEndpointHost(value.host) && + isValidPort(value.port) ); - const livePolicy = await inspectBlueprintPolicyAuthority(value.gateway, sandboxName); - if ( - liveEndpoint.host !== value.gateway_host || - liveEndpoint.port !== value.gateway_port || - liveFingerprint !== value.sandbox_identity_fingerprint || - livePolicy.authority !== "externally-managed" - ) { - throw new Error("The verified external policy boundary no longer matches the live sandbox"); - } - return livePolicy; } function buildSafeInferencePlan(source: InferenceProfile | UnknownRecord): SafeInferencePlan { @@ -1470,9 +1119,7 @@ function buildPersistedRunPlan(args: { sandboxCreatedByApply: boolean; inferenceProviderCreatedByApply: boolean; policyAdditions: PolicyAdditions; - policyAuthorityReceipt?: BlueprintPolicyAuthorityReceipt; - policyCreationTransition?: BlueprintPolicyCreationTransition; - policyTransition?: BlueprintPolicyTransitionReceipt; + gateway: GatewayBinding; inferenceCfg: InferenceProfile; runtimeIdentityReceipt?: RuntimeIdentityReceipt; timestamp: string; @@ -1484,21 +1131,13 @@ function buildPersistedRunPlan(args: { sandbox_created_by_apply: args.sandboxCreatedByApply, inference_provider_created_by_apply: args.inferenceProviderCreatedByApply, policy_additions: args.policyAdditions, + gateway: args.gateway, inference: buildSafeInferencePlan(args.inferenceCfg), timestamp: args.timestamp, }; - if (args.policyAuthorityReceipt) { - plan.policy_authority = args.policyAuthorityReceipt; - } - if (args.policyCreationTransition) { - plan.policy_creation_transition = args.policyCreationTransition; - } if (args.runtimeIdentityReceipt) { plan.identity = args.runtimeIdentityReceipt; } - if (args.policyTransition) { - plan.policy_transition = args.policyTransition; - } return plan; } @@ -1569,47 +1208,8 @@ function buildStatusRunPlan(source: unknown, fallbackRunId: string): StatusRunPl if (isPolicyAdditions(source.policy_additions)) { safePlan.policy_additions = source.policy_additions; } - if ( - source.policy_authority !== undefined && - !isBlueprintPolicyAuthorityReceipt(source.policy_authority) - ) { - return null; - } - if (isBlueprintPolicyAuthorityReceipt(source.policy_authority)) { - safePlan.policy_authority = source.policy_authority; - } - if ( - source.policy_creation_transition !== undefined && - !isBlueprintPolicyCreationTransition(source.policy_creation_transition) - ) { - return null; - } - if (isBlueprintPolicyCreationTransition(source.policy_creation_transition)) { - safePlan.policy_creation_transition = { - ...source.policy_creation_transition, - recovery_required: true, - recovery_action: policyCreationRecoveryAction( - safePlan.run_id, - source.policy_creation_transition, - ), - }; - } - if ( - source.policy_transition !== undefined && - !isBlueprintPolicyTransitionReceipt(source.policy_transition) - ) { - return null; - } - if (isBlueprintPolicyTransitionReceipt(source.policy_transition)) { - const reconciliationRequired = source.policy_transition.status !== "complete"; - safePlan.policy_transition = { - ...source.policy_transition, - reconciliation_required: reconciliationRequired, - ...(reconciliationRequired - ? { reconciliation_action: policyTransitionReconciliationAction(safePlan.run_id) } - : {}), - }; - } + if (source.gateway !== undefined && !isGatewayBinding(source.gateway)) return null; + if (isGatewayBinding(source.gateway)) safePlan.gateway = source.gateway; if (isPlainObject(source.inference)) { safePlan.inference = buildSafeInferencePlan(source.inference); @@ -1774,12 +1374,9 @@ export async function actionApply( credential = process.env[credentialEnv] ?? credentialDefault; } const policyGateway = await inspectActiveGatewayBinding(); - const initialPolicyAuthority = await inspectBlueprintPolicyAuthority(policyGateway.name); - if (initialPolicyAuthority) { - assertBlueprintExternalPolicyRequirements(initialPolicyAuthority, policyAdditions); - } - const configuredSandboxPolicy = initialPolicyAuthority ? null : readConfiguredSandboxPolicy(); - if (!initialPolicyAuthority && !configuredSandboxPolicy) { + const globalPolicy = await inspectBlueprintPolicy(policyGateway.name); + const configuredSandboxPolicy = globalPolicy ? null : readConfiguredSandboxPolicy(); + if (!globalPolicy && !configuredSandboxPolicy) { throw new Error( "A configured NemoClaw sandbox policy is required before the blueprint can create or mutate resources.", ); @@ -1788,18 +1385,6 @@ export async function actionApply( mkdirSync(stateDir, { recursive: true }); let runtimeIdentityReceipt: RuntimeIdentityReceipt | undefined; - let policyAuthorityReceipt: BlueprintPolicyAuthorityReceipt | undefined = initialPolicyAuthority - ? { - authority: "externally-managed", - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - scope: "global", - } - : undefined; - let policyCreationTransition: BlueprintPolicyCreationTransition | undefined; - let sandboxPolicyAuthority: BlueprintPolicyAuthorityInspection | null = null; - let policyTransition: BlueprintPolicyTransitionReceipt | undefined; let sandboxCreatedByApply = false; let inferenceProviderCreatedByApply = false; const persistRunPlan = (): void => { @@ -1812,23 +1397,19 @@ export async function actionApply( sandboxCreatedByApply, inferenceProviderCreatedByApply, policyAdditions, - policyAuthorityReceipt, - policyCreationTransition, - policyTransition, + gateway: policyGateway, inferenceCfg, runtimeIdentityReceipt, timestamp: new Date().toISOString(), }), ); }; - const requireUsablePolicyBoundary = async (): Promise => { - const inspection = await validateBlueprintPolicyAuthorityReceipt( - policyAuthorityReceipt, - sandboxName, - ); - assertBlueprintExternalPolicyRequirements(inspection, policyAdditions); - sandboxPolicyAuthority = inspection; - return inspection; + const requireLivePolicy = async (): Promise => { + const liveGateway = await inspectActiveGatewayBinding(); + if (!isDeepStrictEqual(liveGateway, policyGateway)) { + throw new Error("The OpenShell gateway binding changed during blueprint apply."); + } + return inspectBlueprintPolicy(policyGateway.name, sandboxName); }; const requireCreatePolicyBoundary = async (): Promise => { const liveGateway = await inspectActiveGatewayBinding(); @@ -1839,23 +1420,6 @@ export async function actionApply( ) { throw new Error("The OpenShell gateway binding changed before sandbox creation."); } - const liveGlobalPolicy = await inspectBlueprintPolicyAuthority(policyGateway.name); - if (initialPolicyAuthority === null) { - if (liveGlobalPolicy !== null) { - throw new Error("The OpenShell global policy boundary changed before sandbox creation."); - } - return; - } - if ( - liveGlobalPolicy === null || - liveGlobalPolicy.authority !== "externally-managed" || - liveGlobalPolicy.policyIdentity.hash !== initialPolicyAuthority.policyIdentity.hash || - liveGlobalPolicy.policyIdentity.activeVersion !== - initialPolicyAuthority.policyIdentity.activeVersion || - !isDeepStrictEqual(liveGlobalPolicy.effectivePolicy, initialPolicyAuthority.effectivePolicy) - ) { - throw new Error("The OpenShell global policy boundary changed before sandbox creation."); - } }; const identityDeps = runtimeIdentityDeps( (receipt) => { @@ -1870,26 +1434,12 @@ export async function actionApply( }, policyGateway.name, options?.runtimeIdentityProfilePolicy, - requireUsablePolicyBoundary, ); try { let reuseExistingInferenceProvider = false; let reuseExistingInferenceRoute = false; - let policyCreationReceipt: NemoClawPolicyCreationReceipt | undefined; progress(20, "Creating OpenClaw sandbox"); - const lifecycleGeneration = randomUUID(); - if (configuredSandboxPolicy) { - policyCreationTransition = { - status: "pending", - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - sandbox_name: sandboxName, - lifecycle_generation: lifecycleGeneration, - }; - persistRunPlan(); - } const createArgs = [ "openshell", "sandbox", @@ -1916,112 +1466,25 @@ export async function actionApply( }); if (createResult.exitCode !== 0) { if (createResult.stderr.includes("already exists")) { - if (configuredSandboxPolicy) { - policyCreationTransition = { ...policyCreationTransition!, status: "incomplete" }; - persistRunPlan(); - throw new Error( - `Sandbox ${JSON.stringify(sandboxName)} already exists, so this create transaction cannot establish NemoClaw policy ownership.`, - ); - } - log(`Sandbox '${sandboxName}' already exists, reusing under verified global policy.`); + log(`Sandbox '${sandboxName}' already exists; using its current OpenShell policy.`); } else { - if (configuredSandboxPolicy) { - policyCreationTransition = { ...policyCreationTransition!, status: "incomplete" }; - persistRunPlan(); - } throw new Error(`Failed to create sandbox: ${boundedCommandError(createResult.stderr)}`); } } else { sandboxCreatedByApply = true; - if (configuredSandboxPolicy) { - policyCreationTransition = { ...policyCreationTransition!, status: "incomplete" }; - } persistRunPlan(); } - const sandboxIdentityFingerprint = await inspectSandboxIdentityFingerprint( - policyGateway.name, - sandboxName, - ); - if (configuredSandboxPolicy) { - policyCreationTransition = { - ...policyCreationTransition!, - sandbox_identity_fingerprint: sandboxIdentityFingerprint, - }; - persistRunPlan(); - } - const observedPolicyAuthority = await inspectBlueprintPolicyAuthority( - policyGateway.name, - sandboxName, - ); + const observedPolicy = await requireLivePolicy(); if (configuredSandboxPolicy) { - if ( - observedPolicyAuthority.authority !== "owner-unknown" || - !isDeepStrictEqual( - policyForOwnershipProof(configuredSandboxPolicy.policy), - policyForOwnershipProof(observedPolicyAuthority.effectivePolicy), - ) - ) { - throw new Error( - "The created sandbox did not prove the exact policy supplied by this NemoClaw create transaction.", - ); - } - policyCreationReceipt = { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: policyGateway.name, - gatewayPort: policyGateway.port, - sandboxName, - lifecycleGeneration, - sandboxIdentityFingerprint, - policyHash: observedPolicyAuthority.policyIdentity.hash, - policyVersion: observedPolicyAuthority.policyIdentity.activeVersion, - }; - parseNemoClawPolicyCreationReceipt(policyCreationReceipt); - sandboxPolicyAuthority = managedInspection(observedPolicyAuthority); - policyAuthorityReceipt = { - authority: "nemoclaw-managed", - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - scope: "sandbox", - sandbox_name: sandboxName, - policy_creation_receipt: policyCreationReceipt, - }; - } else { - if (observedPolicyAuthority.authority !== "externally-managed") { - throw new Error("The created sandbox did not retain the verified global policy boundary."); + try { + assertPolicyRequirementContainment(observedPolicy, configuredSandboxPolicy.policy); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`The current OpenShell policy omits create-time requirements: ${detail}.`); } - assertBlueprintExternalPolicyRequirements(observedPolicyAuthority, policyAdditions); - sandboxPolicyAuthority = observedPolicyAuthority; - policyAuthorityReceipt = { - authority: "externally-managed", - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - scope: "sandbox", - sandbox_name: sandboxName, - sandbox_identity_fingerprint: sandboxIdentityFingerprint, - }; - } - policyCreationTransition = undefined; - try { - persistRunPlan(); - } catch (error) { - policyAuthorityReceipt = undefined; - sandboxPolicyAuthority = null; - policyCreationTransition = { - status: "incomplete", - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - sandbox_name: sandboxName, - lifecycle_generation: lifecycleGeneration, - }; - persistRunPlan(); - throw error; } - await requireUsablePolicyBoundary(); + persistRunPlan(); if (runtimeIdentityConfig) { const providerResult = await runCmd(["openshell", "provider", "get", providerName], { @@ -2065,7 +1528,6 @@ export async function actionApply( activeRoute.timeoutSeconds === inferenceCfg.timeout_secs); } progress(30, "Configuring runtime identity"); - await requireUsablePolicyBoundary(); runtimeIdentityReceipt = await prepareRuntimeIdentity(runtimeIdentityConfig, identityDeps); persistRunPlan(); } @@ -2073,7 +1535,6 @@ export async function actionApply( // Keep runtime credentials unattached until OpenShell accepts the // sandbox's requested inference route. progress(50, "Configuring inference provider"); - await requireUsablePolicyBoundary(); if (reuseExistingInferenceProvider) { log(`Provider '${providerName}' already exists, reusing.`); } else { @@ -2096,8 +1557,6 @@ export async function actionApply( if (endpoint) { providerArgs.push("--config", `OPENAI_BASE_URL=${endpoint}`); } - - await requireUsablePolicyBoundary(); const providerResult = await execa(providerArgs[0], providerArgs.slice(1), { reject: false, stdout: "pipe", @@ -2138,7 +1597,7 @@ export async function actionApply( } } else { inferenceProviderCreatedByApply = true; - // Persist ownership before a later route or policy mutation can fail. + // Persist inference-provider ownership before a later route or policy mutation can fail. try { persistRunPlan(); } catch (error) { @@ -2149,7 +1608,6 @@ export async function actionApply( } progress(70, "Setting inference route"); - await requireUsablePolicyBoundary(); if (reuseExistingInferenceRoute) { log(`Inference route '${providerName} / ${model}' is already active, reusing.`); } else { @@ -2165,7 +1623,6 @@ export async function actionApply( if (inferenceCfg.timeout_secs !== undefined) { inferenceArgs.push("--timeout", String(inferenceCfg.timeout_secs)); } - await requireUsablePolicyBoundary(); const inferenceResult = await runCmd(inferenceArgs, { gateway: policyGateway.name, reject: false, @@ -2180,7 +1637,6 @@ export async function actionApply( } if (runtimeIdentityReceipt) { - await requireUsablePolicyBoundary(); const attachmentCreated = await attachRuntimeIdentity( runtimeIdentityReceipt, sandboxName, @@ -2199,125 +1655,12 @@ export async function actionApply( } if (Object.keys(policyAdditions).length > 0) { - if (!sandboxPolicyAuthority) { - throw new Error("Sandbox policy authority is unavailable before applying additions."); - } - const observedPolicyAuthority = await requireUsablePolicyBoundary(); - assertBlueprintExternalPolicyRequirements(observedPolicyAuthority, policyAdditions); - if (observedPolicyAuthority.authority === "nemoclaw-managed") { - progress(78, "Applying policy additions"); - const currentPolicy = await runBlueprintInspectionCommand( - ["openshell", "policy", "get", "-g", policyGateway.name, "--base", sandboxName], - policyGateway.name, - { kind: "receipt", subject: "policy" }, - ); - - const mergedPolicyFile = join(stateDir, "merged-policy.yaml"); - writeFileSync( - mergedPolicyFile, - mergePolicyAdditions(currentPolicy.stdout, policyAdditions), - { - encoding: "utf-8", - mode: 0o600, - }, - ); - - const mergedPolicy = parseOpenShellPolicy(readFileSync(mergedPolicyFile, "utf-8")).policy; - await requireUsablePolicyBoundary(); - policyTransition = { - status: "pending", - sandbox_name: sandboxName, - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - expected_authority: "nemoclaw-managed", - policy_addition_names: Object.keys(policyAdditions).sort(), - target_policy_digest: policyDigest(mergedPolicy), - }; - persistRunPlan(); - await requireUsablePolicyBoundary(); - const policySet = await runCmd( - [ - "openshell", - "policy", - "set", - "-g", - policyGateway.name, - "--policy", - mergedPolicyFile, - "--wait", - sandboxName, - ], - { gateway: policyGateway.name, reject: false }, - ); - if (policySet.exitCode !== 0) { - throw new Error( - `Failed to apply policy additions: ${boundedCommandError(policySet.stderr)}`, - ); - } - policyTransition = { ...policyTransition, status: "incomplete" }; - persistRunPlan(); - const afterMutationIdentity = await inspectSandboxIdentityFingerprint( - policyGateway.name, - sandboxName, - ); - const afterMutation = await inspectBlueprintPolicyAuthority( - policyGateway.name, - sandboxName, - ); - const afterMutationGateway = await inspectGatewayEndpoint(policyGateway.name); - if (!policyCreationReceipt) { - throw new Error("The NemoClaw policy creation receipt is unavailable after mutation"); - } - if ( - afterMutationGateway.host !== policyGateway.host || - afterMutationGateway.port !== policyGateway.port || - afterMutationIdentity !== policyCreationReceipt.sandboxIdentityFingerprint || - afterMutation.authority !== "owner-unknown" || - !isDeepStrictEqual( - policyForOwnershipProof(afterMutation.effectivePolicy), - policyForOwnershipProof(mergedPolicy), - ) - ) { - throw new Error( - "OpenShell did not prove the exact sandbox and effective policy after the NemoClaw policy mutation.", - ); - } - const rotatedReceipt: NemoClawPolicyCreationReceipt = { - ...policyCreationReceipt, - policyHash: afterMutation.policyIdentity.hash, - policyVersion: afterMutation.policyIdentity.activeVersion, - }; - policyAuthorityReceipt = { - authority: "nemoclaw-managed", - gateway: policyGateway.name, - gateway_host: policyGateway.host, - gateway_port: policyGateway.port, - scope: "sandbox", - sandbox_name: sandboxName, - policy_creation_receipt: rotatedReceipt, - }; - sandboxPolicyAuthority = managedInspection(afterMutation); - policyTransition = { ...policyTransition, status: "complete" }; - try { - persistRunPlan(); - } catch (error) { - policyTransition = { ...policyTransition, status: "incomplete" }; - persistRunPlan(); - throw error; - } - } + progress(78, "Applying policy additions"); + await applyBlueprintPolicyAdditions(policyGateway, sandboxName, policyAdditions, stateDir); } progress(85, "Saving run state"); - if (!sandboxPolicyAuthority) { - throw new Error("Sandbox policy authority is unavailable before saving run state."); - } - const finalPolicyAuthority = await requireUsablePolicyBoundary(); - assertBlueprintExternalPolicyRequirements(finalPolicyAuthority, policyAdditions); - if (policyTransition) { - policyTransition = { ...policyTransition, status: "complete" }; - } + assertBlueprintPolicyRequirements(await requireLivePolicy(), policyAdditions); persistRunPlan(); progress(100, "Apply complete"); @@ -2394,7 +1737,7 @@ export function actionStatus(rid?: string): void { receipt_error: detail, run_directory: runDir, recovery: - "Do not reconstruct plan.json. Reconcile and rollback remain disabled. Recover the original receipt from a trusted copy produced by this exact run, then ask a NemoClaw maintainer to validate its run ID, sandbox ownership, provider ownership, and policy transition before using it. If no trusted copy exists, stop and ask a NemoClaw maintainer for recovery direction.", + "Do not reconstruct plan.json. Reconcile and rollback remain disabled. Recover the original receipt from a trusted copy produced by this exact run, then ask a NemoClaw maintainer to validate its run ID and resource bindings before using it. If no trusted copy exists, stop and ask a NemoClaw maintainer for recovery direction.", }, null, 2, @@ -2441,111 +1784,32 @@ export async function actionReconcile(rid: string): Promise { throw new Error(`Run ${rid} not found.`); } - const planFile = join(stateDir, "plan.json"); - let plan: ReconciliationPlanSource; - let transition: BlueprintPolicyTransitionReceipt; + let sandboxName: string; let additions: PolicyAdditions; - let authorityReceipt: BlueprintPolicyAuthorityReceipt; - let targetPolicy: UnknownRecord; + let gateway: GatewayBinding; try { - const parsedPlan: unknown = JSON.parse(readFileSync(planFile, "utf-8")); + const parsedPlan: unknown = JSON.parse(readFileSync(join(stateDir, "plan.json"), "utf-8")); if (!isPlainObject(parsedPlan)) { throw new Error("plan.json must contain a JSON object"); } - plan = parsedPlan; - const sandboxName = readRollbackSandboxName(plan); - if (!isBlueprintPolicyAuthorityReceipt(plan.policy_authority)) { - throw new Error("policy authority receipt is invalid"); - } - authorityReceipt = plan.policy_authority; - if (authorityReceipt.authority !== "nemoclaw-managed") { - throw new Error("policy reconciliation requires a NemoClaw policy creation receipt"); - } - if (!isBlueprintPolicyTransitionReceipt(plan.policy_transition)) { - throw new Error("policy transition receipt is invalid"); - } - transition = plan.policy_transition; - if (transition.sandbox_name !== sandboxName) { - throw new Error("policy transition sandbox does not match the run plan"); - } - if ( - transition.gateway !== authorityReceipt.gateway || - transition.gateway_host !== authorityReceipt.gateway_host || - transition.gateway_port !== authorityReceipt.gateway_port || - transition.sandbox_name !== authorityReceipt.sandbox_name - ) { - throw new Error("policy transition boundary does not match the policy creation receipt"); - } + const plan = parsedPlan as ReconciliationPlanSource; + sandboxName = readRollbackSandboxName(plan); + if (!isGatewayBinding(plan.gateway)) throw new Error("gateway binding is invalid"); + gateway = plan.gateway; if (!isPolicyAdditions(plan.policy_additions)) { throw new Error("policy additions are invalid"); } additions = withoutProviderComposedPolicies(plan.policy_additions); - const additionNames = Object.keys(additions).sort(); - if ( - additionNames.length === 0 || - additionNames.length !== transition.policy_addition_names.length || - additionNames.some((name, index) => name !== transition.policy_addition_names[index]) - ) { - throw new Error("policy transition additions do not match the run plan"); - } - targetPolicy = parseOpenShellPolicy( - readFileSync(join(stateDir, "merged-policy.yaml"), "utf-8"), - ).policy; - if (policyDigest(targetPolicy) !== transition.target_policy_digest) { - throw new Error("policy transition target does not match its durable digest"); - } } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Cannot read reconciliation plan for run ${rid}: ${detail}`); } - - if (transition.status === "complete") { - const validated = await validateManagedPolicyReceipt(authorityReceipt.policy_creation_receipt, { - host: authorityReceipt.gateway_host, - port: authorityReceipt.gateway_port, - }); - if ( - !isDeepStrictEqual( - policyForOwnershipProof(validated.inspection.effectivePolicy), - policyForOwnershipProof(targetPolicy), - ) - ) { - throw new Error("Cannot reconcile the blueprint policy transition: live policy changed."); - } - log(`Policy transition for run ${rid} is already complete.`); - return; - } - - const observed = await inspectReceiptSandboxBinding(authorityReceipt.policy_creation_receipt, { - host: authorityReceipt.gateway_host, - port: authorityReceipt.gateway_port, - }); - if ( - !isDeepStrictEqual( - policyForOwnershipProof(observed.inspection.effectivePolicy), - policyForOwnershipProof(targetPolicy), - ) - ) { - throw new Error( - "Cannot reconcile the blueprint policy transition: OpenShell did not prove the exact intended policy.", - ); + const endpoint = await inspectGatewayEndpoint(gateway.name); + if (endpoint.host !== gateway.host || endpoint.port !== gateway.port) { + throw new Error("Cannot reconcile the blueprint: the OpenShell gateway binding changed."); } - assertBlueprintPolicyRequirements(managedInspection(observed.inspection), additions); - const rotatedReceipt: NemoClawPolicyCreationReceipt = { - ...observed.receipt, - policyHash: observed.inspection.policyIdentity.hash, - policyVersion: observed.inspection.policyIdentity.activeVersion, - }; - - persistRunReceipt(planFile, { - ...plan, - policy_authority: { - ...authorityReceipt, - policy_creation_receipt: rotatedReceipt, - }, - policy_transition: { ...transition, status: "complete" }, - }); - log(`Policy transition for run ${rid} is complete.`); + await applyBlueprintPolicyAdditions(gateway, sandboxName, additions, stateDir); + log(`Blueprint policy requirements for run ${rid} are present in OpenShell.`); } export async function actionRollback(rid: string): Promise { @@ -2564,7 +1828,6 @@ export async function actionRollback(rid: string): Promise { let sandboxCreatedByApply = false; let inferenceProviderCreatedByApply = false; let runtimeIdentityReceipt: RuntimeIdentityReceipt | undefined; - let policyTransition: BlueprintPolicyTransitionReceipt | undefined; try { const planData = readFileSync(planFile, "utf-8"); const parsedPlan: unknown = JSON.parse(planData); @@ -2584,43 +1847,18 @@ export async function actionRollback(rid: string): Promise { } runtimeIdentityReceipt = rollbackPlan.identity; } - if (rollbackPlan?.policy_creation_transition !== undefined) { - if (!isBlueprintPolicyCreationTransition(rollbackPlan.policy_creation_transition)) { - throw new Error("policy creation transition is invalid"); - } - throw new Error( - "policy creation is incomplete, so sandbox ownership is unavailable for rollback", - ); - } - if (rollbackPlan?.policy_authority !== undefined) { - if (!isBlueprintPolicyAuthorityReceipt(rollbackPlan.policy_authority)) { - throw new Error("policy authority receipt is invalid"); - } - } - if (rollbackPlan?.policy_transition !== undefined) { - if (!isBlueprintPolicyTransitionReceipt(rollbackPlan.policy_transition)) { - throw new Error("policy transition receipt is invalid"); - } - policyTransition = rollbackPlan.policy_transition; - } } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Cannot read rollback plan for run ${rid}: ${detail}`); } - if (policyTransition && policyTransition.status !== "complete") { - throw new Error( - `Cannot roll back run ${rid}: the policy transition for reused sandbox ${JSON.stringify(policyTransition.sandbox_name)} through gateway ${JSON.stringify(policyTransition.gateway)} is ${policyTransition.status}. ${policyTransitionReconciliationAction(rid)}`, - ); - } - if ( runtimeIdentityReceipt !== undefined || sandboxCreatedByApply || inferenceProviderCreatedByApply ) { throw new Error( - `Cannot roll back run ${rid}: OpenShell exposes cleanup only through mutable sandbox and provider names. The sandbox, providers, and ownership receipt were preserved for identity-bound recovery.`, + `Cannot roll back run ${rid}: OpenShell exposes cleanup only through mutable sandbox and provider names. The sandbox, providers, and run receipt were preserved for identity-bound recovery.`, ); } else { progress(70, `Preserving unowned sandbox ${sandboxName}`); diff --git a/nemoclaw/src/shared/openshell-policy-boundary.cts b/nemoclaw/src/shared/openshell-policy-boundary.cts index 4da0a704f00..7c068d02f91 100644 --- a/nemoclaw/src/shared/openshell-policy-boundary.cts +++ b/nemoclaw/src/shared/openshell-policy-boundary.cts @@ -17,28 +17,13 @@ export interface ParsedOpenShellPolicy { readonly policy: ValidatedOpenShellPolicyMapping; } -export type OpenShellPolicyAuthority = "nemoclaw-managed" | "externally-managed" | "owner-unknown"; - export interface OpenShellPolicyIdentity { readonly hash: string; readonly activeVersion: number; } -/** Secret-free proof that NemoClaw created and verified one sandbox policy. */ -export interface NemoClawPolicyCreationReceipt { - readonly schemaVersion: 1; - readonly origin: "sandbox-create"; - readonly gatewayName: string; - readonly gatewayPort: number; - readonly sandboxName: string; - readonly lifecycleGeneration: string; - readonly sandboxIdentityFingerprint: string; - readonly policyHash: string; - readonly policyVersion: number; -} - -export interface SandboxPolicyAuthorityInspection { - readonly authority: OpenShellPolicyAuthority; +export interface OpenShellPolicyInspection { + readonly policySource: "sandbox" | "global"; readonly effectivePolicy: OpenShellPolicyMapping; readonly policyIdentity: OpenShellPolicyIdentity; } @@ -48,9 +33,7 @@ export type ActiveGlobalPolicyInspection = | { readonly state: "absent" } | { readonly state: "active"; - readonly inspection: SandboxPolicyAuthorityInspection & { - readonly authority: "externally-managed"; - }; + readonly inspection: OpenShellPolicyInspection & { readonly policySource: "global" }; }; export type OpenShellGlobalPolicyHistoryState = "absent" | "present" | "invalid"; @@ -73,27 +56,7 @@ function isMapping(value: unknown): value is OpenShellPolicyMapping { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isPolicyAuthority(value: unknown): value is OpenShellPolicyAuthority { - return ( - value === "nemoclaw-managed" || value === "externally-managed" || value === "owner-unknown" - ); -} - -const RECEIPT_KEYS = new Set([ - "schemaVersion", - "origin", - "gatewayName", - "gatewayPort", - "sandboxName", - "lifecycleGeneration", - "sandboxIdentityFingerprint", - "policyHash", - "policyVersion", -]); -const RECEIPT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; -const RECEIPT_POLICY_HASH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,255}$/u; -const SHA256_PATTERN = /^[a-f0-9]{64}$/u; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const POLICY_HASH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,255}$/u; function positiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; @@ -105,7 +68,7 @@ function parsePolicyIdentity( ): OpenShellPolicyIdentity { if ( typeof metadata.hash !== "string" || - !RECEIPT_POLICY_HASH_PATTERN.test(metadata.hash) || + !POLICY_HASH_PATTERN.test(metadata.hash) || !positiveInteger(metadata.active_version) ) { throw new Error(invalidMessage); @@ -113,62 +76,6 @@ function parsePolicyIdentity( return { hash: metadata.hash, activeVersion: metadata.active_version }; } -/** Parse a complete receipt. Pending, extended, or malformed values fail closed. */ -export function parseNemoClawPolicyCreationReceipt(value: unknown): NemoClawPolicyCreationReceipt { - if ( - !isMapping(value) || - Object.keys(value).some((key) => !RECEIPT_KEYS.has(key)) || - value.schemaVersion !== 1 || - value.origin !== "sandbox-create" || - typeof value.gatewayName !== "string" || - !RECEIPT_NAME_PATTERN.test(value.gatewayName) || - !positiveInteger(value.gatewayPort) || - value.gatewayPort > 65_535 || - typeof value.sandboxName !== "string" || - !RECEIPT_NAME_PATTERN.test(value.sandboxName) || - typeof value.lifecycleGeneration !== "string" || - !UUID_PATTERN.test(value.lifecycleGeneration) || - typeof value.sandboxIdentityFingerprint !== "string" || - !SHA256_PATTERN.test(value.sandboxIdentityFingerprint) || - typeof value.policyHash !== "string" || - !RECEIPT_POLICY_HASH_PATTERN.test(value.policyHash) || - !positiveInteger(value.policyVersion) - ) { - throw new Error("NemoClaw policy creation receipt is unavailable or invalid"); - } - return { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: value.gatewayName, - gatewayPort: value.gatewayPort, - sandboxName: value.sandboxName, - lifecycleGeneration: value.lifecycleGeneration, - sandboxIdentityFingerprint: value.sandboxIdentityFingerprint, - policyHash: value.policyHash, - policyVersion: value.policyVersion, - }; -} - -/** Require a receipt to describe the exact live policy boundary being used. */ -export function assertNemoClawPolicyCreationReceiptMatches( - value: unknown, - expected: Omit, -): NemoClawPolicyCreationReceipt { - const receipt = parseNemoClawPolicyCreationReceipt(value); - if ( - receipt.gatewayName !== expected.gatewayName || - receipt.gatewayPort !== expected.gatewayPort || - receipt.sandboxName !== expected.sandboxName || - receipt.lifecycleGeneration !== expected.lifecycleGeneration || - receipt.sandboxIdentityFingerprint !== expected.sandboxIdentityFingerprint || - receipt.policyHash !== expected.policyHash || - receipt.policyVersion !== expected.policyVersion - ) { - throw new Error("NemoClaw policy creation receipt does not match the live sandbox policy"); - } - return receipt; -} - function parseJsonMapping(source: string, invalidMessage: string): OpenShellPolicyMapping { let parsed: unknown; try { @@ -183,17 +90,14 @@ function parseJsonMapping(source: string, invalidMessage: string): OpenShellPoli } /** Parse machine-readable effective policy metadata for one sandbox. */ -export function parseSandboxPolicyAuthorityMetadata( +export function parseSandboxPolicyMetadata( raw: string, sandboxName: string, -): SandboxPolicyAuthorityInspection { +): OpenShellPolicyInspection { if (raw.trim().length === 0) { - throw new Error("OpenShell returned empty sandbox policy authority metadata"); + throw new Error("OpenShell returned empty sandbox policy metadata"); } - const metadata = parseJsonMapping( - raw, - "OpenShell returned malformed sandbox policy authority metadata", - ); + const metadata = parseJsonMapping(raw, "OpenShell returned malformed sandbox policy metadata"); if ( metadata.scope !== "sandbox" || metadata.sandbox !== sandboxName || @@ -201,10 +105,10 @@ export function parseSandboxPolicyAuthorityMetadata( (metadata.policy_source !== "sandbox" && metadata.policy_source !== "global") || !isMapping(metadata.policy) ) { - throw new Error("OpenShell returned invalid sandbox policy authority metadata"); + throw new Error("OpenShell returned invalid sandbox policy metadata"); } return { - authority: metadata.policy_source === "sandbox" ? "owner-unknown" : "externally-managed", + policySource: metadata.policy_source, effectivePolicy: metadata.policy, policyIdentity: parsePolicyIdentity( metadata, @@ -214,54 +118,36 @@ export function parseSandboxPolicyAuthorityMetadata( } /** Parse one global policy revision without treating absence as NemoClaw ownership. */ -export function parseActiveGlobalPolicyAuthorityMetadata( - raw: string, -): ActiveGlobalPolicyInspection { +export function parseActiveGlobalPolicyMetadata(raw: string): ActiveGlobalPolicyInspection { if (raw.trim().length === 0) { - throw new Error("OpenShell returned empty global policy authority metadata"); + throw new Error("OpenShell returned empty global policy metadata"); } - const metadata = parseJsonMapping( - raw, - "OpenShell returned malformed global policy authority metadata", - ); + const metadata = parseJsonMapping(raw, "OpenShell returned malformed global policy metadata"); if ( metadata.scope !== "global" || (metadata.status !== "loaded" && metadata.status !== "superseded") || metadata.policy_source !== "global" || Object.hasOwn(metadata, "sandbox") ) { - throw new Error("OpenShell returned invalid global policy authority metadata"); + throw new Error("OpenShell returned invalid global policy metadata"); } if (metadata.status === "superseded") return { state: "absent" }; if (!isMapping(metadata.policy)) { - throw new Error("OpenShell returned invalid global policy authority metadata"); + throw new Error("OpenShell returned invalid global policy metadata"); } return { state: "active", inspection: { - authority: "externally-managed", + policySource: "global", effectivePolicy: metadata.policy, policyIdentity: parsePolicyIdentity( metadata, - "OpenShell returned invalid global policy authority metadata", + "OpenShell returned invalid global policy metadata", ), }, }; } -/** Require durable and observed policy authority to describe the same owner. */ -export function assertMatchingPolicyAuthority(recorded: unknown, observed: unknown): void { - if (!isPolicyAuthority(recorded) || recorded === "owner-unknown") { - throw new Error("the recorded policy authority is unavailable or invalid"); - } - if (!isPolicyAuthority(observed) || observed === "owner-unknown") { - throw new Error("the observed OpenShell policy authority is unavailable or invalid"); - } - if (recorded !== observed) { - throw new Error(`OpenShell policy authority changed from ${recorded} to ${observed}`); - } -} - function policyMapping(value: unknown, invalidMessage: string): OpenShellPolicyMapping { if (!isMapping(value)) throw new Error(invalidMessage); return value; @@ -271,14 +157,32 @@ function formatPolicyKeys(keys: readonly string[]): string { return keys.map((key) => JSON.stringify(key)).join(", "); } +function policyValueContains(observed: unknown, required: unknown): boolean { + if (isMapping(required)) { + return ( + isMapping(observed) && + Object.entries(required).every( + ([key, value]) => + Object.hasOwn(observed, key) && policyValueContains(observed[key], value), + ) + ); + } + if (Array.isArray(required)) { + return ( + Array.isArray(observed) && + required.every((requiredValue) => + observed.some((observedValue) => policyValueContains(observedValue, requiredValue)), + ) + ); + } + return isDeepStrictEqual(observed, required); +} + function assertPolicyRequirementContainmentForOwner( - inspection: SandboxPolicyAuthorityInspection, + inspection: OpenShellPolicyInspection, requiredPolicy: OpenShellPolicyMapping, owner: string, ): void { - if (!isPolicyAuthority(inspection.authority)) { - throw new Error("the observed OpenShell policy authority is invalid"); - } const effectivePolicy = policyMapping( inspection.effectivePolicy, "the observed effective policy is invalid", @@ -296,7 +200,7 @@ function assertPolicyRequirementContainmentForOwner( for (const key of Object.keys(requiredNetwork).sort()) { if (!observedNetwork || !Object.hasOwn(observedNetwork, key)) { missing.push(key); - } else if (!isDeepStrictEqual(observedNetwork[key], requiredNetwork[key])) { + } else if (!policyValueContains(observedNetwork[key], requiredNetwork[key])) { drifted.push(key); } } @@ -308,7 +212,7 @@ function assertPolicyRequirementContainmentForOwner( for (const key of requiredSections) { if (!Object.hasOwn(effectivePolicy, key)) { missingSections.push(key); - } else if (!isDeepStrictEqual(effectivePolicy[key], required[key])) { + } else if (!policyValueContains(effectivePolicy[key], required[key])) { driftedSections.push(key); } } @@ -335,34 +239,12 @@ function assertPolicyRequirementContainmentForOwner( /** Require a policy to contain the requested entries and sections. */ export function assertPolicyRequirementContainment( - inspection: SandboxPolicyAuthorityInspection, + inspection: OpenShellPolicyInspection, requiredPolicy: OpenShellPolicyMapping, ): void { assertPolicyRequirementContainmentForOwner(inspection, requiredPolicy, "observed policy"); } -/** - * Require an external policy to contain the requested entries and sections. - * Additional externally managed content is allowed. - */ -export function assertExternalPolicyRequirementContainment( - inspection: SandboxPolicyAuthorityInspection, - requiredPolicy: OpenShellPolicyMapping, -): void { - if (!isPolicyAuthority(inspection.authority)) { - throw new Error("the observed OpenShell policy authority is invalid"); - } - if (inspection.authority === "owner-unknown") { - throw new Error("the observed OpenShell policy authority is unknown"); - } - if (inspection.authority === "nemoclaw-managed") return; - assertPolicyRequirementContainmentForOwner( - inspection, - requiredPolicy, - "externally managed policy", - ); -} - function assertValidatedPolicyFields( policy: OpenShellPolicyMapping, ): asserts policy is ValidatedOpenShellPolicyMapping { @@ -432,8 +314,8 @@ export function parseOpenShellPolicy(raw: string): ParsedOpenShellPolicy { // invalidState: OpenShell `policy get --base` unexpectedly includes a // provider-composed `_provider_*` entry that `policy set` must never receive. -// sourceBoundary: OpenShell owns base-policy composition; NemoClaw owns every -// read-modify-write payload it submits. +// sourceBoundary: OpenShell owns base-policy composition; NemoClaw is responsible +// only for the exact read-modify-write payload of the current command. // whyNotSourceFix: the upstream formatter cannot be fixed from this repository, // so filter defensively until the supported contract guarantees their absence. // regressionTest: the root policy round-trip and plugin runner policy tests. diff --git a/nemoclaw/src/shared/openshell-policy-boundary.test.ts b/nemoclaw/src/shared/openshell-policy-boundary.test.ts index 1e44c0f3824..36192d61aa7 100644 --- a/nemoclaw/src/shared/openshell-policy-boundary.test.ts +++ b/nemoclaw/src/shared/openshell-policy-boundary.test.ts @@ -5,245 +5,197 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { - assertExternalPolicyRequirementContainment, - assertMatchingPolicyAuthority, - assertNemoClawPolicyCreationReceiptMatches, assertPolicyRequirementContainment, classifyOpenShellGlobalPolicyHistory, - parseActiveGlobalPolicyAuthorityMetadata, - parseNemoClawPolicyCreationReceipt, + parseActiveGlobalPolicyMetadata, parseOpenShellPolicy, - parseSandboxPolicyAuthorityMetadata, + parseSandboxPolicyMetadata, stripProviderComposedPolicies, withoutProviderComposedPolicies, } from "./openshell-policy-boundary.cjs"; -type PolicyDecision = "accepted" | "rejected"; - -function parseDecision(raw: string): PolicyDecision { - try { - parseOpenShellPolicy(raw); - return "accepted"; - } catch { - return "rejected"; - } -} - -const POLICY_CASES = [ - { - name: "valid marked policy", - raw: "Version: 1\n---\nversion: 1\nnetwork_policies:\n safe: {}", - decision: "accepted", - }, - { - name: "unmarked mapping without a policy root", - raw: "future_policy:\n keep: true", - decision: "rejected", - }, - { - name: "versionless network policy", - raw: "network_policies:\n safe: {}", - decision: "accepted", - }, - { name: "missing document", raw: "", decision: "rejected" }, - { - name: "diagnostic output", - raw: "error: gateway unavailable", - decision: "rejected", - }, - { - name: "diagnostic message mapping", - raw: "message: gateway unavailable\ndetails: connection refused", - decision: "rejected", - }, - { - name: "arbitrary lowercase diagnostic mapping", - raw: "reason: gateway unavailable\nretryable: true", - decision: "rejected", - }, - { - name: "malformed YAML", - raw: "version: [unterminated", - decision: "rejected", - }, - { name: "scalar document", raw: "---\nscalar", decision: "rejected" }, - { - name: "sequence document", - raw: "---\n- item", - decision: "rejected", - }, - { - name: "null network policies", - raw: "version: 1\nnetwork_policies: null", - decision: "rejected", - }, - { - name: "string version", - raw: 'version: "1"\nnetwork_policies: {}', - decision: "rejected", - }, - { - name: "fractional version", - raw: "version: 1.5\nnetwork_policies: {}", - decision: "rejected", - }, -] as const; - -describe("sandbox policy authority boundary", () => { - const policy = { version: 1, network_policies: { required: { allow: true } } }; - const metadata = (policySource: "sandbox" | "global", sandbox = "alpha") => - JSON.stringify({ - scope: "sandbox", - sandbox, - status: "effective", - policy_source: policySource, - active_version: 7, - hash: "sha256:effective", - policy, - }); - - it.each([ - ["sandbox", "owner-unknown"], - ["global", "externally-managed"], - ] as const)("classifies the %s policy source as %s", (policySource, authority) => { - expect(parseSandboxPolicyAuthorityMetadata(metadata(policySource), "alpha")).toEqual({ - authority, - effectivePolicy: policy, - policyIdentity: { activeVersion: 7, hash: "sha256:effective" }, +describe("OpenShell policy boundary", () => { + it("parses sandbox policy metadata without assigning an owner", () => { + expect( + parseSandboxPolicyMetadata( + JSON.stringify({ + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "sandbox", + hash: "sha256:one", + active_version: 3, + policy: { version: 1, network_policies: { npm: { endpoints: [] } } }, + }), + "alpha", + ), + ).toEqual({ + policySource: "sandbox", + effectivePolicy: { version: 1, network_policies: { npm: { endpoints: [] } } }, + policyIdentity: { hash: "sha256:one", activeVersion: 3 }, }); }); - it.each([ - ["empty", " \n\t", /empty sandbox policy authority metadata/u], - ["malformed", "{", /malformed sandbox policy authority metadata/u], - ["non-object", "[]", /malformed sandbox policy authority metadata/u], - ["mismatched", metadata("sandbox", "beta"), /invalid sandbox policy authority metadata/u], - ])("rejects %s sandbox authority metadata", (_caseName, raw, expected) => { - expect(() => parseSandboxPolicyAuthorityMetadata(raw, "alpha")).toThrow(expected); - }); - - it("accepts a loaded global policy as active external authority (#9833)", () => { + it("parses active global policy as OpenShell state", () => { expect( - parseActiveGlobalPolicyAuthorityMetadata( + parseActiveGlobalPolicyMetadata( JSON.stringify({ scope: "global", status: "loaded", policy_source: "global", - active_version: 9, hash: "sha256:global", - policy, + active_version: 2, + policy: { version: 1, network_policies: {} }, }), ), ).toEqual({ state: "active", inspection: { - authority: "externally-managed", - effectivePolicy: policy, - policyIdentity: { activeVersion: 9, hash: "sha256:global" }, + policySource: "global", + effectivePolicy: { version: 1, network_policies: {} }, + policyIdentity: { hash: "sha256:global", activeVersion: 2 }, }, }); }); - it.each([ - ["an active revision", "VERSION STATUS\n1 loaded\n", "", "present"], - ["OpenShell 0.0.106 fresh history", "", "No global policy history found\n", "absent"], - ["empty output", "", "", "invalid"], - ["an unexpected diagnostic", "", "gateway warning", "invalid"], - ] as const)( - "classifies %s without treating ambiguous output as absence", - (_name, stdout, stderr, state) => { - expect(classifyOpenShellGlobalPolicyHistory(stdout, stderr)).toBe(state); - }, - ); - - it("treats a superseded global revision as absent without requiring an identity (#9833)", () => { - expect( - parseActiveGlobalPolicyAuthorityMetadata( + it("rejects invalid sandbox identity metadata", () => { + expect(() => + parseSandboxPolicyMetadata( JSON.stringify({ - scope: "global", - status: "superseded", - policy_source: "global", + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "sandbox", + hash: "invalid hash", + active_version: 0, + policy: {}, }), + "alpha", ), - ).toEqual({ state: "absent" }); + ).toThrow(/invalid sandbox policy identity metadata/); }); it.each([ - ["empty output", " \n\t", /empty global policy authority metadata/u], - ["malformed JSON", "{", /malformed global policy authority metadata/u], - ["non-object JSON", "[]", /malformed global policy authority metadata/u], + ["empty global metadata", "", /empty global policy metadata/], [ - "invalid authority metadata", + "invalid global fields", JSON.stringify({ scope: "sandbox", status: "loaded", policy_source: "global" }), - /invalid global policy authority metadata/u, - ], - [ - "sandbox-scoped global metadata", - JSON.stringify({ - scope: "global", - status: "loaded", - policy_source: "global", - sandbox: "alpha", - }), - /invalid global policy authority metadata/u, - ], - [ - "missing loaded policy", - JSON.stringify({ - scope: "global", - status: "loaded", - policy_source: "global", - active_version: 9, - hash: "sha256:global", - }), - /invalid global policy authority metadata/u, - ], - [ - "empty policy identity", - JSON.stringify({ - scope: "global", - status: "loaded", - policy_source: "global", - active_version: 9, - hash: "", - policy, - }), - /invalid global policy authority metadata/u, + /invalid global policy metadata/, ], [ - "malformed policy identity", + "non-mapping global policy", JSON.stringify({ scope: "global", status: "loaded", policy_source: "global", - active_version: "9", - hash: "sha256:global", - policy, + policy: [], }), - /invalid global policy authority metadata/u, + /invalid global policy metadata/, ], - ])("rejects %s for global policy authority inspection (#9833)", (_name, raw, expected) => { - expect(() => parseActiveGlobalPolicyAuthorityMetadata(raw)).toThrow(expected); + ])("rejects %s", (_name, raw, expected) => { + expect(() => parseActiveGlobalPolicyMetadata(raw)).toThrow(expected); }); - it("accepts matching authority and rejects invalid or changed authority", () => { + it("allows unrelated live entries but rejects missing or drifted requirements", () => { + const inspection = { + policySource: "sandbox" as const, + policyIdentity: { hash: "sha256:one", activeVersion: 1 }, + effectivePolicy: { + version: 1, + network_policies: { npm: { endpoints: ["registry.npmjs.org"] }, host: { endpoints: [] } }, + }, + }; expect(() => - assertMatchingPolicyAuthority("externally-managed", "externally-managed"), + assertPolicyRequirementContainment(inspection, { + network_policies: { npm: { endpoints: ["registry.npmjs.org"] } }, + }), ).not.toThrow(); - expect(() => assertMatchingPolicyAuthority(undefined, "externally-managed")).toThrow( - /recorded policy authority is unavailable/u, - ); - expect(() => assertMatchingPolicyAuthority("externally-managed", "unknown")).toThrow( - /observed OpenShell policy authority is unavailable/u, + expect(() => + assertPolicyRequirementContainment(inspection, { + network_policies: { npm: { endpoints: ["different.example"] } }, + }), + ).toThrow(/drifted entries/); + }); + + it("parses base YAML and removes only provider-composed entries", () => { + expect(parseOpenShellPolicy("---\nversion: 1\nnetwork_policies: {}\n").policy).toEqual({ + version: 1, + network_policies: {}, + }); + expect(withoutProviderComposedPolicies({ npm: 1, _provider_token: 2 })).toEqual({ npm: 1 }); + }); + + it("filters provider-composed entries from serialized policy only when present", () => { + const unchanged = "version: 1\nfilesystem_policy:\n read_only: true\n"; + expect(stripProviderComposedPolicies(unchanged)).toBe(unchanged); + + const withoutProviderEntry = "version: 1\nnetwork_policies:\n npm: {}\n"; + expect(stripProviderComposedPolicies(withoutProviderEntry)).toBe(withoutProviderEntry); + + expect( + YAML.parse( + stripProviderComposedPolicies( + "version: 1\nnetwork_policies:\n npm: {}\n _provider_token: {}\n", + ), + ), + ).toEqual({ version: 1, network_policies: { npm: {} } }); + expect(() => stripProviderComposedPolicies("version: [unterminated")).toThrow( + /invalid YAML/, ); - expect(() => assertMatchingPolicyAuthority("nemoclaw-managed", "externally-managed")).toThrow( - /changed from nemoclaw-managed to externally-managed/u, + }); + + it("classifies the OpenShell global history absence contract", () => { + expect(classifyOpenShellGlobalPolicyHistory("", "No global policy history found\n")).toBe( + "absent", ); }); - it("requires external entries and sections while allowing unrelated content", () => { + it.each([ + ["marked policy", "Version: 1\n---\nversion: 1\nnetwork_policies:\n safe: {}", true], + ["versionless network policy", "network_policies:\n safe: {}", true], + ["missing document", "", false], + ["diagnostic mapping", "error: gateway unavailable", false], + ["arbitrary mapping", "future_policy:\n keep: true", false], + ["malformed YAML", "version: [unterminated", false], + ["scalar document", "---\nscalar", false], + ["sequence document", "---\n- item", false], + ["null network policies", "version: 1\nnetwork_policies: null", false], + ["string version", 'version: "1"\nnetwork_policies: {}', false], + ["fractional version", "version: 1.5\nnetwork_policies: {}", false], + ] as const)("validates $0", (_name, raw, accepted) => { + let actual = true; + try { + parseOpenShellPolicy(raw); + } catch { + actual = false; + } + expect(actual).toBe(accepted); + }); + + it.each([ + ["active revision", "VERSION STATUS\n1 loaded\n", "", "present"], + ["fresh history", "", "No global policy history found\n", "absent"], + ["empty output", "", "", "invalid"], + ["unexpected diagnostic", "", "gateway warning", "invalid"], + ] as const)("classifies $0", (_name, stdout, stderr, expected) => { + expect(classifyOpenShellGlobalPolicyHistory(stdout, stderr)).toBe(expected); + }); + + it("treats a superseded global revision as absent without an identity", () => { + expect( + parseActiveGlobalPolicyMetadata( + JSON.stringify({ + scope: "global", + status: "superseded", + policy_source: "global", + }), + ), + ).toEqual({ state: "absent" }); + }); + + it("requires requested sections while allowing unrelated live policy", () => { const inspection = { - authority: "externally-managed" as const, + policySource: "global" as const, policyIdentity: { activeVersion: 7, hash: "sha256:effective" }, effectivePolicy: { version: 9, @@ -253,188 +205,55 @@ describe("sandbox policy authority boundary", () => { }, }; expect(() => - assertExternalPolicyRequirementContainment(inspection, { - version: 1, + assertPolicyRequirementContainment(inspection, { filesystem_policy: { read_only: true }, network_policies: { required: { allow: true } }, }), ).not.toThrow(); expect(() => - assertExternalPolicyRequirementContainment(inspection, { + assertPolicyRequirementContainment(inspection, { filesystem_policy: { read_only: false }, - process: { user: 1000 }, - network_policies: { required: { allow: false }, missing: {} }, }), - ).toThrow( - /missing entries "missing"; drifted entries "required"; missing sections "process"; drifted sections "filesystem_policy"/u, - ); - expect(() => - assertExternalPolicyRequirementContainment( - { - authority: "unknown" as never, - effectivePolicy: {}, - policyIdentity: { activeVersion: 7, hash: "sha256:effective" }, - }, - {}, - ), - ).toThrow(/observed OpenShell policy authority is invalid/u); + ).toThrow(/drifted sections/); expect(() => - assertExternalPolicyRequirementContainment(inspection, { - network_policies: [] as never, + assertPolicyRequirementContainment(inspection, { + process_policy: { run_as_user: true }, }), - ).toThrow(/required network policy input is invalid/u); + ).toThrow(/missing sections/); }); - it("rejects a non-plain policy value that resembles an empty mapping (#9833)", () => { - expect(() => - assertExternalPolicyRequirementContainment( - { - authority: "externally-managed", - effectivePolicy: { network_policies: { required: new Date(0) } }, - policyIdentity: { activeVersion: 7, hash: "sha256:effective" }, - }, - { network_policies: { required: {} } }, - ), - ).toThrow(/drifted entries "required"/u); - }); - - it("requires recorded entries in a NemoClaw-managed policy", () => { + it("accepts OpenShell-enriched policy values while retaining requirements", () => { const inspection = { - authority: "nemoclaw-managed" as const, - effectivePolicy: { network_policies: { required: { allow: true } } }, - policyIdentity: { activeVersion: 7, hash: "sha256:effective" }, + policySource: "sandbox" as const, + policyIdentity: { activeVersion: 8, hash: "sha256:gpu-enriched" }, + effectivePolicy: { + filesystem_policy: { + read_only: ["/etc/ssl", "/proc/driver/nvidia"], + devices: { allow: ["/dev/nvidia0"] }, + }, + network_policies: { + required: { + endpoints: [{ host: "example.test", tls: "passthrough" }], + openshell_metadata: { source: "gpu" }, + }, + }, + }, }; + expect(() => assertPolicyRequirementContainment(inspection, { - network_policies: { required: { allow: true } }, + filesystem_policy: { read_only: ["/etc/ssl"] }, + network_policies: { required: { endpoints: [{ host: "example.test" }] } }, }), ).not.toThrow(); expect(() => assertPolicyRequirementContainment(inspection, { - network_policies: { missing: { allow: true } }, - }), - ).toThrow(/missing entries "missing"/u); - - expect(() => - assertPolicyRequirementContainment( - { ...inspection, authority: "invalid" as never }, - { network_policies: {} }, - ), - ).toThrow(/observed OpenShell policy authority is invalid/u); - expect(() => - assertExternalPolicyRequirementContainment( - { ...inspection, authority: "owner-unknown" }, - { network_policies: {} }, - ), - ).toThrow(/observed OpenShell policy authority is unknown/u); - }); - - it("accepts only a complete secret-free receipt for the exact live policy", () => { - const receipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "alpha", - lifecycleGeneration: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - sandboxIdentityFingerprint: "b".repeat(64), - policyHash: "sha256:effective", - policyVersion: 7, - }; - expect(parseNemoClawPolicyCreationReceipt(receipt)).toEqual(receipt); - expect(() => - assertNemoClawPolicyCreationReceiptMatches(receipt, { - ...receipt, - policyHash: "sha256:drifted", + filesystem_policy: { read_only: ["/missing"] }, }), - ).toThrow(/does not match the live sandbox policy/u); - expect(() => parseNemoClawPolicyCreationReceipt({ ...receipt, status: "pending" })).toThrow( - /unavailable or invalid/u, - ); - expect(JSON.stringify(receipt)).not.toMatch(/credential|network_policies|secret/u); - }); -}); - -describe("canonical OpenShell policy boundary", () => { - it("parses marked output and versionless network policies", () => { - const body = "version: 1\nnetwork_policies:\n safe: {}"; - expect(parseOpenShellPolicy(`Version: 1\n---\n${body}`)).toEqual({ - yamlBody: body, - policy: YAML.parse(body), - }); - - const versionless = "network_policies:\n safe: {}"; - expect(parseOpenShellPolicy(versionless).yamlBody).toBe(versionless); - - const inlineSeparator = 'version: 1\nmetadata:\n marker: "a---b"\nnetwork_policies: {}'; - expect(parseOpenShellPolicy(inlineSeparator).yamlBody).toBe(inlineSeparator); - - const markedFuturePolicy = "Version: 1\n---\nfuture_policy:\n keep: true"; - expect(parseOpenShellPolicy(markedFuturePolicy).policy).toEqual({ - future_policy: { keep: true }, - }); - }); - - it.each(["", "Version: 1\n---", "error: gateway unavailable"])( - "rejects output without a policy: %j", - (raw) => { - expect(() => parseOpenShellPolicy(raw)).toThrow(/does not contain a policy/); - }, - ); - - it("rejects malformed and scalar policy output", () => { - expect(() => parseOpenShellPolicy("version: [unterminated")).toThrow(/not valid YAML/); - expect(() => parseOpenShellPolicy("---\nscalar")).toThrow(/must be a YAML mapping/); - }); - - it.each([ - "version: 1\nnetwork_policies: invalid", - "version: 1\nnetwork_policies: []", - "version: 1\nnetwork_policies: null", - ])("rejects a non-mapping network_policies value: %j", (raw) => { - expect(() => parseOpenShellPolicy(raw)).toThrow(/network_policies must be a YAML mapping/); - }); - - it.each(['version: "1"\nnetwork_policies: {}', "version: 1.5\nnetwork_policies: {}"])( - "rejects a non-integer policy version: %j", - (raw) => { - expect(() => parseOpenShellPolicy(raw)).toThrow(/version must be a positive integer/); - }, - ); - - it("rejects unmarked future output", () => { - expect(() => parseOpenShellPolicy("FutureKey: value")).toThrow(/does not contain a policy/); - }); - - it.each(POLICY_CASES)("returns $decision for $name", ({ raw, decision }) => { - expect(parseDecision(raw)).toBe(decision); + ).toThrow(/drifted sections/); }); - it("removes provider-composed policies without mutating other policy fields", () => { - expect( - withoutProviderComposedPolicies({ safe: { allow: true }, _provider_generated: {} }), - ).toEqual({ safe: { allow: true } }); - - const policy = YAML.stringify({ - version: 1, - future_policy: { keep: true }, - network_policies: { safe: {}, _provider_generated: {} }, - }); - expect(YAML.parse(stripProviderComposedPolicies(policy))).toEqual({ - version: 1, - future_policy: { keep: true }, - network_policies: { safe: {} }, - }); - }); - - it.each(["version: 1", "version: 1\nnetwork_policies:\n safe: {}"])( - "leaves the non-composed mapping %j unchanged", - (policy) => { - expect(stripProviderComposedPolicies(policy)).toBe(policy); - }, - ); - - it("rejects malformed YAML while stripping composed policies", () => { - expect(() => stripProviderComposedPolicies("version: [unterminated")).toThrow(/invalid YAML/); + it.each(["", "{", "[]"])("rejects malformed sandbox metadata [case %#]", (raw) => { + expect(() => parseSandboxPolicyMetadata(raw, "alpha")).toThrow(); }); }); diff --git a/scripts/checks/openshell-policy-mutation-read.mts b/scripts/checks/openshell-policy-mutation-read.mts index 61296288d78..747cf4f7965 100644 --- a/scripts/checks/openshell-policy-mutation-read.mts +++ b/scripts/checks/openshell-policy-mutation-read.mts @@ -67,29 +67,23 @@ export const MUTATION_READS: readonly AuditedPolicyReadFile[] = [ }, { relativePath: "src/lib/policy/index.ts", - expectedReads: [ - ignoredBase("removePreset"), - ignoredBase("readCurrentSandboxPolicy"), - ignoredBase("applyPresetContent"), - ignoredBase("applyPresets"), - preservingBase("customPresetOwnsNetworkPolicyKey"), - ignoredFull("getGatewayPresets/readPolicy"), - preservingBase("getPresetContentGatewayState/readPolicy"), - ], + // Every round-trippable base-policy read is owned by the bounded + // captureSandboxBasePolicy adapter. This remaining --full read is a + // diagnostic preset inventory and never feeds a mutation. + expectedReads: [ignoredFull("getGatewayPresets/readPolicy")], }, { relativePath: "nemoclaw/src/blueprint/runner.ts", expectedReads: [ - unclassifiedBase("actionApply"), - unclassifiedFull("inspectBlueprintPolicyAuthority"), - unclassifiedFull("inspectBlueprintPolicyAuthority"), + unclassifiedBase("applyBlueprintPolicyAdditions"), + unclassifiedFull("inspectBlueprintPolicy"), + unclassifiedFull("inspectBlueprintPolicy"), ], }, { relativePath: "src/lib/shields/index.ts", expectedReads: [ - preservingBase("resolveExactManagedMcpPolicies"), - ignoredBase("resolveProvableManagedMcpPoliciesForDeadline"), + preservingBase("applyShieldsPolicySnapshot"), ignoredBase("shieldsDownWithoutHostLock"), ], }, @@ -97,12 +91,20 @@ export const MUTATION_READS: readonly AuditedPolicyReadFile[] = [ const NON_MUTATION_POLICY_READS: readonly AuditedPolicyReadFile[] = [ { - relativePath: "src/lib/adapters/openshell/policy-authority.ts", + relativePath: "src/lib/adapters/openshell/policy-state.ts", expectedReads: [ unclassifiedBase("captureSandboxBasePolicy"), - unclassifiedFull("inspectSandboxPolicyAuthority"), + unclassifiedFull("inspectSandboxPolicy"), ], }, + { + relativePath: "src/lib/actions/sandbox/snapshot.ts", + expectedReads: [preservingBase("prepareSnapshotClonePolicy")], + }, + { + relativePath: "src/lib/onboard/experimental/hermes-portable-policy-state.ts", + expectedReads: [unclassifiedBase("proveHermesPortableLivePolicy")], + }, { relativePath: "src/lib/actions/sandbox/gateway-state.ts", expectedReads: [ @@ -112,7 +114,7 @@ const NON_MUTATION_POLICY_READS: readonly AuditedPolicyReadFile[] = [ }, { relativePath: "src/lib/actions/sandbox/launch-readiness.ts", - expectedReads: [unclassifiedFull("captureLivePolicy")], + expectedReads: [unclassifiedFull("validateLivePolicy")], }, { relativePath: "src/lib/policy/commands.ts", diff --git a/scripts/runtime-state-mutation-control.py b/scripts/runtime-state-mutation-control.py index f50890f2725..01fb589179e 100755 --- a/scripts/runtime-state-mutation-control.py +++ b/scripts/runtime-state-mutation-control.py @@ -2588,7 +2588,11 @@ def _signal_exact_process(process: ProcessIdentity, requested_signal: int) -> No if current is None: return if current.identity_key() != process.identity_key(): - _fail("writer-pid-reused") + # The pidfd remains bound to the original process, so a numeric PID + # replacement cannot receive this signal. Treat the old writer as + # gone; the caller's next complete writer scan will independently + # discover and handle the replacement identity. + return try: signal.pidfd_send_signal(pidfd, requested_signal) except ProcessLookupError: diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 18910fb7338..d9909f8c2de 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -47,7 +47,7 @@ {"/sandbox/.openclaw", "/sandbox/.hermes", "/sandbox/.deepagents"} ) OPENCLAW_CONFIG_DIR = "/sandbox/.openclaw" -OPENCLAW_NATIVE_MUTABLE_ROOT = "devices" +OPENCLAW_NATIVE_MUTABLE_ROOTS = ("devices", "identity") OPENCLAW_MUTATION_MUTEX_PATH = "/run/nemoclaw/openclaw-config-mutation.lock" MAX_TRANSITION_LOCK_BYTES = 16 * 1024 # Keep this exact source/target contract aligned with @@ -699,9 +699,9 @@ def is_private_writable_root(self, relative_path: str) -> bool: ) def is_openclaw_native_mutable_path(self, relative_path: str) -> bool: - return self.config_path == OPENCLAW_CONFIG_DIR and ( - relative_path == OPENCLAW_NATIVE_MUTABLE_ROOT - or relative_path.startswith(f"{OPENCLAW_NATIVE_MUTABLE_ROOT}/") + return self.config_path == OPENCLAW_CONFIG_DIR and any( + relative_path == root or relative_path.startswith(f"{root}/") + for root in OPENCLAW_NATIVE_MUTABLE_ROOTS ) def is_under_writable_root(self, relative_path: str) -> bool: diff --git a/scripts/sync-agent-variant-docs.mts b/scripts/sync-agent-variant-docs.mts index 1bea87a2649..afbcb04e0f1 100644 --- a/scripts/sync-agent-variant-docs.mts +++ b/scripts/sync-agent-variant-docs.mts @@ -76,7 +76,10 @@ function splitFrontmatter( } function replaceFrontmatterLine(frontmatter: string, key: string, value: string): string { - const pattern = new RegExp(`^${escapeRegExp(key)}:.*$`, "m"); + const pattern = new RegExp( + `^${escapeRegExp(key)}:[^\\r\\n]*(?:\\r?\\n[ \\t]+[^\\r\\n]*)*`, + "m", + ); if (!pattern.test(frontmatter)) { throw new Error(`commands.mdx frontmatter is missing '${key}'`); } diff --git a/src/commands/sandbox/policy/exclude.ts b/src/commands/sandbox/policy/exclude.ts index 19923d10cbc..aa4d1a45568 100644 --- a/src/commands/sandbox/policy/exclude.ts +++ b/src/commands/sandbox/policy/exclude.ts @@ -15,7 +15,7 @@ export default class PolicyExcludeCommand extends NemoClawCommand { static strict = true; static summary = "Exclude an entry from the agent baseline policy"; static description = - "Persistently exclude an exact baseline network policy entry from a sandbox. The removed egress and its support impact are previewed before mutation, and the exclusion is replayed across rebuild."; + "Remove an exact baseline network policy entry from the current OpenShell policy. The removed egress and its support impact are previewed before mutation."; static usage = [" [--force] [--yes|-y] [--dry-run]"]; static examples = [ "<%= config.bin %> sandbox policy exclude alpha nous_research --force", diff --git a/src/lib/actions/inference-route-api.test.ts b/src/lib/actions/inference-route-api.test.ts index 452b33311d4..c77debc708c 100644 --- a/src/lib/actions/inference-route-api.test.ts +++ b/src/lib/actions/inference-route-api.test.ts @@ -39,7 +39,6 @@ function session(overrides: Partial = {}): Session { routerPid: null, routerCredentialHash: null, webSearchConfig: null, - policyPresets: null, messagingPlan: null, migratedLegacyValueHashes: null, hermesToolGateways: null, diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index ff1feabb1d9..f665e6112c4 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -261,7 +261,7 @@ function renderSingleChannelSignals( ? YW : report.report.verdict === "info" ? D - : RD; + : RD; deps.out(` Verdict: ${verdictColor}${report.report.verdict}${R}`); for (const hint of report.report.hints) { deps.out(` ${D}- ${hint}${R}`); @@ -307,7 +307,7 @@ function buildBasicChannelReport( const appliedPresets = deps.getAppliedPresets(sandboxName); const policyPresets = diagnostic.policyPresets.length > 0 ? diagnostic.policyPresets : [channelName]; - const presetInRegistry = policyPresets.some((preset) => appliedPresets.includes(preset)); + const presetApplied = policyPresets.some((preset) => appliedPresets.includes(preset)); const policyLabel = policyPresets.join(", "); const signals: DiagnosticSignal[] = []; signals.push({ @@ -324,11 +324,9 @@ function buildBasicChannelReport( }); signals.push({ label: "Policy coverage", - severity: presetInRegistry ? "ok" : enabled ? "warn" : "info", - detail: presetInRegistry - ? `${policyLabel} preset applied` - : `${policyLabel} preset not applied`, - hint: presetInRegistry + severity: presetApplied ? "ok" : enabled ? "warn" : "info", + detail: presetApplied ? `${policyLabel} preset applied` : `${policyLabel} preset not applied`, + hint: presetApplied ? undefined : `run \`${CLI_NAME} ${sandboxName} policy add ${policyPresets[0]}\``, }); @@ -435,7 +433,7 @@ function runChannelHealthHook( const policyPresets = diagnostic.policyPresets.length > 0 ? diagnostic.policyPresets : [channelName]; const appliedPresets = deps.getAppliedPresets(sandboxName); - const presetInRegistry = policyPresets.some((preset) => appliedPresets.includes(preset)); + const presetApplied = policyPresets.some((preset) => appliedPresets.includes(preset)); let presetOnGateway: boolean | null = null; try { const gatewayPresets = deps.getGatewayPresets(sandboxName); @@ -462,7 +460,7 @@ function runChannelHealthHook( agent: agent.name, probedAt: deps.now().toISOString(), channelEnabledInRegistry, - presetInRegistry, + presetApplied, presetOnGateway, }), }); diff --git a/src/lib/actions/sandbox/connect-flow.test.ts b/src/lib/actions/sandbox/connect-flow.test.ts index 7b9403cf647..6a429c2794f 100644 --- a/src/lib/actions/sandbox/connect-flow.test.ts +++ b/src/lib/actions/sandbox/connect-flow.test.ts @@ -352,7 +352,6 @@ describe("connectSandbox flow", () => { provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", gpuEnabled: false, - policies: [], }); const responses = new Map([ ["sandbox list", { status: 0, output: "alpha Ready" }], @@ -1134,7 +1133,6 @@ describe("connectSandbox flow", () => { agent: "hermes", provider: "ollama-local", model: "qwen3-vl:4b", - policies: [], openshellDriver: "docker", gatewayName: "nemoclaw", lifecycleGeneration: "generation-1", @@ -1169,7 +1167,6 @@ describe("connectSandbox flow", () => { agent: "hermes", provider: null, model: null, - policies: [], openshellDriver: "docker", gatewayName: "nemoclaw", lifecycleGeneration: "generation-1", diff --git a/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts b/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts index 68fd931b142..d6c6a810670 100644 --- a/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts +++ b/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts @@ -19,7 +19,6 @@ function entry(agent: string | null): SandboxEntry { provider: null, model: null, gpuEnabled: false, - policies: [], } as SandboxEntry; } diff --git a/src/lib/actions/sandbox/connect-route-containment.test.ts b/src/lib/actions/sandbox/connect-route-containment.test.ts index 87521d8f031..9e9b12ba07e 100644 --- a/src/lib/actions/sandbox/connect-route-containment.test.ts +++ b/src/lib/actions/sandbox/connect-route-containment.test.ts @@ -60,7 +60,6 @@ describe("connect route containment", () => { provider: "nvidia-prod", openshellDriver: "vm", gpuEnabled: false, - policies: [], }; expect(() => repairSandboxInferenceRouteWithDeps("vm-box", sandbox, {}, deps)).toThrow( @@ -141,53 +140,56 @@ describe("connect route containment", () => { preferredInferenceApi: "openai-responses", }, ], - ] as const)("refuses a different complete custom %s before route reads, mutation, or target probes (#6315)", async (_difference, peerRoute) => { - const target = { - name: "target", - agent: "openclaw", - gatewayName: "nemoclaw", - gatewayPort: 8080, - provider: "compatible-endpoint", - model: "target/model", - endpointUrl: "https://target.example.test/v1", - preferredInferenceApi: "openai-completions", - } as const; - const harness = createConnectHarness({ - inferenceGetOutput: - "Gateway inference:\n Provider: compatible-endpoint\n Model: target/model\n", - registryEntry: target, - registryEntries: [ - target, - { - name: "peer", - agent: "openclaw", - gatewayName: "nemoclaw", - gatewayPort: 8080, - provider: "compatible-endpoint", - model: "peer/model", - ...peerRoute, - }, - ], - }); - - await expect(harness.connectSandbox("target", { probeOnly: true })).rejects.toThrow( - "process.exit(1)", - ); - - expect(harness.captureOpenshellSpy).not.toHaveBeenCalledWith( - ["inference", "get", "-g", "nemoclaw"], - expect.any(Object), - ); - const targetProbeCalls = harness.captureOpenshellSpy.mock.calls.filter( - ([args]) => Array.isArray(args) && args.join(" ").includes("inference.local/v1/models"), - ); - expect(targetProbeCalls).toHaveLength(0); - expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); - expect(harness.errorSpy).toHaveBeenCalledWith( - expect.stringContaining("Cannot set compatible-endpoint / target/model"), - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); + ] as const)( + "refuses a different complete custom %s before route reads, mutation, or target probes (#6315)", + async (_difference, peerRoute) => { + const target = { + name: "target", + agent: "openclaw", + gatewayName: "nemoclaw", + gatewayPort: 8080, + provider: "compatible-endpoint", + model: "target/model", + endpointUrl: "https://target.example.test/v1", + preferredInferenceApi: "openai-completions", + } as const; + const harness = createConnectHarness({ + inferenceGetOutput: + "Gateway inference:\n Provider: compatible-endpoint\n Model: target/model\n", + registryEntry: target, + registryEntries: [ + target, + { + name: "peer", + agent: "openclaw", + gatewayName: "nemoclaw", + gatewayPort: 8080, + provider: "compatible-endpoint", + model: "peer/model", + ...peerRoute, + }, + ], + }); + + await expect(harness.connectSandbox("target", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + + expect(harness.captureOpenshellSpy).not.toHaveBeenCalledWith( + ["inference", "get", "-g", "nemoclaw"], + expect.any(Object), + ); + const targetProbeCalls = harness.captureOpenshellSpy.mock.calls.filter( + ([args]) => Array.isArray(args) && args.join(" ").includes("inference.local/v1/models"), + ); + expect(targetProbeCalls).toHaveLength(0); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Cannot set compatible-endpoint / target/model"), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + ); it("rechecks peers after waiting for the shared gateway route lock", async () => { let releaseLock!: () => void; diff --git a/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts b/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts index f50a8e24ea6..8f12db8afcd 100644 --- a/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts @@ -53,7 +53,6 @@ function sandbox(overrides: Partial = {}): SandboxEntry { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], ...overrides, }; } diff --git a/src/lib/actions/sandbox/connect-route-repair.test.ts b/src/lib/actions/sandbox/connect-route-repair.test.ts index 609e0f6a07f..e0dd43801ff 100644 --- a/src/lib/actions/sandbox/connect-route-repair.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair.test.ts @@ -39,7 +39,6 @@ vi.mock("./gateway-state", () => ({ import { type ManagedInferenceRouteResetDeps, - probeSandboxInferenceRoute, repairSandboxInferenceRouteWithDeps, resetManagedInferenceRouteWithDeps, @@ -65,7 +64,6 @@ function sandbox(overrides: Partial = {}): SandboxEntry { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], ...overrides, }; } @@ -396,7 +394,6 @@ describe("managed inference route reset unit flow", () => { }); }); - describe("connect inference route retries", () => { it("returns the third healthy probe result after two unhealthy probe results (#9218)", () => { vi.mocked(captureOpenshell) diff --git a/src/lib/actions/sandbox/destroy-execution.ts b/src/lib/actions/sandbox/destroy-execution.ts index a54be5f7bd6..5060188a4a8 100644 --- a/src/lib/actions/sandbox/destroy-execution.ts +++ b/src/lib/actions/sandbox/destroy-execution.ts @@ -4,7 +4,7 @@ import { isDeepStrictEqual } from "node:util"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; -import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/policy-authority"; +import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/policy-state"; import { R, YW } from "../../cli/terminal-style"; import { type PreparedPortableDemoSandboxDestroyAuthority, @@ -311,9 +311,9 @@ export async function executeSandboxDestroy({ | { status: "changed"; subject?: string } | { status: "ambiguous"; detail: string; subject?: string } | { status: "probe-failed"; detail: string; subject?: string }; - const pendingPolicyVerification = sandbox?.pendingPolicyVerification; - const inspectPendingPolicyVerificationContinuity = (): IdentityContinuity => { - if (!pendingPolicyVerification) return { status: "match" }; + const pendingCreateIdentity = sandbox?.pendingCreateIdentity; + const inspectPendingCreateVerificationContinuity = (): IdentityContinuity => { + if (!pendingCreateIdentity) return { status: "match" }; if (!getSandbox) { return { status: "probe-failed", @@ -321,9 +321,9 @@ export async function executeSandboxDestroy({ detail: "an exact registry reader is unavailable", }; } - const readCurrentCheckpoint = () => getSandbox(sandboxName)?.pendingPolicyVerification; + const readCurrentCheckpoint = () => getSandbox(sandboxName)?.pendingCreateIdentity; try { - if (!isDeepStrictEqual(readCurrentCheckpoint(), pendingPolicyVerification)) { + if (!isDeepStrictEqual(readCurrentCheckpoint(), pendingCreateIdentity)) { return { status: "changed", subject: "Pending policy verification authority" }; } const inspectIdentity = @@ -331,11 +331,11 @@ export async function executeSandboxDestroy({ inspectOpenShellSandboxIdentityFingerprint; const liveFingerprint = inspectIdentity({ sandboxName, - gatewayName: pendingPolicyVerification.gatewayName, + gatewayName: pendingCreateIdentity.gatewayName, }); if ( - liveFingerprint !== pendingPolicyVerification.sandboxIdentityFingerprint || - !isDeepStrictEqual(readCurrentCheckpoint(), pendingPolicyVerification) + liveFingerprint !== pendingCreateIdentity.sandboxIdentityFingerprint || + !isDeepStrictEqual(readCurrentCheckpoint(), pendingCreateIdentity) ) { return { status: "changed", @@ -352,7 +352,7 @@ export async function executeSandboxDestroy({ } }; const inspectIdentityContinuity = (): IdentityContinuity => { - const pendingContinuity = inspectPendingPolicyVerificationContinuity(); + const pendingContinuity = inspectPendingCreateVerificationContinuity(); if (pendingContinuity.status !== "match") return pendingContinuity; if (portableContainerAuthority) { try { @@ -582,8 +582,8 @@ export async function executeSandboxDestroy({ ` Managed inference cleanup and workspace wipe or hardening may already have run; inspect those resources before retrying.${detachedDetail}`, ); } - const deleteArgs = pendingPolicyVerification - ? ["sandbox", "delete", "-g", pendingPolicyVerification.gatewayName, sandboxName] + const deleteArgs = pendingCreateIdentity + ? ["sandbox", "delete", "-g", pendingCreateIdentity.gatewayName, sandboxName] : ["sandbox", "delete", sandboxName]; const deleteResult = runOpenshell(deleteArgs, { ignoreError: true, diff --git a/src/lib/actions/sandbox/destroy-host-local-inference.test.ts b/src/lib/actions/sandbox/destroy-host-local-inference.test.ts index 00da34149b3..5261c8076e7 100644 --- a/src/lib/actions/sandbox/destroy-host-local-inference.test.ts +++ b/src/lib/actions/sandbox/destroy-host-local-inference.test.ts @@ -20,14 +20,11 @@ const AUTHORITY_ID = `mxc-endpoint:${"a".repeat(64)}`; const BINDING_SHA256 = "d".repeat(64); const MODEL = "qwen3.5-9b"; const SANDBOX_FINGERPRINT = "a".repeat(64); -type ExternalPendingPolicyVerification = Extract< - NonNullable, - { policyAuthority: "externally-managed" } ->; - -function pendingPolicyVerification( - overrides: Partial = {}, -): ExternalPendingPolicyVerification { +type PendingCreateVerification = NonNullable; + +function pendingCreateIdentity( + overrides: Partial = {}, +): PendingCreateVerification { return { schemaVersion: 1, state: "verified-create", @@ -37,10 +34,6 @@ function pendingPolicyVerification( lifecycleGeneration: "alpha-generation-1", sandboxIdentityFingerprint: SANDBOX_FINGERPRINT, route: "none", - policyHash: "policy-hash", - policyVersion: 1, - policyAuthority: "externally-managed", - observedPolicyAuthority: "externally-managed", ...overrides, }; } @@ -280,7 +273,7 @@ describe("sandbox destroy host-local inference transaction", () => { ])("preserves a pending create when its sandbox identity %s", async (_case, inspect) => { const runtimeProvider = provider(); const entry = sandbox("alpha", receipt(), { - pendingPolicyVerification: pendingPolicyVerification(), + pendingCreateIdentity: pendingCreateIdentity(), }); const { result, runOpenshell, stopInferenceResources } = await runDestroy(runtimeProvider, { @@ -300,7 +293,7 @@ describe("sandbox destroy host-local inference transaction", () => { it("re-reads a matching pending checkpoint and gateway-scopes its delete", async () => { const runtimeProvider = provider(); const entry = sandbox("alpha", receipt(), { - pendingPolicyVerification: pendingPolicyVerification(), + pendingCreateIdentity: pendingCreateIdentity(), }); const inspect = vi.fn(() => SANDBOX_FINGERPRINT); @@ -321,14 +314,14 @@ describe("sandbox destroy host-local inference transaction", () => { it("preserves a pending create when its checkpoint changes during identity inspection", async () => { const runtimeProvider = provider(); const entry = sandbox("alpha", receipt(), { - pendingPolicyVerification: pendingPolicyVerification(), + pendingCreateIdentity: pendingCreateIdentity(), }); const getSandbox = vi .fn() .mockReturnValueOnce(entry) .mockReturnValueOnce({ ...entry, - pendingPolicyVerification: pendingPolicyVerification({ policyVersion: 2 }), + pendingCreateIdentity: pendingCreateIdentity({ route: "compatibility" }), }); const runOpenshell = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); const stopInferenceResources = vi.fn(); diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index fa729b103f4..2369f1b2d33 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -48,9 +48,6 @@ function createDoctorHarness( captureHostCommandSpy: MockInstance; configuredMessagingChannelsSpy: MockInstance; executeSandboxCommandForVerificationSpy: MockInstance; - getBaselineExclusionsSpy: MockInstance; - getBaselineExclusionTransitionSpy: MockInstance; - getBaselineExclusionRuntimeStatusSpy: MockInstance; getSandboxSpy: MockInstance; getNamedGatewayLifecycleStateSpy: MockInstance; healthProbeSpy: MockInstance; @@ -78,7 +75,6 @@ function createDoctorHarness( const health = requireDist("../../inference/health.js"); const dockerDriverPlatform = requireDist("../../onboard/docker-driver-platform.js"); const gatewayBinding = requireDist("../../onboard/gateway-binding.js"); - const policy = requireDist("../../policy/index.js"); const sandboxVerificationExec = requireDist("../../onboard/sandbox-verification-exec.js"); const sandboxVersion = requireDist("../../sandbox/version.js"); const shields = requireDist("../../shields/index.js"); @@ -145,13 +141,6 @@ function createDoctorHarness( .spyOn(registry, "getConfiguredMessagingChannelsFromEntry") .mockReturnValue([]); vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); - const getBaselineExclusionsSpy = vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); - const getBaselineExclusionTransitionSpy = vi - .spyOn(registry, "getBaselineExclusionTransition") - .mockReturnValue(null); - const getBaselineExclusionRuntimeStatusSpy = vi - .spyOn(policy, "getBaselineExclusionRuntimeStatus") - .mockReturnValue("excluded"); const resolveOpenShellSpy = vi .spyOn(resolve, "resolveOpenshell") .mockReturnValue("/usr/bin/openshell"); @@ -282,9 +271,6 @@ function createDoctorHarness( captureHostCommandSpy, configuredMessagingChannelsSpy, executeSandboxCommandForVerificationSpy, - getBaselineExclusionsSpy, - getBaselineExclusionTransitionSpy, - getBaselineExclusionRuntimeStatusSpy, getSandboxSpy, getNamedGatewayLifecycleStateSpy, healthProbeSpy, @@ -566,152 +552,6 @@ describe("runSandboxDoctor flow", () => { }, ); - it( - "reports baseline exclusions and flags content drift since approval (#7194)", - testTimeoutOptions(30_000), - async () => { - const harness = createDoctorHarness(); - harness.getBaselineExclusionsSpy.mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - { - version: 1, - agent: "openclaw", - key: "changed_entry", - digest: "digest-stale", - acknowledgedAt: "2026-07-18T00:00:00.000Z", - }, - { - version: 1, - agent: "openclaw", - key: "dropped_entry", - digest: "digest-2", - acknowledgedAt: "2026-07-17T00:00:00.000Z", - }, - ]); - const statuses: Record = { - nous_research: "excluded", - changed_entry: "content-changed", - dropped_entry: "no-longer-in-baseline", - }; - harness.getBaselineExclusionRuntimeStatusSpy.mockImplementation( - (_sandbox, entry) => statuses[entry.key], - ); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - - expect(report?.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - group: "Sandbox", - label: "Baseline exclusion: nous_research", - status: "info", - }), - expect.objectContaining({ - group: "Sandbox", - label: "Baseline exclusion: changed_entry", - status: "warn", - hint: expect.stringContaining("policy restore changed_entry"), - }), - expect.objectContaining({ - group: "Sandbox", - label: "Baseline exclusion: dropped_entry", - status: "warn", - detail: - "Baseline entry 'dropped_entry' no longer exists; rebuild fails closed until the stale exclusion is cleared.", - hint: "key no longer exists in the baseline; run `nemoclaw alpha policy restore dropped_entry` to clear the stale record", - }), - ]), - ); - }, - ); - - it("fails when registry intent is not enforced by the live policy (#7194)", async () => { - const harness = createDoctorHarness(); - harness.getBaselineExclusionsSpy.mockReturnValue([ - { - version: 1, - agent: "hermes", - key: "pypi", - digest: "a".repeat(64), - }, - ]); - harness.getBaselineExclusionRuntimeStatusSpy.mockReturnValue("live-policy-mismatch"); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - - expect(report?.checks).toContainEqual( - expect.objectContaining({ - label: "Baseline exclusion: pypi", - status: "fail", - detail: expect.stringContaining("not enforced"), - }), - ); - }); - - it("flags an interrupted baseline transaction as a rebuild-blocking repair (#7178)", async () => { - const harness = createDoctorHarness(); - harness.getBaselineExclusionTransitionSpy.mockReturnValue({ - id: "tx-1", - operation: "restore", - exclusion: { version: 1, agent: "openclaw", key: "nous_research", digest: "approved" }, - targetLiveDigest: "current", - startedAt: "2026-07-19T00:00:00.000Z", - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - - expect(report?.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - group: "Sandbox", - label: "Baseline exclusion: nous_research", - status: "warn", - detail: expect.stringContaining("interrupted"), - hint: "re-run `nemoclaw alpha policy restore nous_research`", - }), - ]), - ); - }); - - it("keeps repair guidance visible when another exclusion baseline is unreadable (#7194)", async () => { - const harness = createDoctorHarness(); - harness.getBaselineExclusionsSpy.mockReturnValue([ - { version: 1, agent: "openclaw", key: "another_entry", digest: "c".repeat(64) }, - { version: 1, agent: "openclaw", key: "nous_research", digest: "a".repeat(64) }, - ]); - harness.getBaselineExclusionTransitionSpy.mockReturnValue({ - id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", - operation: "restore", - exclusion: { version: 1, agent: "openclaw", key: "nous_research", digest: "a".repeat(64) }, - targetLiveDigest: "b".repeat(64), - startedAt: "2026-07-19T00:00:00.000Z", - }); - harness.getBaselineExclusionRuntimeStatusSpy.mockReturnValue("baseline-unreadable"); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - - expect(report?.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - label: "Baseline exclusion: another_entry", - status: "warn", - detail: expect.stringContaining("unreadable"), - }), - expect.objectContaining({ - label: "Baseline exclusion: nous_research", - status: "warn", - detail: expect.stringContaining("interrupted"), - }), - ]), - ); - }); - it.each(["openclaw", "hermes"] as const)( "pins %s health to the recorded gateway and leaves serving-process health unchecked (#7003)", async (agent) => { diff --git a/src/lib/actions/sandbox/doctor-observation-failure.test.ts b/src/lib/actions/sandbox/doctor-observation-failure.test.ts index 17659f07b13..aefae1faf6b 100644 --- a/src/lib/actions/sandbox/doctor-observation-failure.test.ts +++ b/src/lib/actions/sandbox/doctor-observation-failure.test.ts @@ -58,8 +58,6 @@ vi.mock("../../onboard/runtime-provider/access", () => ({ vi.mock("../../state/registry", () => ({ getSandbox: () => null, - getBaselineExclusionTransition: () => null, - getBaselineExclusions: () => [], })); vi.mock("./doctor-inference", () => ({ diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 1c184386ec7..6d16e66e628 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -32,11 +32,6 @@ import { resolveCurrentRuntimeProviderBundle, } from "../../onboard/runtime-provider/access"; import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec"; -import { getBaselineExclusionRuntimeStatus } from "../../policy"; -import { - BASELINE_EXCLUSION_SUPPORT_IMPACT, - type BaselineExclusionRuntimeStatus, -} from "../../policy/baseline-exclusion"; import { ROOT } from "../../runner"; import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; @@ -452,86 +447,6 @@ function shieldsDoctorCheck(sandboxName: string): DoctorCheck { }; } -function baselineExclusionCheckFields( - sandboxName: string, - key: string, - runtimeStatus: BaselineExclusionRuntimeStatus, -): Pick { - const restoreCommand = `${CLI_NAME} ${sandboxName} policy restore ${key}`; - if (runtimeStatus === "excluded") { - return { - status: "info", - detail: `Baseline entry '${key}' excluded. ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`, - hint: `restore with \`${restoreCommand}\``, - }; - } - if (runtimeStatus === "no-longer-in-baseline") { - return { - status: "warn", - detail: `Baseline entry '${key}' no longer exists; rebuild fails closed until the stale exclusion is cleared.`, - hint: `key no longer exists in the baseline; run \`${restoreCommand}\` to clear the stale record`, - }; - } - if (runtimeStatus === "agent-changed") { - return { - status: "warn", - detail: `Baseline exclusion '${key}' belongs to a different agent; rebuild fails closed until the stale approval is cleared.`, - hint: `run \`${restoreCommand}\`, then review and approve the current agent baseline if needed`, - }; - } - if (runtimeStatus === "baseline-unreadable") { - return { - status: "warn", - detail: "Current agent baseline is unreadable; exclusion scope could not be verified.", - hint: `inspect \`${CLI_NAME} ${sandboxName} policy list\` before rebuilding`, - }; - } - if (runtimeStatus === "live-policy-unreadable") { - return { - status: "warn", - detail: `Live policy for '${key}' is unreadable; exclusion enforcement could not be verified.`, - hint: `restore gateway access, then rerun \`${CLI_NAME} ${sandboxName} doctor\``, - }; - } - if (runtimeStatus === "live-policy-mismatch") { - return { - status: "fail", - detail: `Live policy still contains excluded baseline entry '${key}'; the recorded exclusion is not enforced.`, - hint: `inspect \`${CLI_NAME} ${sandboxName} policy list\`, remove the colliding source, then re-run the exclusion`, - }; - } - return { - status: "warn", - detail: `Baseline entry '${key}' changed since exclusion was approved; rebuild fails closed until re-approved.`, - hint: `run \`${restoreCommand}\`, review with \`${CLI_NAME} ${sandboxName} policy exclude ${key} --dry-run\`, then re-approve`, - }; -} - -function baselineExclusionDoctorChecks(sandboxName: string): DoctorCheck[] { - const transition = registry.getBaselineExclusionTransition(sandboxName); - const checks: DoctorCheck[] = []; - for (const exclusion of registry.getBaselineExclusions(sandboxName)) { - if (transition?.exclusion.key === exclusion.key) continue; - const runtimeStatus = getBaselineExclusionRuntimeStatus(sandboxName, exclusion); - checks.push({ - group: "Sandbox", - label: `Baseline exclusion: ${exclusion.key}`, - ...baselineExclusionCheckFields(sandboxName, exclusion.key, runtimeStatus), - }); - } - if (transition) { - const key = transition.exclusion.key; - checks.push({ - group: "Sandbox", - label: `Baseline exclusion: ${key}`, - status: "warn", - detail: `Baseline policy ${transition.operation} for '${key}' was interrupted; rebuild is blocked until live and durable state are reconciled.`, - hint: `re-run \`${CLI_NAME} ${sandboxName} policy ${transition.operation} ${key}\``, - }); - } - return checks; -} - function collectRegisteredSandboxChecks( sandboxName: string, sb: SandboxEntry | null | undefined, @@ -556,7 +471,6 @@ function collectRegisteredSandboxChecks( }); if (permsCheck) checks.push(permsCheck); checks.push(...collectMessagingDoctorChecks(sandboxName, sb, sandboxReachable)); - checks.push(...baselineExclusionDoctorChecks(sandboxName)); return checks; } diff --git a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts index 253011cf01f..fdf370db27c 100644 --- a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts +++ b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts @@ -29,9 +29,6 @@ function openClawEntry(): SandboxEntry { agentVersion: "1.0.0", nemoclawVersion: "2.0.0", imageTag: "example@sha256:immutable", - policyPresetsFinalized: true, - policies: ["managed_inference"], - policyTier: "standard", provider: null, model: null, endpointUrl: null, @@ -65,7 +62,6 @@ describe("ordinary OpenClaw pairing target", () => { it("resolves ordinary pairing after a supported policy-skip onboarding (#9817)", () => { vi.mocked(deps.getSandbox!).mockReturnValue({ ...openClawEntry(), - policyPresetsFinalized: undefined, }); expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toEqual({ @@ -101,9 +97,7 @@ describe("ordinary OpenClaw pairing target", () => { agent, ), ), - ).toBe( - launchReadinessDigest(buildLaunchReadinessRegistryProjection(openClawEntry(), agent)), - ); + ).toBe(launchReadinessDigest(buildLaunchReadinessRegistryProjection(openClawEntry(), agent))); }); it("resolves a custom Dockerfile without inventing a managed agent version", () => { diff --git a/src/lib/actions/sandbox/launch-readiness.test.ts b/src/lib/actions/sandbox/launch-readiness.test.ts index 3d10332d6a0..052d9799c6a 100644 --- a/src/lib/actions/sandbox/launch-readiness.test.ts +++ b/src/lib/actions/sandbox/launch-readiness.test.ts @@ -18,7 +18,6 @@ import { inspectLaunchReadiness, type LaunchReadinessDeps, launchReadinessDigest, - launchReadinessPolicyDigest, publicationFromDecision, publishLaunchReadiness, withLaunchReadinessMutationGate, @@ -42,17 +41,6 @@ network_policies: - path: /usr/bin/curl `; -const POLICY_A_REORDERED = `network_policies: - public_api: - binaries: - - path: /usr/bin/curl - endpoints: - - port: 443 - host: example.com - name: Public API -version: 1 -`; - const POLICY_B = POLICY_A.replace("example.com", "api.example.com"); function entry(agent = "openclaw"): SandboxEntry { @@ -68,9 +56,6 @@ function entry(agent = "openclaw"): SandboxEntry { agentVersion: "1.0.0", nemoclawVersion: "2.0.0", imageTag: "example@sha256:immutable", - policyPresetsFinalized: true, - policies: ["managed_inference"], - policyTier: "standard", provider: null, model: null, endpointUrl: null, @@ -106,7 +91,7 @@ function servingProfile(): NonNullable function fence(): LaunchReadinessFence { return { - schemaVersion: 2, + schemaVersion: 3, kind: "fence", epochId: EPOCH, sandboxName: SANDBOX, @@ -129,7 +114,7 @@ function fence(): LaunchReadinessFence { function lease(identity: LaunchReadinessIdentity): LaunchReadinessLease { return { - schemaVersion: 2, + schemaVersion: 3, kind: "lease", epochId: EPOCH, sandboxName: SANDBOX, @@ -286,7 +271,6 @@ describe("launch readiness validation", () => { openclawVersion, deviceIdentitySha256: DIGEST, pairingStateSha256, - policySha256: DIGEST, requiredRoles: ["operator"], requiredScopes: ["operator.pairing", "operator.read", "operator.write"], }; @@ -398,13 +382,6 @@ describe("launch readiness validation", () => { pairingStateSha256: "3".repeat(64), }), }, - { - field: "policy", - change: (qualification: LaunchReadinessOpenClawSessionQualification) => ({ - ...qualification, - policySha256: "4".repeat(64), - }), - }, ])("falls back when the OpenClaw $field qualification changes (#9023)", async ({ change }) => { const currentDeps = await createAcceptedLease(); const stored = publishedIdentity?.session; @@ -660,24 +637,6 @@ describe("launch readiness validation", () => { }); it.each([ - { - category: "config", - mutate: () => { - sandbox = { ...sandbox, policyTier: "strict" }; - }, - restore: () => { - sandbox = { ...sandbox, policyTier: "standard" }; - }, - }, - { - category: "config", - mutate: () => { - policy = POLICY_B; - }, - restore: () => { - policy = POLICY_A; - }, - }, { category: "identity", mutate: () => { @@ -705,22 +664,28 @@ describe("launch readiness validation", () => { forwardsHealthy = true; }, }, - ])( - "fences config, policy, live identity, and health changes before fallback [case %#]", - async (testCase) => { - const currentDeps = await createAcceptedLease(); + ])("fences live identity and health changes before fallback [case %#]", async (testCase) => { + const currentDeps = await createAcceptedLease(); - testCase.mutate(); - const decision = await inspectLaunchReadiness(SANDBOX, currentDeps); - expect(decision).toMatchObject({ - kind: "fallback", - category: testCase.category, - fence: { epochId: EPOCH }, - fenceFailed: false, - }); - testCase.restore(); - }, - ); + testCase.mutate(); + const decision = await inspectLaunchReadiness(SANDBOX, currentDeps); + expect(decision).toMatchObject({ + kind: "fallback", + category: testCase.category, + fence: { epochId: EPOCH }, + fenceFailed: false, + }); + testCase.restore(); + }); + + it("accepts an externally changed valid OpenShell policy without shadow identity state", async () => { + const currentDeps = await createAcceptedLease(); + policy = POLICY_B; + + await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ + kind: "accepted", + }); + }); it("requires exact owning-gateway policy, inference route, and semantic health", async () => { vi.stubEnv("OPENSHELL_GATEWAY", "ambient-sibling"); @@ -915,13 +880,6 @@ describe("launch readiness validation", () => { expect(gatewayHealth).not.toHaveBeenCalled(); }); - it("hashes parsed policy semantics instead of presentation bytes", () => { - expect(launchReadinessPolicyDigest(POLICY_A_REORDERED)).toBe( - launchReadinessPolicyDigest(POLICY_A), - ); - expect(launchReadinessPolicyDigest(POLICY_B)).not.toBe(launchReadinessPolicyDigest(POLICY_A)); - }); - it("uses an exact versioned allowlist for launch-affecting registry state", () => { const agent = loadAgent("openclaw"); const projection = buildLaunchReadinessRegistryProjection(sandbox, agent) as Record< @@ -932,8 +890,6 @@ describe("launch readiness validation", () => { [ "agent", "agentVersion", - "baselineExclusions", - "customPolicies", "dashboardPort", "dashboardRemoteBindPrepared", "dcodeAutoApprovalMode", @@ -963,9 +919,6 @@ describe("launch readiness validation", () => { "openclawImagePluginInstalls", "openshellDriver", "openshellVersion", - "policies", - "policyPresetsFinalized", - "policyTier", "sandboxGpuDevice", "sandboxGpuEnabled", "sandboxGpuMode", @@ -1005,23 +958,6 @@ describe("launch readiness validation", () => { { ...sandbox, sandboxGpuDevice: "0" }, { ...sandbox, servingProfileProvenance: servingProfile() }, { ...sandbox, hermesAuthMethod: "oauth" }, - { ...sandbox, policies: ["managed_inference", "slack"] }, - { - ...sandbox, - customPolicies: [{ name: "custom", content: POLICY_A, pendingContent: POLICY_B }], - }, - { - ...sandbox, - baselineExclusions: [ - { - version: 1, - agent: "openclaw", - key: "phone_home", - digest: DIGEST, - appliedAgentVersion: "1.0.0", - }, - ], - }, { ...sandbox, webSearchEnabled: true, webSearchProvider: "brave" }, { ...sandbox, observabilityEnabled: true }, { ...sandbox, hermesDashboardEnabled: true, hermesDashboardPort: 3000 }, @@ -1042,11 +978,15 @@ describe("launch readiness validation", () => { ], }, ]; - expect(mutations.every((mutation) => + expect( + mutations.every( + (mutation) => !Object.is( launchReadinessDigest(buildLaunchReadinessRegistryProjection(mutation, agent)), original, - ))).toBe(true); + ), + ), + ).toBe(true); }); it("binds current Portable lifecycle state into final readiness publication (#9207)", async () => { @@ -1166,33 +1106,6 @@ describe("launch readiness validation", () => { expect(publishLease).not.toHaveBeenCalled(); }); - it("publishes no readiness lease for a policy-incomplete Portable receipt (#9207)", async () => { - const currentDeps = deps(); - currentDeps.classifyPortableLifecycleReceipt = () => ({ - kind: "current", - registryGeneration: "generation-1", - runtimeAuthority: { - schemaVersion: 1, - kind: "podman", - ownership: "current-user", - uid: 1001, - homeDir: "/home/operator", - configHome: "/home/operator/.config", - runtimeDir: "/run/user/1001", - socketPath: "/run/user/1001/podman/podman.sock", - }, - }); - sandbox = { ...sandbox, policyPresetsFinalized: undefined }; - const publishLease = vi.fn(); - currentDeps.publishLease = publishLease; - const decision = await inspectLaunchReadiness(SANDBOX, currentDeps); - - await expect( - publishLaunchReadiness(publicationFromDecision(SANDBOX, decision), currentDeps), - ).resolves.toEqual({ kind: "validation-failed", category: "config" }); - expect(publishLease).not.toHaveBeenCalled(); - }); - it("binds every host mount field without projecting the host source path (#8942)", () => { const agent = loadAgent("openclaw"); const source = "/private/host/customer-project"; @@ -1249,11 +1162,15 @@ describe("launch readiness validation", () => { ], }, ]; - expect(mutations.every((mutation) => + expect( + mutations.every( + (mutation) => !Object.is( launchReadinessDigest(buildLaunchReadinessRegistryProjection(mutation, agent)), original, - ))).toBe(true); + ), + ), + ).toBe(true); expect(() => buildLaunchReadinessRegistryProjection( { @@ -1298,7 +1215,9 @@ describe("launch readiness validation", () => { { ...originalProfile, estimatedImageDownloadBytes: 1_001 }, { ...originalProfile, estimatedModelDownloadBytes: 2_001 }, ]; - expect(mutations.every((mutation) => + expect( + mutations.every( + (mutation) => !Object.is( launchReadinessDigest( buildLaunchReadinessRegistryProjection( @@ -1307,7 +1226,9 @@ describe("launch readiness validation", () => { ), ), original, - ))).toBe(true); + ), + ), + ).toBe(true); }); it("excludes diagnostic timestamps, source paths, and GPU detail from the projection", () => { @@ -1315,23 +1236,6 @@ describe("launch readiness validation", () => { const first: SandboxEntry = { ...sandbox, createdAt: "2026-01-01T00:00:00.000Z", - customPolicies: [ - { - name: "custom", - content: POLICY_A, - sourcePath: "/first/policy.yaml", - appliedAt: "2026-01-01T00:00:00.000Z", - }, - ], - baselineExclusions: [ - { - version: 1, - agent: "openclaw", - key: "phone_home", - digest: DIGEST, - acknowledgedAt: "2026-01-01T00:00:00.000Z", - }, - ], sandboxGpuProof: { status: "verified", cudaVerified: true, @@ -1343,25 +1247,6 @@ describe("launch readiness validation", () => { const second: SandboxEntry = { ...first, createdAt: "2026-06-01T00:00:00.000Z", - customPolicies: [ - { - ...first.customPolicies?.[0], - name: "custom", - content: POLICY_A, - sourcePath: "/second/policy.yaml", - appliedAt: "2026-06-01T00:00:00.000Z", - }, - ], - baselineExclusions: [ - { - ...first.baselineExclusions?.[0], - version: 1, - agent: "openclaw", - key: "phone_home", - digest: DIGEST, - acknowledgedAt: "2026-06-01T00:00:00.000Z", - }, - ], sandboxGpuProof: { ...first.sandboxGpuProof!, detail: "second diagnostic", @@ -1476,25 +1361,5 @@ describe("launch readiness validation", () => { agent, ), ).toThrow(); - expect(() => - buildLaunchReadinessRegistryProjection( - { - ...sandbox, - baselineExclusionTransition: { - id: "transition", - operation: "exclude", - exclusion: { - version: 1, - agent: "openclaw", - key: "phone_home", - digest: DIGEST, - }, - targetLiveDigest: null, - startedAt: "2026-01-01T00:00:00.000Z", - }, - }, - agent, - ), - ).toThrow(); }); }); diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index 37cf47a0ba4..39cf8418643 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -283,10 +283,6 @@ function projectOptionalBoolean(value: unknown): boolean { return value; } -export function launchReadinessPolicyDigest(content: string): string { - return launchReadinessDigest(parseAndValidateSandboxPolicy(content)); -} - function projectWorkload(workload: SandboxWorkloadReceipt | undefined): unknown { if (!workload) return null; if (workload.kind === "legacy-dockerfile") { @@ -387,7 +383,6 @@ function projectMessagingState(entry: SandboxEntry): unknown { agent: persisted.plan.agent, workflow: persisted.plan.workflow, disabledChannels: [...persisted.plan.disabledChannels], - networkPolicy: persisted.plan.networkPolicy, channels: persisted.plan.channels.map((channel) => { const originalInputs = new Map( (originalChannels.get(channel.channelId)?.inputs ?? []).map((input) => [ @@ -520,27 +515,6 @@ export function buildLaunchReadinessRegistryProjection( if (entry.pendingRouteReservation === true) { throw new ObservationError("config"); } - if (entry.baselineExclusionTransition) throw new ObservationError("config"); - - const customPolicies = (entry.customPolicies ?? []).map((policy) => ({ - name: policy.name, - contentSha256: exactContentDigest(policy.content), - pendingContentSha256: - typeof policy.pendingContent === "string" ? exactContentDigest(policy.pendingContent) : null, - pinAuthoritySha256: policy.trustedPrivatePins - ? launchReadinessDigest({ - version: policy.trustedPrivatePins.version, - contentDigest: policy.trustedPrivatePins.contentDigest, - }) - : null, - })); - const baselineExclusions = (entry.baselineExclusions ?? []).map((exclusion) => ({ - version: exclusion.version, - agent: exclusion.agent, - key: exclusion.key, - digest: exclusion.digest, - appliedAgentVersion: exclusion.appliedAgentVersion ?? null, - })); const inference = normalizeInferenceSelection(entry); if ( inference.credentialEnv !== null && @@ -617,17 +591,12 @@ export function buildLaunchReadinessRegistryProjection( } : null, inference, - policies: [...(entry.policies ?? [])], - policyTier: normalizedString(entry.policyTier), - policyPresetsFinalized: entry.policyPresetsFinalized === true, ...(portableRuntimeAuthoritySha256 ? { portableLifecycleReceipt: "current", portableRuntimeAuthoritySha256, } : {}), - customPolicies, - baselineExclusions, webSearchEnabled: entry.webSearchEnabled === true, webSearchProvider: entry.webSearchProvider ?? null, toolDisclosure: entry.toolDisclosure ?? null, @@ -658,17 +627,17 @@ function classifyReceipt( return read.kind === "valid" ? "config" : read.kind; } -function captureLivePolicy( +function validateLivePolicy( sandboxName: string, gatewayName: string, deps: LaunchReadinessDeps, -): string { +): void { const result = ( deps.capture ?? ((args) => captureLaunchReadiness(args, { maxBuffer: LIVE_POLICY_MAX_BYTES })) )(["policy", "get", "-g", gatewayName, "--full", sandboxName]); if (result.status !== 0 || !result.output?.trim()) throw new LaunchReadinessEvidenceError(); try { - return launchReadinessPolicyDigest(result.output); + parseAndValidateSandboxPolicy(result.output); } catch { throw new LaunchReadinessEvidenceError(); } @@ -711,10 +680,7 @@ async function captureLaunchIdentity( if (entry.agent === "openclaw") { if (portableReceipt.kind === "invalid-or-legacy") throw new ObservationError("config"); if (portableReceipt.kind === "current") { - if ( - entry.policyPresetsFinalized !== true || - entry.lifecycleGeneration !== portableReceipt.registryGeneration - ) { + if (entry.lifecycleGeneration !== portableReceipt.registryGeneration) { throw new ObservationError("config"); } portableRuntimeAuthoritySha256 = launchReadinessDigest(portableReceipt.runtimeAuthority); @@ -758,7 +724,7 @@ async function captureLaunchIdentity( throw new ObservationError("identity"); } - const livePolicy = captureLivePolicy(sandboxName, gatewayName, deps); + validateLivePolicy(sandboxName, gatewayName, deps); const inferenceSelection = normalizeInferenceSelection(entry); const inference = registry.getSandboxEntryInference(entry); const inferenceResult = (deps.capture ?? ((args) => captureLaunchReadiness(args)))( @@ -813,7 +779,6 @@ async function captureLaunchIdentity( identity: { registry: launchReadinessDigest(projection), agent: launchReadinessDigest(projectAgent(agent)), - livePolicy, liveInference: launchReadinessDigest({ selection: inferenceSelection, live: liveInference @@ -837,7 +802,6 @@ function compareIdentity( const baseMatches = left.registry === right.registry && left.agent === right.agent && - left.livePolicy === right.livePolicy && left.liveInference === right.liveInference && left.gatewayName === right.gatewayName && left.lifecycleGeneration === right.lifecycleGeneration && @@ -1033,7 +997,7 @@ export async function settlePortableOpenClawPairing( sandboxName: string, options: { readonly portableRequired?: boolean; - readonly revalidatePolicyRequirements?: (operation: string) => void; + readonly verifyLivePolicyRequirements?: (operation: string) => void; } = {}, deps: LaunchReadinessDeps = {}, ): Promise { @@ -1048,7 +1012,7 @@ export async function settlePortableOpenClawPairing( deps.observeOpenClawPairingSettlement ?? observeOpenClawPairingSettlement; const runProducer = deps.runPortablePairingProducer ?? runPortableOpenClawPairingRequestProducer; const runApproval = deps.runPortablePairingApproval ?? runPortableOpenClawPairingApproval; - const revalidatePolicyRequirements = options.revalidatePolicyRequirements; + const verifyLivePolicyRequirements = options.verifyLivePolicyRequirements; const now = deps.now ?? (() => performance.now()); const sleep = deps.sleep ?? @@ -1069,10 +1033,9 @@ export async function settlePortableOpenClawPairing( if ( firstEntry?.agent === null && options.portableRequired === true && - firstEntry.policyPresetsFinalized === true && portableLifecycleReceiptMatchesGeneration(firstReceipt, firstEntry.lifecycleGeneration) ) { - revalidatePolicyRequirements?.(`update the recorded agent for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`update the recorded agent for sandbox '${sandboxName}'`); if (!updateSandbox(sandboxName, { agent: "openclaw" })) { return incompletePortablePairing("portable-runtime-identity-invalid"); } @@ -1095,9 +1058,6 @@ export async function settlePortableOpenClawPairing( if (firstReceipt.kind !== "current") { return incompletePortablePairing("portable-receipt-invalid"); } - if (firstEntry.policyPresetsFinalized !== true) { - return incompletePortablePairing("portable-policy-incomplete"); - } const firstTarget = resolveOpenClawPairingSettlementTarget( sandboxName, firstEntry, @@ -1112,9 +1072,6 @@ export async function settlePortableOpenClawPairing( if (lockedReceipt.kind !== "current" || portableReceiptChanged(firstReceipt, lockedReceipt)) { return incompletePortablePairing("portable-receipt-invalid"); } - if (lockedEntry?.policyPresetsFinalized !== true) { - return incompletePortablePairing("portable-policy-incomplete"); - } const target = resolveOpenClawPairingSettlementTarget( sandboxName, lockedEntry, @@ -1149,7 +1106,7 @@ export async function settlePortableOpenClawPairing( } const first = initial.value; if (first.state === "settled") { - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `publish settled Portable OpenClaw pairing for sandbox '${sandboxName}'`, ); return { kind: "settled" }; @@ -1157,12 +1114,12 @@ export async function settlePortableOpenClawPairing( // A canonical pending transition is the producer's completed output. if (first.state === "pairing-only") { - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `request Portable OpenClaw pairing for sandbox '${sandboxName}'`, ); runProducer(sandboxName, target.gatewayName); } - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `approve Portable OpenClaw pairing for sandbox '${sandboxName}'`, ); runApproval(sandboxName, target.gatewayName, first.deviceIdentitySha256); @@ -1194,7 +1151,7 @@ export async function settlePortableOpenClawPairing( if (final.kind !== "observed") { return incompletePortablePairing("portable-pairing-incomplete"); } - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `publish settled Portable OpenClaw pairing for sandbox '${sandboxName}'`, ); return { kind: "settled" }; diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts index cdcf299a71f..792a465aa53 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -223,7 +223,6 @@ describe("OpenClaw launch-readiness pairing qualification", () => { requiredScopes: ["operator.pairing", "operator.read", "operator.write"], deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), pairingStateSha256: expect.stringMatching(/^[a-f0-9]{64}$/), - policySha256: expect.stringMatching(/^[a-f0-9]{64}$/), }); expect(serialized).not.toContain(TOKEN); expect(serialized).not.toContain(PRIVATE_KEY); diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts index 7daccd7626b..e0fd4fab6a9 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; import path from "node:path"; import { resolveOpenshellBinary } from "../../../adapters/openshell/command-argv"; @@ -82,10 +81,6 @@ export class OpenClawPairingObservationRetryableError extends OpenClawPairingQua } } -function sha256(value: string): string { - return createHash("sha256").update(value, "utf8").digest("hex"); -} - function hasExactKeys(value: Record, keys: readonly string[]): boolean { const actual = Object.keys(value).sort(); const expected = [...keys].sort(); @@ -835,7 +830,6 @@ export function observeOpenClawPairingQualification( kind: "openclaw-pairing", openclawVersion: normalizedVersion, ...projection, - policySha256: sha256(executed.policy), }; } catch (error) { if (error instanceof OpenClawPairingQualificationError) throw error; diff --git a/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts b/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts index 758be368374..1f5c141adfe 100644 --- a/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts @@ -43,7 +43,6 @@ const ENTRY = { name: "alpha", agent: "openclaw", agentVersion: "2026.7.1", - policyPresetsFinalized: true, lifecycleGeneration: "generation-1", lifecycleLiveIdentityFingerprint: "fingerprint-1", gatewayName: "nemoclaw", @@ -194,39 +193,6 @@ describe("Portable OpenClaw pairing settlement", () => { expect(scope.runApproval).not.toHaveBeenCalled(); }); - it("performs zero pairing writes for pre-finalization pairing-only state (#9207)", async () => { - const scope = settlementDeps({ - getSandbox: vi.fn(() => ({ ...ENTRY, policyPresetsFinalized: undefined })), - }); - - await expect(settlePortableOpenClawPairing("alpha", {}, scope.deps)).resolves.toEqual({ - kind: "incomplete", - reason: "portable-policy-incomplete", - }); - expect(scope.calls).toEqual(["sandbox-lock"]); - expect(scope.observePairing).not.toHaveBeenCalled(); - expect(scope.runProducer).not.toHaveBeenCalled(); - expect(scope.runApproval).not.toHaveBeenCalled(); - }); - - it("fails closed when Portable policy finalization changes inside the gateway lock (#9207)", async () => { - const getSandbox = vi - .fn() - .mockReturnValueOnce(ENTRY) - .mockReturnValue({ ...ENTRY, policyPresetsFinalized: undefined }); - const scope = settlementDeps({ getSandbox }); - - await expect(settlePortableOpenClawPairing("alpha", {}, scope.deps)).resolves.toEqual({ - kind: "incomplete", - reason: "portable-policy-incomplete", - }); - expect(scope.calls).toEqual(["sandbox-lock", "gateway-lock"]); - expect(getSandbox).toHaveBeenCalledTimes(2); - expect(scope.observePairing).not.toHaveBeenCalled(); - expect(scope.runProducer).not.toHaveBeenCalled(); - expect(scope.runApproval).not.toHaveBeenCalled(); - }); - it("leaves a current Portable receipt on the ordinary non-OpenClaw path (#9207)", async () => { const scope = settlementDeps({ getSandbox: vi.fn(() => ({ ...ENTRY, agent: "hermes" })), @@ -299,14 +265,14 @@ describe("Portable OpenClaw pairing settlement", () => { }); it("does not repair a legacy registry row when authority changes while acquiring the lifecycle lock (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; + let verifyLivePolicyRequirements = () => undefined; const updateSandbox = vi.fn(() => true); const scope = settlementDeps({ getSandbox: vi.fn(() => ({ ...ENTRY, agent: null })), updateSandbox, withSandboxLock: vi.fn(async (_name, operation) => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); + verifyLivePolicyRequirements = () => { + throw new Error("policy requirements changed"); }; return operation(); }), @@ -317,11 +283,11 @@ describe("Portable OpenClaw pairing settlement", () => { "alpha", { portableRequired: true, - revalidatePolicyRequirements: () => revalidatePolicyRequirements(), + verifyLivePolicyRequirements: () => verifyLivePolicyRequirements(), }, scope.deps, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(updateSandbox).not.toHaveBeenCalled(); expect(scope.observePairing).not.toHaveBeenCalled(); @@ -352,7 +318,7 @@ describe("Portable OpenClaw pairing settlement", () => { expect(scope.runApproval).not.toHaveBeenCalled(); }); - it("does not repair a legacy OpenClaw row without exact receipt and policy authority (#9207)", async () => { + it("does not repair a legacy OpenClaw row without exact receipt and policy requirements (#9207)", async () => { const updateSandbox = vi.fn(() => true); const scope = settlementDeps({ getSandbox: vi.fn(() => ({ @@ -611,11 +577,11 @@ describe("Portable OpenClaw pairing settlement", () => { }); it("does not produce or approve a request when authority changes during initial observation (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; + let verifyLivePolicyRequirements = () => undefined; const scope = settlementDeps(); scope.observePairing.mockImplementationOnce(() => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); + verifyLivePolicyRequirements = () => { + throw new Error("policy requirements changed"); }; return { state: "pairing-only", @@ -627,11 +593,11 @@ describe("Portable OpenClaw pairing settlement", () => { settlePortableOpenClawPairing( "alpha", { - revalidatePolicyRequirements: () => revalidatePolicyRequirements(), + verifyLivePolicyRequirements: () => verifyLivePolicyRequirements(), }, scope.deps, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(scope.runProducer).not.toHaveBeenCalled(); expect(scope.runApproval).not.toHaveBeenCalled(); @@ -639,11 +605,11 @@ describe("Portable OpenClaw pairing settlement", () => { }); it("does not approve a request when authority changes during request production (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; + let verifyLivePolicyRequirements = () => undefined; const scope = settlementDeps({ runPortablePairingProducer: vi.fn(() => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); + verifyLivePolicyRequirements = () => { + throw new Error("policy requirements changed"); }; }), }); @@ -656,11 +622,11 @@ describe("Portable OpenClaw pairing settlement", () => { settlePortableOpenClawPairing( "alpha", { - revalidatePolicyRequirements: () => revalidatePolicyRequirements(), + verifyLivePolicyRequirements: () => verifyLivePolicyRequirements(), }, scope.deps, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(scope.deps.runPortablePairingProducer).toHaveBeenCalledOnce(); expect(scope.runApproval).not.toHaveBeenCalled(); @@ -668,11 +634,11 @@ describe("Portable OpenClaw pairing settlement", () => { }); it("does not publish settled pairing when authority changes during initial observation (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; + let verifyLivePolicyRequirements = () => undefined; const scope = settlementDeps(); scope.observePairing.mockImplementationOnce(() => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); + verifyLivePolicyRequirements = () => { + throw new Error("policy requirements changed"); }; return { state: "settled", @@ -684,11 +650,11 @@ describe("Portable OpenClaw pairing settlement", () => { settlePortableOpenClawPairing( "alpha", { - revalidatePolicyRequirements: () => revalidatePolicyRequirements(), + verifyLivePolicyRequirements: () => verifyLivePolicyRequirements(), }, scope.deps, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(scope.runProducer).not.toHaveBeenCalled(); expect(scope.runApproval).not.toHaveBeenCalled(); @@ -696,7 +662,7 @@ describe("Portable OpenClaw pairing settlement", () => { }); it("does not publish settled pairing when authority changes during final observation (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; + let verifyLivePolicyRequirements = () => undefined; const scope = settlementDeps(); scope.observeFinalPairing.mockReturnValueOnce({ state: "settled", @@ -708,8 +674,8 @@ describe("Portable OpenClaw pairing settlement", () => { deviceIdentitySha256: "b".repeat(64), }) .mockImplementationOnce(() => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); + verifyLivePolicyRequirements = () => { + throw new Error("policy requirements changed"); }; return { state: "settled", @@ -721,11 +687,11 @@ describe("Portable OpenClaw pairing settlement", () => { settlePortableOpenClawPairing( "alpha", { - revalidatePolicyRequirements: () => revalidatePolicyRequirements(), + verifyLivePolicyRequirements: () => verifyLivePolicyRequirements(), }, scope.deps, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(scope.runProducer).toHaveBeenCalledOnce(); expect(scope.runApproval).toHaveBeenCalledOnce(); diff --git a/src/lib/actions/sandbox/launch.test.ts b/src/lib/actions/sandbox/launch.test.ts index 41a0bbc8ff2..2b7ffdb8725 100644 --- a/src/lib/actions/sandbox/launch.test.ts +++ b/src/lib/actions/sandbox/launch.test.ts @@ -118,7 +118,6 @@ function sandboxEntry(agentName: string | null): SandboxEntry { provider: null, model: null, gpuEnabled: false, - policies: [], } as SandboxEntry; } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts index 5c861a226ea..7f4ce98b358 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ ensureSandboxGatewaySelected: vi.fn(), getBridgeAdapter: vi.fn(), getSandboxAgent: vi.fn(), + getSandboxPolicy: vi.fn(), getSandboxOrThrow: vi.fn(), inspectMcpProvider: vi.fn(), observeMcpCredentialRevision: vi.fn(), @@ -55,9 +56,14 @@ vi.mock("./mcp-bridge-destroy-preflight", () => ({ vi.mock("./mcp-bridge-policy", () => ({ assertGeneratedPolicyMutationSafe: vi.fn(), assertGeneratedPolicyRegistrationMutationSafe: vi.fn(), + buildMcpBridgePolicyKey: vi.fn(() => "mcp_bridge_github"), removeGeneratedPolicy: mocks.removeGeneratedPolicy, })); +vi.mock("./policy-get", () => ({ + getSandboxPolicy: mocks.getSandboxPolicy, +})); + vi.mock("./mcp-bridge-restart", () => ({ restoreExistingMcpBridgeRuntime: mocks.restoreExistingMcpBridgeRuntime, })); @@ -107,6 +113,10 @@ describe("MCP adapter teardown rollback", () => { mocks.ensureSandboxGatewaySelected.mockReset().mockResolvedValue(undefined); mocks.getBridgeAdapter.mockReset().mockReturnValue("hermes-config"); mocks.getSandboxAgent.mockReset().mockReturnValue("hermes"); + mocks.getSandboxPolicy.mockReset().mockReturnValue({ + raw: "", + yaml: "version: 1\nnetwork_policies:\n mcp_bridge_github: {}\n", + }); mocks.getSandboxOrThrow.mockReset().mockReturnValue(sandbox); mocks.inspectMcpProvider.mockReset().mockReturnValue({ exists: false }); mocks.observeMcpCredentialRevision.mockReset().mockReturnValue("v12"); diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index b5e189826a8..a07134f04ff 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -119,14 +119,6 @@ function assertPreparedMcpAddResourcesAbsent( ); } - const existingPolicy = registry - .getCustomPolicies(sandboxName) - .find((policy) => policy.name === entry.policyName); - if (existingPolicy) { - throw new McpBridgeError( - `MCP add preflight for '${entry.server}' found an existing policy ownership record '${entry.policyName}'. The durable add manifest was preserved without claiming it.`, - ); - } const policyContent = buildMcpBridgePolicyYaml( entry.server, entry.url, @@ -273,10 +265,10 @@ async function addMcpBridgeUnlocked( adapter, url: normalizedUrl, env: envNames, + allowedIps: [...target.addresses], ...(target.trustedPrivateHost ? { trustedPrivateHost: target.trustedPrivateHost, - allowedIps: [...target.addresses], } : {}), ...(providerName ? { providerName } : {}), diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts index 0f65bb46ca3..01c0da3d863 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts @@ -89,8 +89,7 @@ export async function discardSafeIncompleteMcpAdds( if (Object.keys(remaining).length === Object.keys(bridges).length) return sandbox; for (const entry of providerlessPreflighted) { if (options.sandboxAbsent) { - const ownedRegistration = assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); - if (ownedRegistration) registry.removeCustomPolicyByName(sandboxName, entry.policyName); + assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } else { removeGeneratedPolicy(sandboxName, entry); } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index fcfa0b95ffb..56e249d5dc6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -48,7 +48,7 @@ export { * Phase one of sandbox destroy. Remove the adapter entry from the retained * sandbox volume and detach exact MCP providers while preserving the global * provider objects (and therefore their host-only credentials) and registry - * cleanup manifest. OpenShell requires the bound policy to be removed before + * cleanup manifest. OpenShell requires the generated policy key to be removed before * detach. Any failure restores the managed runtime before returning. */ export async function prepareMcpBridgesForDestroy( @@ -60,7 +60,7 @@ export async function prepareMcpBridgesForDestroy( (entry) => entry.addState !== "prepared", ); // Run the host-visible config preflight before - // discardSafeIncompleteMcpAdds, which may remove an owned policy for a + // discardSafeIncompleteMcpAdds, which may remove the generated live policy key for a // providerless preflighted add. That cleanup has no adapter/provider to // probe; complete entries get the teardown runtime probe after retry markers. assertMcpAdapterConfigMutationsAllowed( @@ -178,9 +178,7 @@ export async function prepareMcpBridgesForDestroy( } } if (!runtimeRestored) { - rollbackFailures.push( - ...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters), - ); + rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); } const current = registry.getSandbox(sandboxName); if (current?.mcp?.destroyPreparedAt) { @@ -281,7 +279,7 @@ export async function restoreMcpBridgesAfterDestroyAbort( /** * Phase two of sandbox destroy, called only after OpenShell confirmed the * sandbox is gone. Delete exact matching global providers, then clear the MCP - * bridge manifest and owned custom-policy records in one registry update. + * bridge lifecycle record in one registry update. */ export async function finalizeMcpBridgesAfterSandboxDelete( sandboxName: string, @@ -340,15 +338,9 @@ export async function finalizeMcpBridgesAfterSandboxDelete( } } - const finalSandbox = assertMcpDestroySnapshotCurrent(sandboxName, entries); - const ownedPolicyNames = new Set(entries.map((entry) => entry.policyName)); - const remainingCustomPolicies = (finalSandbox.customPolicies ?? []).filter( - (policy) => - !(ownedPolicyNames.has(policy.name) && policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE), - ); + assertMcpDestroySnapshotCurrent(sandboxName, entries); const cleared = registry.updateSandbox(sandboxName, { mcp: undefined, - customPolicies: remainingCustomPolicies.length > 0 ? remainingCustomPolicies : undefined, }); if (!cleared) { throw new McpBridgeError( diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts index 23315de2c5a..0d0b08cf22e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts @@ -1,80 +1,117 @@ // 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 { beforeEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; -import type { AgentMcpAdapter } from "../../agent/defs"; import * as policies from "../../policy"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; import type { McpBridgeEntry } from "../../state/registry"; -import * as registry from "../../state/registry"; import { + applyGeneratedPolicy, buildMcpBridgePolicyName, - buildMcpBridgePolicyYaml as renderMcpBridgePolicyYaml, - buildMcpBridgeProviderName, + buildMcpBridgePolicyYaml, + getRegisteredGeneratedPolicy, MCP_BRIDGE_ALLOWED_METHODS, MCP_BRIDGE_POLICY_MAX_BODY_BYTES, - MCP_BRIDGE_POLICY_SOURCE, -} from "./mcp-bridge"; -import { - applyGeneratedPolicy, - assertGeneratedPolicyExactReadOnly, - assertGeneratedPolicyMutationSafe, removeGeneratedPolicy, } from "./mcp-bridge-policy"; -import type { McpBridgeTargetValidation } from "./mcp-bridge-url-validation"; +import { buildMcpBridgeProviderName } from "./mcp-bridge-validation"; + +const entry: McpBridgeEntry = { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://mcp.example.com/api", + env: [], + allowedIps: ["8.8.8.8"], + providerName: "mcp-github", + policyName: buildMcpBridgePolicyName("github"), + addedAt: "2026-08-27T00:00:00.000Z", +}; + +beforeEach(() => vi.restoreAllMocks()); + +describe("generated MCP policy", () => { + it("derives canonical policy content from MCP domain state", () => { + expect(getRegisteredGeneratedPolicy("alpha", entry)).toEqual( + expect.objectContaining({ + name: entry.policyName, + content: expect.stringContaining("allowed_ips"), + }), + ); + }); -function buildMcpBridgePolicyYaml( - server: string, - url: string, - adapter: AgentMcpAdapter, - target: McpBridgeTargetValidation, -): string { - return renderMcpBridgePolicyYaml(server, url, adapter, target, "alpha-mcp-bound-provider"); -} + it("applies directly to live OpenShell policy without a custom-policy registry row", () => { + const livePolicy: { network_policies: Record } = { network_policies: {} }; + vi.spyOn(policies, "applyPresetContent").mockImplementation( + (_sandboxName, _presetName, content) => { + Object.assign( + livePolicy.network_policies, + (YAML.parse(content) as typeof livePolicy).network_policies, + ); + return true; + }, + ); + vi.spyOn(policies, "getPresetContentGatewayState").mockImplementation( + (_sandboxName, content) => { + const expected = (YAML.parse(content) as typeof livePolicy).network_policies; + return Object.keys(expected).every((key) => key in livePolicy.network_policies) + ? "match" + : "absent"; + }, + ); -function githubBridgeEntry(overrides: Partial = {}): McpBridgeEntry { - return { - server: "github", - agent: "openclaw", - adapter: "mcporter", - url: "https://api.githubcopilot.com/mcp", - env: ["GITHUB_MCP_TOKEN"], - providerName: "alpha-mcp-github-0123456789abcdef", - policyName: "mcp-bridge-github", - addedAt: "2026-06-01T00:00:00.000Z", - ...overrides, - }; -} + applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }); -describe("MCP OpenShell policy", () => { - afterEach(() => { - vi.restoreAllMocks(); + expect(livePolicy.network_policies.mcp_bridge_github).toMatchObject({ + endpoints: [ + expect.objectContaining({ + allowed_ips: ["8.8.8.8"], + credential_binding: { provider: "mcp-github" }, + }), + ], + }); }); - it("refuses to apply a generated policy without exact public address pins", () => { + it("removes generated content from the live policy", () => { + const livePolicy: { network_policies: Record } = { + network_policies: { + mcp_bridge_github: YAML.parse( + buildMcpBridgePolicyYaml( + "github", + entry.url, + "mcporter", + { addresses: ["8.8.8.8"] }, + "mcp-github", + ), + ).network_policies.mcp_bridge_github, + }, + }; + vi.spyOn(policies, "removePreset").mockImplementation( + (_sandboxName, _presetName, options) => { + const removal = (YAML.parse(options?.presetContent ?? "") as typeof livePolicy) + .network_policies; + expect(removal).toHaveProperty("mcp_bridge_github"); + delete livePolicy.network_policies.mcp_bridge_github; + return true; + }, + ); + + removeGeneratedPolicy("alpha", entry); + + expect(livePolicy.network_policies).not.toHaveProperty("mcp_bridge_github"); + }); + + it("refuses generated policy without exact public address pins", () => { expect(() => - applyGeneratedPolicy( - "alpha", - { - server: "github", - agent: "openclaw", - adapter: "mcporter", - url: "https://api.githubcopilot.com/mcp", - env: ["GITHUB_MCP_TOKEN"], - providerName: "alpha-mcp-github-0123456789abcdef", - policyName: "mcp-bridge-github", - addedAt: "2026-06-01T00:00:00.000Z", - }, - { addresses: [] }, - ), + applyGeneratedPolicy("alpha", { ...entry, allowedIps: [] }, { addresses: [] }), ).toThrow(/without exact public address pins/); }); - it("refuses to render an MCP credential binding without an exact provider name", () => { + it("refuses to render a credential binding without an exact provider name", () => { expect(() => - renderMcpBridgePolicyYaml( + buildMcpBridgePolicyYaml( "github", "https://api.githubcopilot.com/mcp", "mcporter", @@ -83,7 +120,7 @@ describe("MCP OpenShell policy", () => { ), ).toThrow(/requires an exact provider name/); expect(() => - renderMcpBridgePolicyYaml( + buildMcpBridgePolicyYaml( "github", "https://api.githubcopilot.com/mcp", "mcporter", @@ -93,91 +130,35 @@ describe("MCP OpenShell policy", () => { ).toThrow(/requires an exact provider name/); }); - it("inspects an unowned direct-private policy key without targetless rendering (#8267)", () => { - const entry = githubBridgeEntry({ - server: "local", - url: "https://10.20.30.40/mcp", - trustedPrivateHost: "10.20.30.40", - allowedIps: ["10.20.30.40"], - policyName: "mcp-bridge-local", - }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - const inspectKey = vi.spyOn(policies, "getLiveSandboxPolicyEntryDigest").mockReturnValue(null); - const inspectContent = vi.spyOn(policies, "getPresetContentGatewayState"); - const removePreset = vi.spyOn(policies, "removePreset"); - - expect(() => assertGeneratedPolicyMutationSafe("alpha", entry)).not.toThrow(); - expect(() => removeGeneratedPolicy("alpha", entry)).not.toThrow(); - expect(inspectKey).toHaveBeenCalledWith("alpha", "mcp_bridge_local"); - expect(inspectContent).not.toHaveBeenCalled(); - expect(removePreset).not.toHaveBeenCalled(); - }); - - it("can remove the live policy while preserving rebuild journal ownership (#9792)", () => { - const entry = githubBridgeEntry(); - const registration = { - name: entry.policyName, - content: "network_policies:\n mcp_bridge_github: {}\n", - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }; - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([registration]); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("match") - .mockReturnValueOnce("absent"); - const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - const removeOwnership = vi.spyOn(registry, "removeCustomPolicyByName"); - - expect(() => - removeGeneratedPolicy("alpha", entry, { preserveRegistryOwnership: true }), - ).not.toThrow(); - - expect(removePreset).toHaveBeenCalledWith( - "alpha", - entry.policyName, - expect.objectContaining({ skipRegistryUpdate: true }), - ); - expect(removeOwnership).not.toHaveBeenCalled(); - }); - - it("pins DNS answers while constraining the generic mcporter Node grant", () => { - const policyName = buildMcpBridgePolicyName("GitHub_Server"); - const policy = YAML.parse( - buildMcpBridgePolicyYaml("GitHub_Server", "https://api.githubcopilot.com/mcp", "mcporter", { - addresses: ["2606:4700:4700::1111", "8.8.8.8"], - }), + it("pins DNS answers and the current MCP method profile for mcporter", () => { + const parsed = YAML.parse( + buildMcpBridgePolicyYaml( + "GitHub_Server", + "https://api.githubcopilot.com/mcp", + "mcporter", + { addresses: ["2606:4700:4700::1111", "8.8.8.8"] }, + "alpha-mcp-bound-provider", + ), ) as { preset: { name: string }; network_policies: Record< string, { endpoints: Array<{ - host: string; - port: number; - path: string; - protocol: string; + allowed_ips: string[]; credential_binding: { provider: string }; - mcp: { - max_body_bytes: number; - strict_tool_names?: boolean; - allow_all_known_mcp_methods?: boolean; - }; - allowed_ips?: string[]; - rules?: Array<{ allow: { method: string } }>; + mcp: Record; + rules: Array<{ allow: { method: string } }>; }>; binaries: Array<{ path: string }>; } >; }; - const entry = policy.network_policies.mcp_bridge_github_server; + const policy = parsed.network_policies.mcp_bridge_github_server; - expect(policyName).toBe("mcp-bridge-github-server"); - expect(policy.preset.name).toBe(policyName); - expect(entry.endpoints[0]).toMatchObject({ - host: "api.githubcopilot.com", - port: 443, - path: "/mcp", - protocol: "mcp", - enforcement: "enforce", + expect(parsed.preset.name).toBe("mcp-bridge-github-server"); + expect(policy.endpoints[0]).toMatchObject({ + allowed_ips: ["2606:4700:4700::1111", "8.8.8.8"], credential_binding: { provider: "alpha-mcp-bound-provider" }, mcp: { max_body_bytes: MCP_BRIDGE_POLICY_MAX_BODY_BYTES, @@ -185,377 +166,146 @@ describe("MCP OpenShell policy", () => { allow_all_known_mcp_methods: false, }, }); - expect(entry.endpoints[0].rules).toEqual( - MCP_BRIDGE_ALLOWED_METHODS.map((method) => ({ - allow: { method }, - })), + expect(policy.endpoints[0].rules).toEqual( + MCP_BRIDGE_ALLOWED_METHODS.map((method) => ({ allow: { method } })), ); - expect(entry.endpoints[0].allowed_ips).toEqual(["2606:4700:4700::1111", "8.8.8.8"]); - expect(entry.binaries.map((binary) => binary.path)).toEqual([ + expect(policy.binaries.map(({ path }) => path)).toEqual([ "/usr/local/bin/mcporter", "/usr/bin/mcporter", "/usr/local/bin/openclaw", "/usr/local/bin/node", "/usr/bin/node", ]); - expect(entry.endpoints[0].mcp).toEqual({ - max_body_bytes: MCP_BRIDGE_POLICY_MAX_BODY_BYTES, - strict_tool_names: true, - allow_all_known_mcp_methods: false, - }); }); it.each(["mcporter", "hermes-config", "deepagents-config"] as const)( - "renders an exactly authorized private IPv4 target for %s (#8267)", + "renders an authorized private target for %s with a process-local capability", (adapter) => { const replay = replayTrustedPrivateEndpoint("10.20.30.40", ["10.20.30.40"]); - const policy = YAML.parse( - buildMcpBridgePolicyYaml("local", "https://10.20.30.40/mcp", adapter, { - addresses: [...replay.addresses], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }), + const parsed = YAML.parse( + buildMcpBridgePolicyYaml( + "local", + "https://10.20.30.40/mcp", + adapter, + { + addresses: [...replay.addresses], + trustedPrivateCapability: replay.trustedPrivateCapability, + trustedPrivateHost: replay.host, + }, + "alpha-mcp-private-provider", + ), ) as { network_policies: Record< string, { endpoints: Array<{ allowed_ips: string[]; host: string }> } >; }; - - expect(policy.network_policies.mcp_bridge_local.endpoints[0]).toMatchObject({ + expect(parsed.network_policies.mcp_bridge_local.endpoints[0]).toMatchObject({ host: "10.20.30.40", allowed_ips: ["10.20.30.40"], }); }, ); - it("requires host-bound capability authority for a trusted private DNS policy (#8267)", () => { + it("rejects a missing, forged, or host-mismatched private capability", () => { const replay = replayTrustedPrivateEndpoint("mcp.corp.internal", ["10.20.30.40"]); const target = { addresses: [...replay.addresses], trustedPrivateCapability: replay.trustedPrivateCapability, trustedPrivateHost: replay.host, }; - - expect(() => - buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", target), - ).not.toThrow(); expect(() => - buildMcpBridgePolicyYaml("local", "https://other.corp.internal/mcp", "mcporter", target), + buildMcpBridgePolicyYaml( + "local", + "https://other.corp.internal/mcp", + "mcporter", + target, + "alpha-mcp-private-provider", + ), ).toThrow(/does not match URL host/); expect(() => - buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", { - addresses: ["10.20.30.40"], - trustedPrivateHost: "mcp.corp.internal", - }), + buildMcpBridgePolicyYaml( + "local", + "https://mcp.corp.internal/mcp", + "mcporter", + { addresses: ["10.20.30.40"], trustedPrivateHost: "mcp.corp.internal" }, + "alpha-mcp-private-provider", + ), ).toThrow(/no provenance-checked endpoint capability/); - }); - - it("rejects empty and structurally forged render targets (#8267)", () => { - expect(() => - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter", { - addresses: [], - }), - ).toThrow(/non-empty canonical set/); expect(() => - buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", { - addresses: ["10.20.30.40"], - trustedPrivateHost: "mcp.corp.internal", - trustedPrivateCapability: { - host: "mcp.corp.internal", + buildMcpBridgePolicyYaml( + "local", + "https://mcp.corp.internal/mcp", + "mcporter", + { addresses: ["10.20.30.40"], - }, - } as never), + trustedPrivateHost: "mcp.corp.internal", + trustedPrivateCapability: { + host: "mcp.corp.internal", + addresses: ["10.20.30.40"], + }, + } as never, + "alpha-mcp-private-provider", + ), ).toThrow(/does not match its host-bound endpoint capability/); }); - it("applies internally generated DNS pins outside the user-supplied preset path", () => { - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - vi.spyOn(registry, "addCustomPolicy").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("absent") - .mockReturnValueOnce("match"); - const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - - applyGeneratedPolicy( - "alpha", - { - server: "github", - agent: "openclaw", - adapter: "mcporter", - url: "https://api.githubcopilot.com/mcp", - env: ["GITHUB_MCP_TOKEN"], - providerName: "alpha-mcp-github-0123456789abcdef", - policyName: "mcp-bridge-github", - addedAt: "2026-06-01T00:00:00.000Z", - }, - { addresses: ["8.8.8.8"] }, - ); - - const [, , generatedContent, options] = applyPresetContent.mock.calls[0]; - expect(generatedContent).toContain("allowed_ips:"); - expect(options).toEqual({ - expectedExistingNetworkPolicyContent: null, - nonFatal: true, - skipRegistryUpdate: true, - }); - }); - - it("accepts only the canonical generated policy for the exact bridge and DNS pins", () => { - const entry = githubBridgeEntry(); - const pins = ["2606:4700:4700::1111", "8.8.8.8"]; - const content = renderMcpBridgePolicyYaml( - entry.server, - entry.url, - "mcporter", - { addresses: pins }, - entry.providerName ?? "", - ); - const registration = { - name: entry.policyName, - content, - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }; - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([registration]); - const gatewayState = vi - .spyOn(policies, "getPresetContentGatewayState") - .mockReturnValue("match"); - - expect( - assertGeneratedPolicyExactReadOnly("alpha", entry, "mcporter", { addresses: pins }), - ).toEqual(registration); - expect(gatewayState).toHaveBeenCalledWith("alpha", content); - }); - - it.each(["owned-first", "unowned-first"])( - "rejects duplicate same-name ownership records regardless of order (%s)", - (order) => { - const entry = githubBridgeEntry(); - const pins = ["8.8.8.8"]; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: pins, - }); - const owned = { - name: entry.policyName, - content, - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }; - const unowned = { - name: entry.policyName, - content: `${content}\n# conflicting duplicate`, - sourcePath: "/tmp/operator-policy.yaml", - }; - vi.spyOn(registry, "getCustomPolicies").mockReturnValue( - order === "owned-first" ? [owned, unowned] : [unowned, owned], - ); - const gatewayState = vi.spyOn(policies, "getPresetContentGatewayState"); - - expect(() => - assertGeneratedPolicyExactReadOnly("alpha", entry, "mcporter", { addresses: pins }), - ).toThrow(/ownership is missing or ambiguous/); - expect(gatewayState).not.toHaveBeenCalled(); - }, - ); - - it("rejects individually valid policy records that disagree with their bridge definition", () => { - const entry = githubBridgeEntry(); - const pins = ["8.8.8.8"]; - const canonical = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: pins, - }); - const wrongKeyDocument = YAML.parse(canonical) as { - network_policies: Record; - }; - wrongKeyDocument.network_policies.mcp_bridge_other = { - ...wrongKeyDocument.network_policies.mcp_bridge_github, - name: "mcp_bridge_other", - }; - delete wrongKeyDocument.network_policies.mcp_bridge_github; - - const mismatches: Array<{ - label: string; - candidateEntry?: McpBridgeEntry; - candidateName?: string; - content: string; - }> = [ - { - label: "host", - content: buildMcpBridgePolicyYaml( - entry.server, - "https://mcp.example.test/mcp", - "mcporter", - { addresses: pins }, - ), - }, - { - label: "path", - content: buildMcpBridgePolicyYaml( - entry.server, - "https://api.githubcopilot.com/other", - "mcporter", - { addresses: pins }, - ), - }, - { - label: "adapter", - content: buildMcpBridgePolicyYaml(entry.server, entry.url, "hermes-config", { - addresses: pins, - }), - }, - { label: "network policy key", content: YAML.stringify(wrongKeyDocument) }, - { - label: "resolved address pins", - content: buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: ["1.1.1.1"], - }), - }, - { - label: "policy name", - candidateEntry: githubBridgeEntry({ policyName: "mcp-bridge-other" }), - candidateName: "mcp-bridge-other", - content: canonical, - }, - ]; - - mismatches.forEach((mismatch) => { - vi.restoreAllMocks(); - const candidateEntry = mismatch.candidateEntry ?? entry; - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([ - { - name: mismatch.candidateName ?? candidateEntry.policyName, - content: mismatch.content, - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }, - ]); - const gatewayState = vi.spyOn(policies, "getPresetContentGatewayState"); - - expect( - () => - assertGeneratedPolicyExactReadOnly("alpha", candidateEntry, "mcporter", { - addresses: pins, - }), - mismatch.label, - ).toThrow(/not canonical for its recorded bridge definition/); - expect(gatewayState, mismatch.label).not.toHaveBeenCalled(); - }); - }); - - it("does not expose malformed persisted URLs in canonical ownership errors", () => { - const secret = `nvapi-${"a".repeat(32)}`; - const entry = githubBridgeEntry({ url: `not-a-url-${secret}` }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - - let message = ""; - try { - assertGeneratedPolicyExactReadOnly("alpha", entry, "mcporter", { - addresses: ["8.8.8.8"], - }); - } catch (error) { - message = error instanceof Error ? error.message : String(error); - } - expect(message).toContain("not canonical for its recorded bridge definition"); - expect(message).not.toContain(secret); - }); - - it("pins the current OpenShell main client-to-server MCP method profile", () => { - expect(MCP_BRIDGE_ALLOWED_METHODS).toEqual([ - "initialize", - "notifications/initialized", - "ping", - "tools/list", - "tools/call", - "resources/list", - "resources/read", - "resources/templates/list", - "resources/subscribe", - "resources/unsubscribe", - "prompts/list", - "prompts/get", - "tasks/list", - "tasks/get", - "tasks/update", - "tasks/result", - "tasks/cancel", - "completion/complete", - "logging/setLevel", - "server/discover", - "messages/listen", - "notifications/cancelled", - "notifications/progress", - "notifications/roots/list_changed", - "notifications/elicitation/complete", - ]); - }); - - it("emits only fields supported by OpenShell current main", () => { - const policy = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter", { - addresses: ["8.8.8.8"], - }), - ) as { network_policies: Record> }> }; - const endpoint = policy.network_policies.mcp_bridge_srv.endpoints[0]; - expect(endpoint).not.toHaveProperty("credential_keys"); - expect(endpoint).not.toHaveProperty("tls"); - }); - it.each([ "host.openshell.internal", "host.openshell.internal.", "host.docker.internal", "host.containers.internal", - ])( - "refuses to generate authenticated policies for unpinnable OpenShell host aliases [case %#]", - (host) => { - expect(() => - buildMcpBridgePolicyYaml("local", `https://${host}:31337/mcp`, "mcporter", { - addresses: ["8.8.8.8"], - }), - ).toThrow(/does not expose an attested driver gateway address/); - }, - ); - - it("scopes binaries to the selected agent adapter", () => { - const hermes = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "hermes-config", { - addresses: ["8.8.8.8"], - }), - ) as { - network_policies: Record }>; - }; - const deepAgents = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "deepagents-config", { - addresses: ["8.8.8.8"], - }), - ) as { - network_policies: Record }>; - }; + ])("refuses an unpinnable host alias [case %#]", (host) => { + expect(() => + buildMcpBridgePolicyYaml( + "local", + `https://${host}:31337/mcp`, + "mcporter", + { addresses: ["8.8.8.8"] }, + "alpha-mcp-provider", + ), + ).toThrow(/does not expose an attested driver gateway address/); + }); - expect(hermes.network_policies.mcp_bridge_srv.binaries.map((b) => b.path)).toEqual([ - "/usr/local/bin/hermes", - "/usr/bin/python3*", - "/opt/hermes/.venv/bin/python*", - ]); - expect(deepAgents.network_policies.mcp_bridge_srv.binaries.map((b) => b.path)).toEqual([ - "/usr/local/bin/dcode", - "/opt/venv/bin/python3*", - ]); + it("emits only current OpenShell fields and scopes binaries by adapter", () => { + const render = (adapter: "mcporter" | "hermes-config" | "deepagents-config") => + YAML.parse( + buildMcpBridgePolicyYaml( + "srv", + "https://mcp.example.test/mcp", + adapter, + { addresses: ["8.8.8.8"] }, + "alpha-mcp-provider", + ), + ) as { + network_policies: Record< + string, + { binaries: Array<{ path: string }>; endpoints: Array> } + >; + }; + const mcporter = render("mcporter").network_policies.mcp_bridge_srv; + expect(mcporter.endpoints[0]).not.toHaveProperty("credential_keys"); + expect(mcporter.endpoints[0]).not.toHaveProperty("tls"); + expect( + render("hermes-config").network_policies.mcp_bridge_srv.binaries.map((b) => b.path), + ).toEqual(["/usr/local/bin/hermes", "/usr/bin/python3*", "/opt/hermes/.venv/bin/python*"]); + expect( + render("deepagents-config").network_policies.mcp_bridge_srv.binaries.map((b) => b.path), + ).toEqual(["/usr/local/bin/dcode", "/opt/venv/bin/python3*"]); }); it("uses stable collision-resistant provider names with a length guard", () => { expect(buildMcpBridgeProviderName("alpha", "github-server")).toBe("alpha-mcp-github-server"); const caseNormalized = buildMcpBridgeProviderName("alpha", "GitHub-Server"); const underscoreNormalized = buildMcpBridgeProviderName("alpha", "github_server"); - expect(caseNormalized).toMatch(/^alpha-mcp-github-server-[a-f0-9]{16}$/); - expect(underscoreNormalized).toMatch(/^alpha-mcp-github-server-[a-f0-9]{16}$/); + expect(caseNormalized).toMatch(/^alpha-mcp-github-server-[a-f0-9]{16}$/u); + expect(underscoreNormalized).toMatch(/^alpha-mcp-github-server-[a-f0-9]{16}$/u); expect(new Set([caseNormalized, underscoreNormalized, "alpha-mcp-github-server"]).size).toBe(3); - const long = buildMcpBridgeProviderName( - "sandbox-name-prefix", - "ServerNameThatWouldOtherwiseExceedTheProviderNameLimit", - ); - expect(long.length).toBeLessThanOrEqual(63); - expect(long).toMatch(/^sandbox-name-prefix-mcp-servernamethatwouldoth-[a-f0-9]{16}$/); - expect(buildMcpBridgeProviderName("alpha", "github-server", "0123456789abcdef")).toBe( - "alpha-mcp-github-server-0123456789abcdef", - ); + expect( + buildMcpBridgeProviderName( + "sandbox-name-prefix", + "ServerNameThatWouldOtherwiseExceedTheProviderNameLimit", + ).length, + ).toBeLessThanOrEqual(63); }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index c3805905258..741216e43e4 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -1,20 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isIP } from "node:net"; import { isDeepStrictEqual } from "node:util"; -import YAML from "yaml"; import type { AgentMcpAdapter } from "../../agent/defs"; -import { diagnosticPreview } from "../../name-validation"; import * as policies from "../../policy"; -import { isBlockedMcpUrlTargetHost } from "../../security/mcp-url-target"; import { assertTrustedPrivateEndpointCapability, replayTrustedPrivateEndpoint, } from "../../security/trusted-private-endpoint"; import type { McpBridgeEntry } from "../../state/registry"; -import * as registry from "../../state/registry"; import { isAgentMcpAdapter, MCP_BRIDGE_POLICY_SOURCE, @@ -37,601 +32,16 @@ export { MCP_BRIDGE_POLICY_MAX_BODY_BYTES, } from "./mcp-bridge-policy-render"; -export interface ExactManagedMcpPolicy { - key: string; - networkPolicy: unknown; - policyName: string; - server: string; -} - -export interface ManagedMcpPolicyOmission { - key?: string; - policyName?: string; - server?: string; - reason: string; -} - -export interface ProvableManagedMcpPolicies { - policies: ExactManagedMcpPolicy[]; - omissions: ManagedMcpPolicyOmission[]; -} - -type ManagedMcpPolicyInspectionDeps = { - getSandbox: typeof registry.getSandbox; -}; - -const managedMcpPolicyInspectionDeps: ManagedMcpPolicyInspectionDeps = { - getSandbox: registry.getSandbox, -}; - -function parseManagedPolicyDocument(source: string, label: string): Record { - let parsed: unknown; - try { - parsed = YAML.parse(source); - } catch { - throw new Error(`${label} is not valid YAML`); - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`${label} must be a YAML mapping`); - } - return parsed as Record; -} - -function readManagedNetworkPolicies( - document: Record, - label: string, -): Record { - const networkPolicies = document.network_policies; - if (networkPolicies === undefined || networkPolicies === null) return {}; - if (typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { - throw new Error(`${label} network_policies must be a mapping`); - } - return networkPolicies as Record; -} - -function requireCanonicalAllowedIps( - networkPolicy: unknown, - policyName: string, - bridge: McpBridgeEntry, -): McpBridgeTargetValidation { - const addressKind = bridge.trustedPrivateHost ? "trusted-private" : "public"; - if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { - throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); - } - const endpoints = (networkPolicy as Record).endpoints; - if (!Array.isArray(endpoints) || endpoints.length !== 1) { - throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); - } - const endpoint = endpoints[0]; - if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) { - throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); - } - const allowedIps = (endpoint as Record).allowed_ips; - if (!Array.isArray(allowedIps) || allowedIps.length === 0) { - throw new Error(`Managed MCP policy '${policyName}' has no exact ${addressKind} address pins`); - } - if ( - allowedIps.some( - (address) => - typeof address !== "string" || - address !== address.toLowerCase() || - address.includes("%") || - isIP(address) === 0, - ) - ) { - throw new Error(`Managed MCP policy '${policyName}' has invalid ${addressKind} address pins`); - } - const pins = allowedIps as string[]; - if (new Set(pins).size !== pins.length || !isDeepStrictEqual(pins, [...pins].sort())) { - throw new Error( - `Managed MCP policy '${policyName}' has non-canonical ${addressKind} address pins`, - ); - } - if (bridge.trustedPrivateHost) { - let replay; - try { - replay = replayTrustedPrivateEndpoint(bridge.trustedPrivateHost, bridge.allowedIps ?? [], { - requireAllPrivate: true, - }); - } catch { - throw new Error( - `Managed MCP policy '${policyName}' has invalid trusted-private address pins`, - ); - } - if ( - replay.host !== bridge.trustedPrivateHost || - !isDeepStrictEqual(replay.addresses, bridge.allowedIps) || - !isDeepStrictEqual(pins, bridge.allowedIps) - ) { - throw new Error( - `Managed MCP policy '${policyName}' does not match its recorded trusted-private address pins`, - ); - } - return { - addresses: [...pins], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }; - } else if (pins.some((address) => isBlockedMcpUrlTargetHost(address))) { - throw new Error(`Managed MCP policy '${policyName}' has invalid public address pins`); - } - return { addresses: [...pins] }; -} - -function resolveCanonicalManagedMcpAdapter( - sandbox: registry.SandboxEntry, - bridge: McpBridgeEntry, -): AgentMcpAdapter { - if (isAgentMcpAdapter(bridge.adapter)) return bridge.adapter; - switch (sandbox.agent || "openclaw") { - case "openclaw": - return "mcporter"; - case "hermes": - return "hermes-config"; - case "langchain-deepagents-code": - return "deepagents-config"; - default: - throw new Error("Managed MCP bridge has no canonical adapter"); - } -} - -function requireCanonicalManagedPolicy( - sandbox: registry.SandboxEntry, - server: string, - livePolicies?: Record, -): ExactManagedMcpPolicy { - const bridge = sandbox.mcp?.bridges[server]; - if (!bridge || bridge.addState || bridge.server !== server) { - throw new Error( - `Managed MCP bridge ${diagnosticPreview(server)} has an incomplete lifecycle transition`, - ); - } - - const policyName = buildMcpBridgePolicyName(server); - const policyKey = buildMcpBridgePolicyKey(server); - if (bridge.policyName !== policyName) { - throw new Error(`Managed MCP bridge '${server}' has a non-canonical policy name`); - } - - const registrations = (sandbox.customPolicies ?? []).filter( - (policy) => policy.name === policyName, - ); - if (registrations.length !== 1) { - throw new Error( - `Managed MCP bridge '${server}' does not have one exact policy ownership record`, - ); - } - const [registration] = registrations; - if (registration?.sourcePath !== MCP_BRIDGE_POLICY_SOURCE) { - throw new Error(`Managed MCP bridge '${server}' has no NemoClaw-owned policy record`); - } - if (registration.pendingContent !== undefined) { - throw new Error(`Managed MCP bridge '${server}' has an incomplete policy transition`); - } - - const registeredDocument = parseManagedPolicyDocument( - registration.content, - `Managed MCP policy '${policyName}'`, - ); - const preset = registeredDocument.preset; - if ( - !preset || - typeof preset !== "object" || - Array.isArray(preset) || - (preset as Record).name !== policyName - ) { - throw new Error(`Managed MCP policy '${policyName}' has non-canonical preset metadata`); - } - const registeredPolicies = readManagedNetworkPolicies( - registeredDocument, - `Managed MCP policy '${policyName}'`, - ); - const registeredKeys = Object.keys(registeredPolicies); - if (registeredKeys.length !== 1 || registeredKeys[0] !== policyKey) { - throw new Error(`Managed MCP policy '${policyName}' has a non-canonical network policy key`); - } - - const registeredNetworkPolicy = registeredPolicies[policyKey]; - const target = requireCanonicalAllowedIps(registeredNetworkPolicy, policyName, bridge); - let expectedDocument: Record; - try { - expectedDocument = parseManagedPolicyDocument( - buildMcpBridgePolicyYaml( - bridge.server, - bridge.url, - resolveCanonicalManagedMcpAdapter(sandbox, bridge), - target, - bridge.providerName ?? "", - ), - `Canonical managed MCP policy '${policyName}'`, - ); - } catch { - throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); - } - if (!isDeepStrictEqual(registeredDocument, expectedDocument)) { - throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); - } - - if (livePolicies && !Object.hasOwn(livePolicies, policyKey)) { - throw new Error(`Managed MCP policy '${policyName}' is absent from the live gateway policy`); - } - if (livePolicies && !isDeepStrictEqual(livePolicies[policyKey], registeredNetworkPolicy)) { - throw new Error(`Managed MCP policy '${policyName}' has drifted from its ownership record`); - } - - return { - key: policyKey, - networkPolicy: registeredNetworkPolicy, - policyName, - server, - }; -} - -/** - * Resolve the exact generated MCP entries that NemoClaw currently owns. - * - * The registry is an ownership claim, not sufficient authority to overwrite - * the gateway. Every committed bridge must have one canonical, fully - * committed custom-policy record whose sole network entry exactly matches the - * live base policy. - */ -function inspectCanonicalManagedMcpPolicies( - sandboxName: string, - livePolicies: Record | undefined, - deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, -): ExactManagedMcpPolicy[] { - const sandbox = deps.getSandbox(sandboxName); - if (!sandbox) { - const unclassifiedKey = Object.keys(livePolicies ?? {}).find((key) => - key.startsWith("mcp_bridge_"), - ); - if (unclassifiedKey) { - throw new Error( - `Reserved MCP policy key ${diagnosticPreview(unclassifiedKey)} has no committed managed bridge ownership`, - ); - } - return []; - } - const generatedRegistrations = (sandbox.customPolicies ?? []).filter( - (policy) => policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE, - ); - if (!sandbox.mcp) { - const orphaned = generatedRegistrations[0]; - if (orphaned) { - throw new Error( - `Generated MCP policy ${diagnosticPreview(orphaned.name)} has no committed managed bridge ownership`, - ); - } - const unclassifiedKey = Object.keys(livePolicies ?? {}).find((key) => - key.startsWith("mcp_bridge_"), - ); - if (unclassifiedKey) { - throw new Error( - `Reserved MCP policy key ${diagnosticPreview(unclassifiedKey)} has no committed managed bridge ownership`, - ); - } - return []; - } - if (sandbox.mcp.destroyPreparedAt || sandbox.mcp.destroyPendingAt) { - throw new Error("Managed MCP sandbox destruction is incomplete"); - } - - const bridgeEntries = Object.entries(sandbox.mcp.bridges); - if (bridgeEntries.some(([, bridge]) => bridge.addState !== undefined)) { - throw new Error("A managed MCP bridge lifecycle transition is incomplete"); - } - const exact = bridgeEntries.map(([server]) => - requireCanonicalManagedPolicy(sandbox, server, livePolicies), - ); - - const committedPolicyNames = new Set(exact.map((entry) => entry.policyName)); - const orphaned = generatedRegistrations.find( - (registration) => !committedPolicyNames.has(registration.name), - ); - if (orphaned) { - throw new Error( - `Generated MCP policy ${diagnosticPreview(orphaned.name)} has no committed managed bridge ownership`, - ); - } - - const keys = new Set(); - for (const entry of exact) { - if (keys.has(entry.key)) { - throw new Error(`Managed MCP policy key '${entry.key}' has ambiguous bridge ownership`); - } - keys.add(entry.key); - } - const unclassifiedKey = Object.keys(livePolicies ?? {}).find( - (key) => key.startsWith("mcp_bridge_") && !keys.has(key), - ); - if (unclassifiedKey) { - throw new Error( - `Reserved MCP policy key ${diagnosticPreview(unclassifiedKey)} has no committed managed bridge ownership`, - ); - } - return exact.sort((left, right) => left.key.localeCompare(right.key)); -} - -export function inspectExactManagedMcpPolicies( - sandboxName: string, - livePolicyYaml: string, - deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, -): ExactManagedMcpPolicy[] { - const liveDocument = parseManagedPolicyDocument(livePolicyYaml, "Live gateway policy"); - return inspectCanonicalManagedMcpPolicies( - sandboxName, - readManagedNetworkPolicies(liveDocument, "Live gateway policy"), - deps, - ); -} - -export function inspectRecordedManagedMcpPolicies( - sandboxName: string, - deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, -): ExactManagedMcpPolicy[] { - return inspectCanonicalManagedMcpPolicies(sandboxName, undefined, deps); -} - -/** - * Deadline-only inspection for automatic Shields restoration. - * - * Each entry is admitted independently through the same exact committed/live - * proof as the strict path. Incomplete, drifted, orphaned, or ambiguous claims - * are omitted instead of extending the mutable window; registry state is never - * reconciled or rewritten here. - */ -export function inspectProvableManagedMcpPoliciesForDeadline( - sandboxName: string, - livePolicyYaml: string, - deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, -): ProvableManagedMcpPolicies { - const derivedIdentity = (server: string): { key?: string; policyName?: string } => { - try { - return { - key: buildMcpBridgePolicyKey(server), - policyName: buildMcpBridgePolicyName(server), - }; - } catch { - return {}; - } - }; - const omit = (reason: string, server?: string, policyName?: string): ManagedMcpPolicyOmission => { - const identity = server ? derivedIdentity(server) : {}; - return { - ...(server ? { server } : {}), - ...identity, - ...(policyName ? { policyName } : {}), - reason, - }; - }; - const sandbox = deps.getSandbox(sandboxName); - const generatedRegistrations = (sandbox?.customPolicies ?? []).filter( - (policy) => policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE, - ); - const bridgeEntries = Object.entries(sandbox?.mcp?.bridges ?? {}); - - if (sandbox?.mcp?.destroyPreparedAt || sandbox?.mcp?.destroyPendingAt) { - const reason = "Managed MCP sandbox destruction is incomplete"; - const omissions = bridgeEntries.map(([server]) => omit(reason, server)); - for (const registration of generatedRegistrations) { - if (!omissions.some((entry) => entry.policyName === registration.name)) { - omissions.push(omit(reason, undefined, registration.name)); - } - } - if (omissions.length === 0) omissions.push({ reason }); - return { policies: [], omissions }; - } - - let livePolicies: Record; - try { - livePolicies = readManagedNetworkPolicies( - parseManagedPolicyDocument(livePolicyYaml, "Live gateway policy"), - "Live gateway policy", - ); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - const omissions = bridgeEntries.map(([server]) => omit(reason, server)); - for (const registration of generatedRegistrations) { - if (!omissions.some((entry) => entry.policyName === registration.name)) { - omissions.push(omit(reason, undefined, registration.name)); - } - } - return { policies: [], omissions }; - } - - const policies: ExactManagedMcpPolicy[] = []; - const omissions: ManagedMcpPolicyOmission[] = []; - if (!sandbox) { - for (const key of Object.keys(livePolicies).filter((candidate) => - candidate.startsWith("mcp_bridge_"), - )) { - omissions.push({ - key, - reason: `Reserved MCP policy key '${key}' has no committed managed bridge ownership`, - }); - } - return { policies, omissions }; - } - const claimedServersByKey = new Map(); - const claimedServersByPolicyName = new Map(); - for (const [server] of bridgeEntries) { - const identity = derivedIdentity(server); - if (identity.key) { - const servers = claimedServersByKey.get(identity.key) ?? []; - servers.push(server); - claimedServersByKey.set(identity.key, servers); - } - if (identity.policyName) { - const servers = claimedServersByPolicyName.get(identity.policyName) ?? []; - servers.push(server); - claimedServersByPolicyName.set(identity.policyName, servers); - } - } - const ambiguousServers = new Set(); - for (const servers of [...claimedServersByKey.values(), ...claimedServersByPolicyName.values()]) { - if (servers.length <= 1) continue; - for (const server of servers) ambiguousServers.add(server); - } - for (const [server] of bridgeEntries) { - if (ambiguousServers.has(server)) { - omissions.push(omit("Managed MCP policy identity has ambiguous bridge ownership", server)); - continue; - } - try { - policies.push(requireCanonicalManagedPolicy(sandbox, server, livePolicies)); - } catch (error) { - omissions.push(omit(error instanceof Error ? error.message : String(error), server)); - } - } - - const bridgePolicyNames = new Set( - bridgeEntries - .map(([server]) => derivedIdentity(server).policyName) - .filter((name): name is string => name !== undefined), - ); - for (const registration of generatedRegistrations) { - if (!bridgePolicyNames.has(registration.name)) { - omissions.push( - omit( - `Generated MCP policy '${registration.name}' has no committed managed bridge ownership`, - undefined, - registration.name, - ), - ); - } - } - - const policiesByKey = new Map(); - for (const policy of policies) { - const entries = policiesByKey.get(policy.key) ?? []; - entries.push(policy); - policiesByKey.set(policy.key, entries); - } - const exact: ExactManagedMcpPolicy[] = []; - for (const entries of policiesByKey.values()) { - if (entries.length === 1) { - exact.push(entries[0]!); - continue; - } - for (const entry of entries) { - omissions.push( - omit(`Managed MCP policy key '${entry.key}' has ambiguous ownership`, entry.server), - ); - } - } - const exactKeys = new Set(exact.map((entry) => entry.key)); - for (const key of Object.keys(livePolicies).filter( - (candidate) => candidate.startsWith("mcp_bridge_") && !exactKeys.has(candidate), - )) { - if (omissions.some((entry) => entry.key === key)) continue; - omissions.push({ - key, - reason: `Reserved MCP policy key '${key}' has no exact committed managed bridge ownership`, - }); - } - return { - policies: exact.sort((left, right) => left.key.localeCompare(right.key)), - omissions, - }; -} - -export function hasManagedMcpPolicyClaims( - sandboxName: string, - deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, -): boolean { - const sandbox = deps.getSandbox(sandboxName); - if (!sandbox) return false; - return ( - Boolean( - sandbox.mcp && - (Object.keys(sandbox.mcp.bridges).length > 0 || - (sandbox.mcp.managedServerNames?.length ?? 0) > 0 || - sandbox.mcp.destroyPreparedAt || - sandbox.mcp.destroyPendingAt), - ) || - (sandbox.customPolicies ?? []).some((policy) => policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE) - ); -} - -type GeneratedPolicyRegistrationState = { - policy: registry.CustomPolicyEntry; - state: "match" | "absent" | "drift" | null; - confirmed: boolean; -}; - -function withoutPendingContent( - policy: registry.CustomPolicyEntry, - content = policy.content, -): registry.CustomPolicyEntry { - const { pendingContent: _pendingContent, ...confirmed } = policy; - return { ...confirmed, content }; -} - -function persistGeneratedPolicyRegistration( - sandboxName: string, - policy: registry.CustomPolicyEntry, -): void { - if (!registry.addCustomPolicy(sandboxName, policy)) { - throw new McpBridgeError( - `Could not persist ownership for generated MCP policy '${policy.name}'.`, - ); - } -} - -/** - * Resolve a crash-interrupted generated-policy transition against the effective - * gateway policy. `content` remains the last confirmed value while - * `pendingContent` reserves the desired value, so either side of the mutation - * can be recognized safely after process death. - */ -function reconcileGeneratedPolicyRegistration( - sandboxName: string, - policy: registry.CustomPolicyEntry, -): GeneratedPolicyRegistrationState { - const pendingContent = policy.pendingContent; - if (pendingContent === undefined) { - return { - policy, - state: policies.getPresetContentGatewayState(sandboxName, policy.content), - confirmed: true, - }; - } - if (!pendingContent) { - return { policy, state: "drift", confirmed: false }; - } - - const pendingState = policies.getPresetContentGatewayState(sandboxName, pendingContent); - if (pendingState === "match") { - const confirmedPolicy = withoutPendingContent(policy, pendingContent); - persistGeneratedPolicyRegistration(sandboxName, confirmedPolicy); - return { policy: confirmedPolicy, state: "match", confirmed: true }; - } - - // A new add has no older confirmed value; content equals the reservation. - // Only an absent key is safe to retry. - if (pendingContent === policy.content) { - return { policy, state: pendingState, confirmed: false }; - } - - const confirmedState = policies.getPresetContentGatewayState(sandboxName, policy.content); - if (confirmedState === "match" || (confirmedState === "absent" && pendingState === "absent")) { - const confirmedPolicy = withoutPendingContent(policy); - persistGeneratedPolicyRegistration(sandboxName, confirmedPolicy); - return { policy: confirmedPolicy, state: confirmedState, confirmed: true }; - } - return { policy, state: confirmedState === null ? null : "drift", confirmed: false }; -} - export function applyGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, target: McpBridgeTargetValidation, options: { bindCredential?: boolean } = {}, ): void { - const resolvedAddresses = assertMcpBridgePolicyTarget(entry, target); - if (resolvedAddresses.length === 0) { + const addresses = assertMcpBridgePolicyTarget(entry, target); + if (addresses.length === 0) { throw new McpBridgeError( - `Refusing to apply generated MCP policy '${entry.policyName}' without exact public address pins.`, + `Refusing to apply generated MCP policy '${entry.policyName}' without address pins.`, ); } const adapter = isAgentMcpAdapter(entry.adapter) ? entry.adapter : "mcporter"; @@ -645,98 +55,14 @@ export function applyGeneratedPolicy( target, entry.providerName ?? "", ); - const policyKey = buildMcpBridgePolicyKey(entry.server); - const sameNamePolicy = registry - .getCustomPolicies(sandboxName) - .find((policy) => policy.name === entry.policyName); - if (sameNamePolicy && sameNamePolicy.sourcePath !== MCP_BRIDGE_POLICY_SOURCE) { - throw new McpBridgeError( - `Generated MCP policy '${entry.policyName}' conflicts with an unowned same-name registry record. Refusing to replace operator-owned policy state.`, - ); - } - const registeredPolicy = sameNamePolicy; - let previousPolicy: registry.CustomPolicyEntry | undefined; - let previousPolicyConfirmed = false; - let ownsExistingPolicyKey = false; - if (registeredPolicy) { - const reconciled = reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy); - previousPolicy = reconciled.policy; - previousPolicyConfirmed = reconciled.confirmed; - const previousState = reconciled.state; - if (previousState !== "absent" && previousState !== "match") { - throw new McpBridgeError( - `Generated MCP policy '${entry.policyName}' has drifted or could not be inspected against its recorded content. Refusing to replace the live key.`, - ); - } - // A prior ownership record may have been reserved immediately before a - // process died, so an absent key is safe to create. A present key is safe - // to replace only after its full content matches that ownership record. - ownsExistingPolicyKey = previousState === "match"; - } else { - const unownedState = policies.getPresetContentGatewayState(sandboxName, content); - if (unownedState !== "absent") { - throw new McpBridgeError( - `Generated MCP policy key '${policyKey}' is already present or could not be inspected without a NemoClaw ownership record.`, - ); - } - } - - // Preserve the last confirmed content while reserving a changed desired - // value. For a brand-new key, content and pendingContent are intentionally - // equal so an absent live key remains recognizable as an uncommitted add. - let reservation: registry.CustomPolicyEntry; if ( - previousPolicy && - previousPolicy.content === content && - (previousPolicy.pendingContent === undefined || previousPolicy.pendingContent === content) + !policies.applyPresetContent(sandboxName, entry.policyName, content, { + nonFatal: true, + }) || + policies.getPresetContentGatewayState(sandboxName, content) !== "match" ) { - reservation = previousPolicy; - } else if (previousPolicy) { - reservation = { ...withoutPendingContent(previousPolicy), pendingContent: content }; - persistGeneratedPolicyRegistration(sandboxName, reservation); - } else { - reservation = { - name: entry.policyName, - content, - pendingContent: content, - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }; - persistGeneratedPolicyRegistration(sandboxName, reservation); + throw new McpBridgeError(`Failed to activate generated MCP policy '${entry.policyName}'.`); } - // `custom` denotes user-supplied preset content and intentionally rejects - // `allowed_ips`. This content is generated from validated MCP inputs and the - // ownership reservation above; `skipRegistryUpdate` avoids a second write. - const ok = policies.applyPresetContent(sandboxName, entry.policyName, content, { - expectedExistingNetworkPolicyContent: - ownsExistingPolicyKey && previousPolicy ? previousPolicy.content : null, - nonFatal: true, - skipRegistryUpdate: true, - }); - // `policy set --wait` proves that a submitted revision loaded, but OpenShell - // also returns success for unchanged and concurrently superseded revisions. - // Confirm that the effective policy still contains our exact generated entry. - const activeState = policies.getPresetContentGatewayState(sandboxName, content); - if (ok !== false && activeState === "match") { - persistGeneratedPolicyRegistration(sandboxName, withoutPendingContent(reservation, content)); - return; - } - - if (previousPolicyConfirmed && previousPolicy) { - const previousState = policies.getPresetContentGatewayState( - sandboxName, - previousPolicy.content, - ); - if (previousState === "match" || (previousState === "absent" && activeState === "absent")) { - persistGeneratedPolicyRegistration(sandboxName, withoutPendingContent(previousPolicy)); - } - } else if (activeState === "absent") { - registry.removeCustomPolicyByName(sandboxName, entry.policyName); - } - const detail = - activeState === "match" ? "the update command failed" : `effective state: ${activeState}`; - throw new McpBridgeError( - `Failed to activate generated MCP policy '${entry.policyName}' (${detail}).`, - ); } export function assertMcpBridgePolicyTarget( @@ -782,228 +108,94 @@ export function assertMcpBridgePolicyTarget( return recordedPins; } -function getUnownedGeneratedPolicyState( - sandboxName: string, - entry: McpBridgeEntry, -): "absent" | "present" | null { - try { - return policies.getLiveSandboxPolicyEntryDigest( - sandboxName, - buildMcpBridgePolicyKey(entry.server), - ) === null - ? "absent" - : "present"; - } catch { - return null; +function recordedMcpTarget(entry: McpBridgeEntry): McpBridgeTargetValidation { + if (entry.trustedPrivateHost) { + const replay = replayTrustedPrivateEndpoint(entry.trustedPrivateHost, entry.allowedIps ?? [], { + requireAllPrivate: true, + }); + return { + addresses: [...replay.addresses], + trustedPrivateCapability: replay.trustedPrivateCapability, + trustedPrivateHost: replay.host, + }; } + return { addresses: [...(entry.allowedIps ?? [])] }; } -export function assertGeneratedPolicyMutationSafe( - sandboxName: string, +function generatedPolicyContent( entry: McpBridgeEntry, -): void { - const registeredPolicy = assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); - const owned = registeredPolicy !== undefined; - const reconciled = registeredPolicy - ? reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy) - : undefined; - const state = reconciled?.state ?? getUnownedGeneratedPolicyState(sandboxName, entry); - if (state === "absent") return; - if (!owned || state !== "match") { - throw new McpBridgeError( - `Generated MCP policy '${entry.policyName}' is unowned, unreachable, or drifted. Refusing to mutate the adapter, provider, or same-key live policy until ownership is resolved. The registry entry was preserved so cleanup can be retried.`, - ); - } + adapter: AgentMcpAdapter = isAgentMcpAdapter(entry.adapter) ? entry.adapter : "mcporter", + target: McpBridgeTargetValidation = recordedMcpTarget(entry), +): string { + assertMcpBridgePolicyTarget(entry, target); + return buildMcpBridgePolicyYaml( + entry.server, + entry.url, + adapter, + target, + entry.providerName ?? "", + ); } -/** Check registry ownership without consulting a sandbox already proven absent. */ -export function assertGeneratedPolicyRegistrationMutationSafe( - sandboxName: string, +export function assertGeneratedPolicyMutationSafe( + _sandboxName: string, entry: McpBridgeEntry, -): registry.CustomPolicyEntry | undefined { - const registeredPolicy = registry - .getCustomPolicies(sandboxName) - .find((policy) => policy.name === entry.policyName); - const owned = registeredPolicy?.sourcePath === MCP_BRIDGE_POLICY_SOURCE; - if (registeredPolicy && !owned) { - throw new McpBridgeError( - `Generated MCP policy '${entry.policyName}' conflicts with an unowned same-name registry record. Refusing to mutate the adapter, provider, or live policy.`, - ); +): void { + if (entry.policyName !== buildMcpBridgePolicyName(entry.server)) { + throw new McpBridgeError("Generated MCP policy name does not match its bridge definition."); } - return owned ? registeredPolicy : undefined; } -/** - * Prove that a complete bridge still owns its exact live policy without - * reconciling crash markers or writing registry state. This is intentionally - * stricter than the mutation preflight: a still-live sandbox whose adapter - * cannot be inspected may cross the rebuild delete boundary only from a fully - * committed policy registration generated for the authoritative recorded-agent - * adapter and exactly matching the gateway. - */ -export function assertGeneratedPolicyExactReadOnly( - sandboxName: string, +export function assertGeneratedPolicyRegistrationMutationSafe( + _sandboxName: string, entry: McpBridgeEntry, - adapter: AgentMcpAdapter, - target: McpBridgeTargetValidation, -): registry.CustomPolicyEntry { - const resolvedAddresses = assertMcpBridgePolicyTarget(entry, target); - const canonicalOwnershipError = (): McpBridgeError => - new McpBridgeError( - "Generated MCP policy ownership is not canonical for its recorded bridge definition. Refusing host-side rebuild recovery.", - ); - let expectedPolicyName: string; - try { - expectedPolicyName = buildMcpBridgePolicyName(entry.server); - } catch { - throw canonicalOwnershipError(); - } - if ( - entry.policyName !== expectedPolicyName || - entry.adapter !== adapter || - resolvedAddresses.length === 0 - ) { - throw canonicalOwnershipError(); - } - let expectedContent: string; - try { - expectedContent = buildMcpBridgePolicyYaml( - entry.server, - entry.url, - adapter, - target, - entry.providerName ?? "", - ); - } catch { - // Registry entries are untrusted local state. Keep malformed URLs and any - // credential-shaped material out of the recovery diagnostic. - throw canonicalOwnershipError(); - } - const sameNamePolicies = registry - .getCustomPolicies(sandboxName) - .filter((policy) => policy.name === expectedPolicyName); - if (sameNamePolicies.length !== 1) { - throw new McpBridgeError( - "Generated MCP policy ownership is missing or ambiguous. Refusing host-side rebuild recovery for a still-live sandbox.", - ); - } - const [registeredPolicy] = sameNamePolicies; - if (registeredPolicy?.sourcePath !== MCP_BRIDGE_POLICY_SOURCE) { - throw new McpBridgeError( - "Generated MCP policy has no exact NemoClaw ownership record. Refusing host-side rebuild recovery for a still-live sandbox.", - ); - } - if (registeredPolicy.pendingContent !== undefined) { - throw new McpBridgeError( - "Generated MCP policy has an incomplete registry transition. Refusing read-only host-side rebuild recovery.", - ); - } - if (registeredPolicy.content !== expectedContent) { - throw canonicalOwnershipError(); - } - const state = policies.getPresetContentGatewayState(sandboxName, registeredPolicy.content); - if (state !== "match") { - throw new McpBridgeError( - "Generated MCP policy is absent, unreachable, or drifted from its exact ownership record. Refusing host-side rebuild recovery.", - ); - } - return { ...registeredPolicy }; +) { + assertGeneratedPolicyMutationSafe(_sandboxName, entry); + return { + name: entry.policyName, + content: generatedPolicyContent(entry), + sourcePath: MCP_BRIDGE_POLICY_SOURCE, + }; } export function removeGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, - options: { bestEffort?: boolean; preserveRegistryOwnership?: boolean } = {}, + options: { bestEffort?: boolean } = {}, ): void { - const policyName = entry.policyName; - const registeredPolicy = registry - .getCustomPolicies(sandboxName) - .find((policy) => policy.name === policyName); - const ownsRegistration = registeredPolicy?.sourcePath === MCP_BRIDGE_POLICY_SOURCE; - const reconciled = - registeredPolicy && ownsRegistration - ? reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy) - : undefined; - const effectiveRegistration = reconciled?.policy ?? registeredPolicy; - const content = effectiveRegistration?.content; - const gatewayState = - reconciled?.state ?? - (content - ? policies.getPresetContentGatewayState(sandboxName, content) - : getUnownedGeneratedPolicyState(sandboxName, entry)); - if (gatewayState === "absent") { - if (ownsRegistration && !options.preserveRegistryOwnership) { - registry.removeCustomPolicyByName(sandboxName, policyName); - } - return; - } - if (!ownsRegistration || gatewayState !== "match") { - if (options.bestEffort) return; - throw new McpBridgeError( - `Generated MCP policy '${policyName}' is unowned, unreachable, or no longer matches its registered content. Refusing to delete same-key policy state.`, - ); - } - const ok = policies.removePreset(sandboxName, policyName, { + const policyKey = buildMcpBridgePolicyKey(entry.server); + const content = `network_policies:\n ${policyKey}: {}\n`; + const removed = policies.removePreset(sandboxName, entry.policyName, { nonFatal: true, - // Keep ownership durable across a crash or superseded OpenShell revision. - // It is cleared only after the exact live key is proven absent below. - skipRegistryUpdate: true, + presetContent: content, }); - // OpenShell can acknowledge a superseded policy revision as success. Confirm - // the exact generated key is absent before discarding its ownership record. - if (!content) { - if (options.bestEffort) return; - throw new McpBridgeError( - `Generated MCP policy '${policyName}' has no exact ownership content. Refusing to delete same-key policy state.`, - ); - } - const activeState = policies.getPresetContentGatewayState(sandboxName, content); - if (activeState === "absent") { - if (!options.preserveRegistryOwnership) { - registry.removeCustomPolicyByName(sandboxName, policyName); - } - return; - } - // Keep (or defensively restore) the last reconciled ownership record when - // exact post-state is not proven. - if (ownsRegistration && effectiveRegistration) { - persistGeneratedPolicyRegistration(sandboxName, effectiveRegistration); - } + if (removed) return; if (options.bestEffort) return; - const detail = ok ? `effective state: ${activeState}` : "the removal command failed"; - throw new McpBridgeError(`Failed to remove generated MCP policy '${policyName}' (${detail}).`); + throw new McpBridgeError(`Failed to remove generated MCP policy '${entry.policyName}'.`); } export function getRegisteredGeneratedPolicy( - sandboxName: string, + _sandboxName: string, entry: McpBridgeEntry | undefined, -): ReturnType[number] | undefined { +) { if (!entry?.policyName) return undefined; - return registry - .getCustomPolicies(sandboxName) - .find( - (policy) => - policy.name === entry.policyName && policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE, - ); + try { + return { + name: entry.policyName, + content: generatedPolicyContent(entry), + sourcePath: MCP_BRIDGE_POLICY_SOURCE, + }; + } catch { + return undefined; + } } export function getPolicyPresence( sandboxName: string, entry: McpBridgeEntry | undefined, ): boolean | null { - if (!entry?.policyName) return false; - const registeredPolicy = getRegisteredGeneratedPolicy(sandboxName, entry); - if (!registeredPolicy) return null; - const confirmedState = policies.getPresetContentGatewayState( - sandboxName, - registeredPolicy.content, - ); - if (confirmedState === "match") return true; - const pendingContent = registeredPolicy.pendingContent; - if (typeof pendingContent !== "string" || pendingContent.length === 0) { - return confirmedState === null ? null : false; - } - const pendingState = policies.getPresetContentGatewayState(sandboxName, pendingContent); - if (pendingState === "match") return true; - return confirmedState === null || pendingState === null ? null : false; + const registered = getRegisteredGeneratedPolicy(sandboxName, entry); + if (!registered) return entry ? null : false; + const state = policies.getPresetContentGatewayState(sandboxName, registered.content); + return state === "match" ? true : state === "absent" ? false : null; } diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts index 8bbdd00900b..fc357f47f98 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts @@ -123,9 +123,7 @@ function tryObserveMcpCredentialRevision( if (!result) return { kind: "transport-unavailable" }; if (result.status !== 0) return { kind: "command-failed", status: result.status }; const observation = parseMcpCredentialRevisionObservation(result.stdout); - return observation === null - ? { kind: "invalid-output" } - : { kind: "observation", observation }; + return observation === null ? { kind: "invalid-output" } : { kind: "observation", observation }; } function describeMcpCredentialRevisionAttempt(attempt: McpCredentialRevisionAttempt): string { @@ -227,7 +225,7 @@ export function waitForAttachedMcpCredential( ); if (!ready) { throw new McpBridgeError( - `OpenShell did not synchronize the expected credential revision for placeholder '${envName}' into sandbox '${sandboxName}' after provider attachment or update (last bounded observation: ${describeMcpCredentialRevisionAttempt(lastAttempt)}; post-policy refresh attempted: ${refreshedAfterObservedAbsence ? "yes" : "no"}).`, + `OpenShell did not synchronize the expected credential revision for placeholder '${envName}' into sandbox '${sandboxName}' after provider attachment or update (last bounded observation: ${describeMcpCredentialRevisionAttempt(lastAttempt)}; post-absence provider refresh attempted: ${refreshedAfterObservedAbsence ? "yes" : "no"}).`, ); } if (attachedRevision === undefined) { @@ -267,7 +265,7 @@ export function waitForDetachedMcpCredential(sandboxName: string, entry: McpBrid ); if (!revoked) { throw new McpBridgeError( - `OpenShell did not confirm credential '${envName}' was revoked from fresh execs in sandbox '${sandboxName}' after detach. Preserving MCP policy and ownership state.`, + `OpenShell did not confirm credential '${envName}' was revoked from fresh execs in sandbox '${sandboxName}' after detach. Preserving the MCP bridge lifecycle record.`, ); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts index d8d05bfde61..0eaaff30d83 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider.test.ts @@ -450,7 +450,32 @@ alpha-mcp-slack generic 1 0 ).toThrow(/last bounded observation: canonical/); }); - it("refreshes once after a fresh exec reports the credential absent (#9764)", () => { + it("reports an absent attached credential without attempting policy recovery", () => { + vi.stubEnv("NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS", "1"); + const exec = vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue({ + status: 0, + stdout: "absent", + stderr: "", + }); + vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(1_000); + + expect(() => + waitForAttachedMcpCredential("alpha", { + server: "github", + agent: "openclaw", + adapter: "mcporter", + url: "https://mcp.example.test/mcp", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github-0123456789abcdef", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-github", + addedAt: "2026-06-01T00:00:00.000Z", + }), + ).toThrow(/last bounded observation: absent/); + expect(exec).toHaveBeenCalledOnce(); + }); + + it("runs one provider-owned refresh after a fresh exec reports the credential absent", () => { const entry: McpBridgeEntry = { server: "github", agent: "openclaw", @@ -468,16 +493,14 @@ alpha-mcp-slack generic 1 0 .mockReturnValue({ status: 0, stdout: "v12", stderr: "" }); const refreshAfterObservedAbsence = vi.fn(); - const revision = waitForAttachedMcpCredential("alpha", entry, { - refreshAfterObservedAbsence, - }); - - expect(refreshAfterObservedAbsence).toHaveBeenCalledTimes(1); + expect(waitForAttachedMcpCredential("alpha", entry, { refreshAfterObservedAbsence })).toBe( + "v12", + ); + expect(refreshAfterObservedAbsence).toHaveBeenCalledOnce(); expect(exec).toHaveBeenCalledTimes(3); - expect(revision).toBe("v12"); }); - it("does not repeat the refresh when the credential remains absent (#9764)", () => { + it("does not repeat the provider refresh when the credential remains absent", () => { vi.stubEnv("NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS", "1"); const exec = vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue({ status: 0, @@ -503,8 +526,8 @@ alpha-mcp-slack generic 1 0 }, { refreshAfterObservedAbsence }, ), - ).toThrow(/last bounded observation: absent; post-policy refresh attempted: yes/); - expect(refreshAfterObservedAbsence).toHaveBeenCalledTimes(1); + ).toThrow(/post-absence provider refresh attempted: yes/u); + expect(refreshAfterObservedAbsence).toHaveBeenCalledOnce(); expect(exec).toHaveBeenCalledTimes(2); }); @@ -512,7 +535,7 @@ alpha-mcp-slack generic 1 0 ["unavailable", null, "transport-unavailable"], ["malformed", { status: 0, stdout: "raw-secret", stderr: "" }, "invalid-bounded-output"], ["rejected", { status: 1, stdout: "", stderr: "" }, "proof-command-exit-1"], - ])("does not refresh when a credential observation is %s (#9764)", (_case, result, diagnostic) => { + ])("does not refresh when a credential observation is %s", (_case, result, diagnostic) => { vi.stubEnv("NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS", "1"); vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue(result); vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(1_000); @@ -539,21 +562,19 @@ alpha-mcp-slack generic 1 0 failure = error; } expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain( - `last bounded observation: ${diagnostic}; post-policy refresh attempted: no`, - ); + expect((failure as Error).message).toContain(`last bounded observation: ${diagnostic}`); expect((failure as Error).message).not.toContain("raw-secret"); expect(refreshAfterObservedAbsence).not.toHaveBeenCalled(); }); - it("propagates a credential refresh failure after observed absence (#9764)", () => { + it("propagates a provider refresh failure after observed absence", () => { vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue({ status: 0, stdout: "absent", stderr: "", }); const refreshAfterObservedAbsence = vi.fn(() => { - throw new Error("credential-free refresh failed"); + throw new Error("provider refresh failed"); }); expect(() => @@ -572,11 +593,11 @@ alpha-mcp-slack generic 1 0 }, { refreshAfterObservedAbsence }, ), - ).toThrow("credential-free refresh failed"); - expect(refreshAfterObservedAbsence).toHaveBeenCalledTimes(1); + ).toThrow("provider refresh failed"); + expect(refreshAfterObservedAbsence).toHaveBeenCalledOnce(); }); - it("does not accept a stale revision after the absence refresh (#9764)", () => { + it("does not accept a stale revision after the provider refresh", () => { vi.stubEnv("NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS", "1"); const exec = vi .spyOn(processRecovery, "executeSandboxExecCommand") @@ -601,8 +622,8 @@ alpha-mcp-slack generic 1 0 }, { previousRevision: "v11", refreshAfterObservedAbsence }, ), - ).toThrow(/last bounded observation: v11; post-policy refresh attempted: yes/); - expect(refreshAfterObservedAbsence).toHaveBeenCalledTimes(1); + ).toThrow(/last bounded observation: v11; post-absence provider refresh attempted: yes/u); + expect(refreshAfterObservedAbsence).toHaveBeenCalledOnce(); expect(exec).toHaveBeenCalledTimes(2); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts index 2899a6dc2c7..a9e15211cb8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild-exec-unavailable.ts @@ -10,7 +10,6 @@ import { cloneMcpBridgeEntry, inspectExactMcpDestroyProvider, } from "./mcp-bridge-destroy-preflight"; -import { assertGeneratedPolicyExactReadOnly } from "./mcp-bridge-policy"; import { assertNoProviderCredentialCollisions, preflightMcpEntryTargets, @@ -27,7 +26,6 @@ import type { McpBridgeTargetValidation } from "./mcp-bridge-url-validation"; import { assertAuthenticatedBridgeEntry, validateSandboxName } from "./mcp-bridge-validation"; type ReadOnlyValidationSnapshot = { - policyByServer: Map; providerByServer: Map; targetsByServer: Map; }; @@ -107,16 +105,6 @@ function snapshotCompleteEntries(sandboxName: string): { }; } -function policyFingerprint(policy: ReturnType): string { - return JSON.stringify({ - name: policy.name, - content: policy.content, - pendingContent: policy.pendingContent, - sourcePath: policy.sourcePath, - appliedAt: policy.appliedAt, - }); -} - function providerFingerprint(provider: ReturnType): string { return JSON.stringify({ exists: provider.exists, @@ -139,34 +127,23 @@ function targetFingerprint(target: McpBridgeTargetValidation | undefined): strin async function inspectReadOnlyRecoveryState( sandboxName: string, entries: readonly McpBridgeEntry[], - adapter: AgentMcpAdapter, ): Promise { const resolvedTargets = await preflightMcpEntryTargets(entries); // This may start or recover the sandbox's recorded host gateway and select - // it in CLI context. It does not mutate MCP ownership or sandbox contents; - // the provider, policy, and target checks below remain inspection-only. + // it in CLI context. It does not mutate MCP lifecycle state or sandbox + // contents; the provider and target checks below remain inspection-only. if (entries.length > 0) await ensureSandboxGatewaySelected(sandboxName); - const policyByServer = new Map(); const providerByServer = new Map(); const targetsByServer = new Map(); for (const entry of entries) { const target = resolvedTargets.get(entry.server); - const policy = assertGeneratedPolicyExactReadOnly( - sandboxName, - entry, - adapter, - target ?? { - addresses: [], - }, - ); - policyByServer.set(entry.server, policyFingerprint(policy)); const provider = inspectExactMcpDestroyProvider(entry, { allowMissing: false }); providerByServer.set(entry.server, providerFingerprint(provider)); targetsByServer.set(entry.server, targetFingerprint(target)); } assertNoProviderCredentialCollisions(sandboxName, entries); - return { policyByServer, providerByServer, targetsByServer }; + return { providerByServer, targetsByServer }; } function assertValidationSnapshotCurrent( @@ -176,13 +153,12 @@ function assertValidationSnapshotCurrent( ): void { const drifted = entries.find( (entry) => - current.policyByServer.get(entry.server) !== expected.policyByServer.get(entry.server) || current.providerByServer.get(entry.server) !== expected.providerByServer.get(entry.server) || current.targetsByServer.get(entry.server) !== expected.targetsByServer.get(entry.server), ); if (drifted) { throw new McpBridgeError( - `MCP server '${drifted.server}' changed after host-side rebuild preflight. Refusing to delete the still-live sandbox; retry after its target, policy, and provider state is stable.`, + `MCP server '${drifted.server}' changed after host-side rebuild preflight. Refusing to delete the still-live sandbox; retry after its target and provider state are stable.`, ); } } @@ -228,32 +204,24 @@ async function revalidateBeforeDelete( expectedAgentName, expectedAdapter, ); - const currentValidation = await inspectReadOnlyRecoveryState( - sandboxName, - expectedEntries, - expectedAdapter, - ); + const currentValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); assertValidationSnapshotCurrent(expectedEntries, expectedValidation, currentValidation); } /** * Preserve complete MCP intent when sandbox exec is unavailable but OpenShell * still reports the sandbox live. Unlike absent-sandbox recovery, this path is - * read-only with respect to MCP ownership and sandbox contents: it may recover + * read-only with respect to MCP lifecycle state and sandbox contents: it may recover * and select the recorded host gateway for inspection, but it never discards - * add markers, scrubs adapters, detaches providers, reconciles policy records, - * or otherwise mutates MCP ownership before delete. + * add markers, scrubs adapters, detaches providers, or changes policy before + * delete. */ export async function prepareMcpBridgesForExecUnavailableRebuild( sandboxName: string, ): Promise { const { entries, gatewayName, agentName, adapter } = snapshotCompleteEntries(sandboxName); const expectedEntries = entries.map(cloneMcpBridgeEntry); - const expectedValidation = await inspectReadOnlyRecoveryState( - sandboxName, - expectedEntries, - adapter, - ); + const expectedValidation = await inspectReadOnlyRecoveryState(sandboxName, expectedEntries); return { entries: entries.map(cloneMcpBridgeEntry), detachedProviderEntries: [], diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index 027e4f6da86..02918f9e7ec 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -1,7 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isDeepStrictEqual } from "node:util"; + +import YAML from "yaml"; + import type { McpBridgeEntry } from "../../state/registry"; +import * as policies from "../../policy"; import { rollbackScrubbedMcpAdapters, scrubManagedMcpAdapterOrThrow, @@ -16,6 +21,7 @@ import { import { assertGeneratedPolicyMutationSafe, assertGeneratedPolicyRegistrationMutationSafe, + buildMcpBridgePolicyKey, removeGeneratedPolicy, } from "./mcp-bridge-policy"; import { @@ -39,17 +45,51 @@ import { setBridgeState, } from "./mcp-bridge-state"; import { assertAuthenticatedBridgeEntry, validateSandboxName } from "./mcp-bridge-validation"; +import { getSandboxPolicy } from "./policy-get"; export interface McpRebuildPreparation { entries: McpBridgeEntry[]; detachedProviderEntries: McpBridgeEntry[]; scrubbedAdapterEntries: McpScrubbedAdapterEntry[]; - /** Full read-only target, policy, provider, and registry proof before delete. */ + /** Complete live OpenShell policy captured immediately before MCP teardown. */ + policyHandoff?: string; + /** Full target, policy, provider, and registry proof before delete. */ revalidateBeforeDelete?: () => Promise; /** Final synchronous registry-only proof immediately before delete. */ assertDeleteEdgeUnchanged?: () => void; } +function policyDocumentsMatch(left: string, right: string): boolean { + try { + return isDeepStrictEqual(YAML.parse(left), YAML.parse(right)); + } catch { + return false; + } +} + +function policyWithoutManagedMcpEntries( + policyHandoff: string, + entries: readonly McpBridgeEntry[], +): string { + return entries.reduce( + (policy, entry) => + policies.removePresetFromPolicy(policy, ` ${buildMcpBridgePolicyKey(entry.server)}: {}\n`), + policyHandoff, + ); +} + +function assertMcpTeardownPolicyUnchanged( + sandboxName: string, + expectedTeardownPolicy: string, +): void { + const currentPolicy = getSandboxPolicy(sandboxName).yaml; + if (!currentPolicy || !policyDocumentsMatch(currentPolicy, expectedTeardownPolicy)) { + throw new McpBridgeError( + `OpenShell policy changed while preparing MCP teardown for sandbox '${sandboxName}'. Refusing sandbox deletion.`, + ); + } +} + export { prepareMcpBridgesForExecUnavailableRebuild } from "./mcp-bridge-rebuild-exec-unavailable"; async function getCompleteMcpRebuildEntries( @@ -64,7 +104,7 @@ async function getCompleteMcpRebuildEntries( (entry) => entry.addState !== "prepared", ); // This host-visible config preflight must precede - // discardSafeIncompleteMcpAdds, which can remove an owned policy for a + // discardSafeIncompleteMcpAdds, which can remove the generated live policy key for a // providerless preflighted add. That cleanup has no adapter/provider to // probe; complete entries get the teardown runtime probe below. assertMcpAdapterConfigMutationsAllowed( @@ -133,6 +173,18 @@ export async function prepareMcpBridgesForRebuild( assertMcpAdapterTeardownRuntimeCapabilities(sandboxName, sandbox, entries); for (const entry of entries) assertMcpProviderRecoverable(entry); assertNoProviderCredentialCollisions(sandboxName, entries); + // This is the bounded replacement handoff, not a durable NemoClaw policy + // record. Capture OpenShell immediately before the internal teardown + // mutations so the replacement receives the complete operator-owned + // document, including the MCP rules that must be removed temporarily from + // the still-running source sandbox before provider detach. + const policyHandoff = getSandboxPolicy(sandboxName).yaml; + if (!policyHandoff) { + throw new McpBridgeError( + `Could not capture the live OpenShell policy before MCP teardown for sandbox '${sandboxName}'.`, + ); + } + const expectedTeardownPolicy = policyWithoutManagedMcpEntries(policyHandoff, entries); const detached: McpBridgeEntry[] = []; const scrubbedAdapters: McpScrubbedAdapterEntry[] = []; const removedPolicies: McpBridgeEntry[] = []; @@ -145,10 +197,9 @@ export async function prepareMcpBridgesForRebuild( } for (const entry of entries) { // The same-name replacement journal fingerprints this source row before - // MCP teardown. Keep exact generated-policy ownership in that preserved - // row while removing only the live policy; inner onboarding excludes the - // generated name and post-rebuild restoration reuses this ownership. - removeGeneratedPolicy(sandboxName, entry, { preserveRegistryOwnership: true }); + // MCP teardown removes the live entry from the source sandbox. Rebuild's + // OpenShell policy handoff already captured the complete live document. + removeGeneratedPolicy(sandboxName, entry); removedPolicies.push(entry); } for (const entry of entries) { @@ -167,6 +218,7 @@ export async function prepareMcpBridgesForRebuild( // reattached if sandbox deletion later aborts. detached.push(entry); } + assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); } catch (error) { const rollbackFailures: string[] = []; let runtimeRestored = false; @@ -183,9 +235,7 @@ export async function prepareMcpBridgesForRebuild( } } if (!runtimeRestored) { - rollbackFailures.push( - ...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters), - ); + rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); } const detail = error instanceof Error ? error.message : String(error); throw new McpBridgeError( @@ -198,6 +248,10 @@ export async function prepareMcpBridgesForRebuild( entries, detachedProviderEntries: detached, scrubbedAdapterEntries: scrubbedAdapters, + policyHandoff, + revalidateBeforeDelete: async () => { + assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy); + }, }; } @@ -246,5 +300,8 @@ export async function restoreMcpBridgesAfterRebuild( // Persist the recovery contract before touching the gateway. If refresh // fails, `mcp restart` remains retryable after the operator fixes the cause. setBridgeState(sandboxName, bridges); - await restoreExistingMcpBridgeRuntime(sandboxName, entries); + // Sandbox creation already received the complete pre-rebuild OpenShell + // policy. Restore providers and adapters without regenerating or overwriting + // policy entries that an operator may have edited independently. + await restoreExistingMcpBridgeRuntime(sandboxName, entries, { applyPolicy: false }); } diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index 6d7c956ed05..cbab3068043 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -324,7 +324,7 @@ async function removeMcpBridgeUnlocked( if (adapterRemoval === "unowned") { adapterCleanupProved = false; throw new McpBridgeError( - `Could not prove removal of the exact managed adapter entry for MCP server '${entry.server}'. Preserved provider, policy, and registry ownership state.`, + `Could not prove removal of the exact managed adapter entry for MCP server '${entry.server}'. Preserved the provider and MCP bridge lifecycle record.`, ); } if (adapter === "hermes-config") { @@ -392,7 +392,7 @@ async function removeMcpBridgeUnlocked( } if (!reservationCleanupProved) { failures.push( - `Provider detach or policy cleanup state for '${entry.providerName}' is unknown; preserved the MCP ownership manifest.`, + `Provider detach or policy cleanup state for '${entry.providerName}' is unknown; preserved the MCP bridge record.`, ); } if ( diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index 23595deca94..379812df5ec 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -101,9 +101,7 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P const resolvedByServer = await preflightMcpEntryTargets(targetEntries); assertMcpCredentialBoundaryRuntimeVersion(); await ensureSandboxGatewaySelected(sandboxName); - // Prove every policy key is absent or still matches its recorded ownership - // before inspecting or updating any provider. `applyGeneratedPolicy` repeats - // this check immediately before mutation to close the preflight-to-apply race. + // Validate every generated policy name before inspecting or updating any provider. for (const entry of targetEntries) assertGeneratedPolicyMutationSafe(sandboxName, entry); const providerInspectionByServer = new Map(); for (const entry of targetEntries) { @@ -200,7 +198,10 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P export async function restoreExistingMcpBridgeRuntime( sandboxName: string, entries: readonly McpBridgeEntry[], - options: { lifecyclePhase?: "active-mutation" | "teardown-rollback" } = {}, + options: { + lifecyclePhase?: "active-mutation" | "teardown-rollback"; + applyPolicy?: boolean; + } = {}, ): Promise { if (entries.length === 0) return; for (const entry of entries) assertAuthenticatedBridgeEntry(entry); @@ -238,11 +239,15 @@ export async function restoreExistingMcpBridgeRuntime( for (const entry of entries) { assertNoAttachedProviderCredentialCollisions(sandboxName, [entry]); ensureMcpBridgeProviderProfile(); - applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { - bindCredential: false, - }); + if (options.applyPolicy !== false) { + applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { + bindCredential: false, + }); + } attachProvider(sandboxName, entry); - applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry)); + if (options.applyPolicy !== false) { + applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry)); + } const adapter = (entry.adapter as AgentMcpAdapter | undefined) ?? defaultAdapter; refreshMcpProviderEnvironment(entry); const credentialRevision = waitForAttachedMcpCredential(sandboxName, entry); diff --git a/src/lib/actions/sandbox/mcp-bridge-state.ts b/src/lib/actions/sandbox/mcp-bridge-state.ts index e5bb739dd10..291c2efa813 100644 --- a/src/lib/actions/sandbox/mcp-bridge-state.ts +++ b/src/lib/actions/sandbox/mcp-bridge-state.ts @@ -206,15 +206,6 @@ export function assertNoDerivedResourceCollision( providerName: string | undefined, policyName: string, ): void { - const conflictingCustomPolicy = sandbox.customPolicies?.find( - (policy) => policy.name === policyName && policy.sourcePath !== MCP_BRIDGE_POLICY_SOURCE, - ); - if (conflictingCustomPolicy || sandbox.policies?.includes(policyName)) { - throw new McpBridgeError( - `Generated MCP policy name '${policyName}' conflicts with an existing non-MCP policy. Choose a different server name.`, - 2, - ); - } for (const entry of Object.values(bridgeState(sandbox))) { if (entry.server === server) continue; const providerCollision = diff --git a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts index 96d62902066..1757a5dc0f4 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-boundaries.test.ts @@ -78,11 +78,6 @@ registry.registerSandbox({ addedAt: "2026-06-01T00:00:00.000Z", } } }, }); -registry.addCustomPolicy("alpha", { - name: "mcp-bridge-fake", - content: "network_policies: {}\n", - sourcePath: "generated:nemoclaw-mcp-bridge", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); (async () => { const [status] = await bridge.statusMcpBridge("alpha", "fake"); diff --git a/src/lib/actions/sandbox/mcp-bridge-status-removal.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-removal.test.ts index b417a039bcc..abdf57c1637 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-removal.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-removal.test.ts @@ -71,12 +71,6 @@ registry.registerSandbox({ updatedAt: "2026-06-01T00:00:00.000Z", } } }, }); -registry.addCustomPolicy("legacy-sandbox", { - name: "mcp-bridge-github", - content: "network_policies:\\n mcp_bridge_github:\\n endpoints: []\\n", - sourcePath: "generated:nemoclaw-mcp-bridge", - appliedAt: "2026-06-01T00:00:00.000Z", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); bridge.removeMcpBridge("legacy-sandbox", "github").then( () => { @@ -150,12 +144,6 @@ registry.registerSandbox({ updatedAt: "2026-06-01T00:00:00.000Z", } } }, }); -registry.addCustomPolicy("legacy-sandbox", { - name: "mcp-bridge-github", - content: "network_policies:\\n mcp_bridge_github:\\n name: managed\\n endpoints: []\\n", - sourcePath: "generated:nemoclaw-mcp-bridge", - appliedAt: "2026-06-01T00:00:00.000Z", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); bridge.removeMcpBridge("legacy-sandbox", "github", { force: true }).then( () => process.exit(1), diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index e2b6015943c..09c024b10b2 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -147,17 +147,13 @@ registry.registerSandbox({ adapter: "mcporter", url: "https://api.githubcopilot.com/mcp/", env: ["GITHUB_TOKEN"], + allowedIps: ["8.8.8.8"], providerName: "alpha-mcp-github", providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", addedAt: "2026-06-01T00:00:00.000Z", } } }, }); -registry.addCustomPolicy("alpha", { - name: "mcp-bridge-github", - content: "network_policies: {}\n", - sourcePath: "generated:nemoclaw-mcp-bridge", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); const logLines = []; const errorLines = []; @@ -401,7 +397,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 expect(outcomes).toHaveLength(6); expect(outcomes.map((outcome) => outcome.gatewayPresent).slice(0, 3)).toEqual([ false, - false, + null, null, ]); outcomes.forEach((outcome) => { diff --git a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts index 4fae336ecff..eff50005e3d 100644 --- a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -75,9 +75,7 @@ beforeEach(() => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: null, - policies: ["pypi"], }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); vi.spyOn(onboardSession, "updateSession").mockReturnValue( @@ -149,7 +147,6 @@ describe("addSandboxPolicy drift-aware named re-add", () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "openclaw", - policies: ["npm"], }); vi.spyOn(policies, "listPresets").mockReturnValue([ { file: "npm.yaml", name: "npm", description: "npm registry access" }, @@ -158,7 +155,6 @@ describe("addSandboxPolicy drift-aware named re-add", () => { vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( "network_policies:\n npm_yarn:\n name: npm_yarn\n", ); - vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); const disclosureSpy = vi .spyOn(policies, "logOpenClawNpmCompatibilityDisclosure") .mockImplementation(() => undefined); @@ -228,26 +224,6 @@ describe("addSandboxPolicy drift-aware named re-add", () => { expect(refreshSpy).toHaveBeenCalledTimes(1); }); - it("refuses a built-in re-add when the name is owned by a custom preset", async () => { - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([ - { name: "pypi", content: "network_policies:\n pypi:\n host: custom.example.com\n" }, - ]); - - await expect( - captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), - ).resolves.toBe(1); - - expect(errSpy).toHaveBeenCalledWith( - " Preset 'pypi' was applied as a custom preset (--from-file).", - ); - expect(errSpy).toHaveBeenCalledWith( - ` Edit and re-apply it with --from-file, or run '${CLI_NAME} alpha policy remove pypi' first.`, - ); - expect(gatewayStateMock).not.toHaveBeenCalled(); - expect(applyPresetMock).not.toHaveBeenCalled(); - expect(refreshSpy).not.toHaveBeenCalled(); - }); - it("fails without an already-applied claim when the preset content cannot be read", async () => { vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue(null); diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts index b1d54c89a8e..c1f09cedf1c 100644 --- a/src/lib/actions/sandbox/policy-channel-baseline.test.ts +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -7,7 +7,6 @@ import * as store from "../../credentials/store"; import * as policies from "../../policy"; import { digestBaselineEntry } from "../../policy/baseline-exclusion"; import type { PolicyObject } from "../../policy/preset-parsing"; -import * as registry from "../../state/registry"; vi.mock("../../state/mcp-lifecycle-lock", () => ({ withSandboxMutationLock: (_name: string, action: () => Promise) => action(), @@ -48,7 +47,6 @@ let exitSpy: MockInstance; let promptMock: MockInstance; let excludeBaselineEntryMock: MockInstance; let restoreBaselineEntryMock: MockInstance; -let getBaselineExclusionsMock: MockInstance; async function captureExit(action: () => Promise): Promise { try { @@ -80,10 +78,6 @@ beforeEach(() => { }) as never); promptMock = vi.spyOn(store, "prompt").mockResolvedValue("y"); - vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes" }); - getBaselineExclusionsMock = vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); - vi.spyOn(registry, "getBaselineExclusionTransition").mockReturnValue(null); - vi.spyOn(policies, "resolveSandboxBaselinePolicy").mockReturnValue({ agent: "hermes", policyPath: "/repo/policy-additions.yaml", @@ -209,15 +203,7 @@ describe("excludeSandboxBaseline (#7178)", () => { }); describe("restoreSandboxBaseline (#7178)", () => { - it("exits when the key is not excluded", async () => { - getBaselineExclusionsMock.mockReturnValue([]); - const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" })); - expect(code).toBe(1); - expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); - }); - - it("restores a recorded exclusion after interactive acknowledgement", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); + it("restores a live missing baseline entry after interactive acknowledgement", async () => { await restoreSandboxBaseline("alpha", { key: "nous_research" }); expect(promptMock).toHaveBeenCalledOnce(); expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research", { @@ -226,7 +212,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("requires explicit acknowledgement in non-interactive mode (#8114)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" })); expect(code).toBe(1); @@ -241,7 +226,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("requires explicit restore acknowledgement when standard input has no terminal (#8877)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); arrangeTerminal(false); const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" })); @@ -252,7 +236,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("does not restore when standard input closes before acknowledgement (#8114)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); promptMock.mockRejectedValue( Object.assign(new Error("Prompt closed before input"), { code: "EOF" }), ); @@ -267,7 +250,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("restores without prompting when acknowledged via --yes (#8114)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; await restoreSandboxBaseline("alpha", { key: "nous_research", yes: true }); expect(promptMock).not.toHaveBeenCalled(); @@ -277,7 +259,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("restores without prompting when acknowledged via --force (#8114)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); await restoreSandboxBaseline("alpha", { key: "nous_research", force: true }); expect(promptMock).not.toHaveBeenCalled(); expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research", { @@ -286,7 +267,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("binds stale exclusion cleanup to an absent preview", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "legacy_entry", digest: "digest-1" }]); vi.mocked(policies.getSandboxBaselineEntry).mockReturnValue(null); await restoreSandboxBaseline("alpha", { key: "legacy_entry", force: true }); @@ -297,7 +277,6 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("discloses the restored egress before interactive acknowledgement (#8114)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); promptMock.mockImplementation(async () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining("re-allows:")); return "n"; @@ -310,14 +289,12 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("aborts when the interactive confirmation is declined (#8114)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); promptMock.mockResolvedValue("n"); await restoreSandboxBaseline("alpha", { key: "nous_research" }); expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); }); it("reports the cancellation when the interactive confirmation is declined", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); promptMock.mockResolvedValue("n"); await restoreSandboxBaseline("alpha", { key: "nous_research" }); expect(console.log).toHaveBeenCalledWith(" Cancelled."); @@ -325,14 +302,12 @@ describe("restoreSandboxBaseline (#7178)", () => { }); it("does not mutate on --dry-run", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); await restoreSandboxBaseline("alpha", { key: "nous_research", dryRun: true }); expect(promptMock).not.toHaveBeenCalled(); expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); }); it("does not mutate when a recorded agent baseline cannot be resolved (#7194)", async () => { - getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); vi.mocked(policies.resolveSandboxBaselinePolicy).mockImplementation(() => { throw new Error("Refusing to substitute the OpenClaw baseline"); }); diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index d3c871b4505..8ec52b22031 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -224,7 +224,6 @@ function makeHermesDiscordEntry(name: string): SandboxEntry { return { name, agent: "hermes", - policies: [], messaging: { schemaVersion: 1, plan: { @@ -353,7 +352,7 @@ beforeEach(() => { // Lazy legacy-provider seam: no onboarding graph is loaded for this suite. upsertMock = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders").mockReturnValue([]); - vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicyAuthority").mockImplementation( + vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicy").mockImplementation( () => undefined, ); @@ -609,13 +608,10 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { key === "DISCORD_BOT_TOKEN" ? DISCORD_TOKEN : null, ); upsertMock.mockImplementationOnce(() => { - throw Object.assign( - new Error("alpha-discord-bridge does not match the required binding"), - { - code: "NEMOCLAW_MESSAGING_PROVIDER_BINDING_CONFLICT", - mutatedProviderNames: [], - }, - ); + throw Object.assign(new Error("alpha-discord-bridge does not match the required binding"), { + code: "NEMOCLAW_MESSAGING_PROVIDER_BINDING_CONFLICT", + mutatedProviderNames: [], + }); }); await expect(addSandboxChannel("alpha", { channel: "discord" })).rejects.toThrow( diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts index a8cafb39dbe..95b6dcf6cb6 100644 --- a/src/lib/actions/sandbox/policy-channel-dependencies.ts +++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/policy-authority"; +import { inspectOpenShellSandboxIdentityFingerprint } from "../../adapters/openshell/policy-state"; import { runOpenshell } from "../../adapters/openshell/runtime"; type MessagingProviderTokenDefinition = { @@ -76,12 +76,11 @@ export const policyChannelDependencies = { runOpenshell: gatewayRunner(gatewayName), }); }, - revalidateChannelProviderPolicyAuthority(sandboxName: string, gatewayName: string): void { + revalidateChannelProviderPolicy(sandboxName: string, gatewayName: string): void { const policy = require("../../policy") as PolicyModule; const operation = `change messaging providers for sandbox '${sandboxName}'`; - const authority = policy.inspectPolicyMutationAuthority(sandboxName, operation, gatewayName); - policy.assertNemoClawManagedPolicy(authority, operation); - policy.recheckPolicyMutationAuthority(sandboxName, operation, authority); + const context = policy.inspectPolicyMutationContext(sandboxName, operation, gatewayName); + policy.recheckPolicyMutationContext(sandboxName, operation, context); }, runGatewayOpenshell( gatewayName: string, diff --git a/src/lib/actions/sandbox/policy-channel-list.test.ts b/src/lib/actions/sandbox/policy-channel-list.test.ts index 40ec52266b3..051f05e49e2 100644 --- a/src/lib/actions/sandbox/policy-channel-list.test.ts +++ b/src/lib/actions/sandbox/policy-channel-list.test.ts @@ -1,375 +1,64 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -type PresetInfo = { - name: string; - description?: string; -}; - -const moduleMocks = vi.hoisted(() => ({ - getSandbox: vi.fn<(sandboxName: string) => Record | null>(), - getCustomPolicies: vi.fn<(sandboxName: string) => PresetInfo[]>(), - getBaselineExclusions: vi.fn(), - getBaselineExclusionTransition: vi.fn(), - 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>(), - getSandboxBaselineEntryDigest: vi.fn(), - isDockerRuntimeDown: vi.fn<(sandboxName: string) => boolean>(), - printDockerRuntimeDownGuidance: vi.fn(), -})); - -vi.mock("../../state/registry", async (importOriginal) => ({ - ...(await importOriginal()), - getSandbox: moduleMocks.getSandbox, - getCustomPolicies: moduleMocks.getCustomPolicies, - getBaselineExclusions: moduleMocks.getBaselineExclusions, - getBaselineExclusionTransition: moduleMocks.getBaselineExclusionTransition, +const mocks = vi.hoisted(() => ({ + getAppliedPresets: vi.fn(), + getGatewayPresets: vi.fn(), + listCustomPresets: vi.fn(), + listPresets: vi.fn(), })); vi.mock("../../policy", async (importOriginal) => ({ ...(await importOriginal()), - listPresets: moduleMocks.listPresets, - listCustomPresets: moduleMocks.listCustomPresets, - getAppliedPresets: moduleMocks.getAppliedPresets, - getGatewayPresets: moduleMocks.getGatewayPresets, - getSandboxBaselineEntryDigest: moduleMocks.getSandboxBaselineEntryDigest, -})); - -vi.mock("./gateway-failure-classifier", async (importOriginal) => ({ - ...(await importOriginal()), - isDockerRuntimeDown: moduleMocks.isDockerRuntimeDown, - printDockerRuntimeDownGuidance: moduleMocks.printDockerRuntimeDownGuidance, + getAppliedPresets: mocks.getAppliedPresets, + getGatewayPresets: mocks.getGatewayPresets, + listCustomPresets: mocks.listCustomPresets, + listPresets: mocks.listPresets, })); import { listSandboxPolicies } from "./policy-channel"; -const POLICY_PRESETS: PresetInfo[] = [ - { name: "npm", description: "npm and Yarn registry access" }, - { name: "pypi", description: "Python Package Index access" }, - { name: "discord", description: "Discord API access" }, - { name: "openclaw-pricing", description: "OpenClaw pricing lookup" }, - { name: "nous-web", description: "Nous Portal managed web search gateway" }, -]; - -let logSpy: MockInstance; -let errSpy: MockInstance; - -function printedText(): string { - return [...logSpy.mock.calls, ...errSpy.mock.calls] - .map((call) => call.map(String).join(" ")) +function output(): string { + return [...vi.mocked(console.log).mock.calls, ...vi.mocked(console.error).mock.calls] + .flat() .join("\n"); } -function arrangeListing({ - appliedNames, - gatewayNames, - tier, - agent, -}: { - appliedNames: string[]; - gatewayNames: string[] | null; - tier: string | null; - agent: string | null; -}): void { - moduleMocks.getSandbox.mockReturnValue({ - name: "test-sandbox", - agent, - policyTier: tier, - policies: appliedNames, - }); - moduleMocks.getAppliedPresets.mockReturnValue(appliedNames); - moduleMocks.getGatewayPresets.mockReturnValue(gatewayNames); -} - -beforeEach(() => { - vi.clearAllMocks(); - logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - moduleMocks.getCustomPolicies.mockReturnValue([]); - moduleMocks.listPresets.mockReturnValue(POLICY_PRESETS); - moduleMocks.listCustomPresets.mockReturnValue([]); - moduleMocks.isDockerRuntimeDown.mockReturnValue(false); - moduleMocks.getBaselineExclusions.mockReturnValue([]); - moduleMocks.getBaselineExclusionTransition.mockReturnValue(null); - moduleMocks.getSandboxBaselineEntryDigest.mockReturnValue(null); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("listSandboxPolicies provenance", () => { - it("discloses an interrupted baseline transaction and exact repair command (#7178)", () => { - arrangeListing({ - appliedNames: [], - gatewayNames: [], - tier: null, - agent: "hermes", - }); - moduleMocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-1", - operation: "exclude", - exclusion: { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "approved", - }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).toContain("repair required — interrupted exclude; rebuild blocked"); - expect(output).toContain("nemoclaw test-sandbox policy exclude nous_research"); - }); - - it.each([ - "exclude", - "restore", - ] as const)("keeps an interrupted %s repair visible when the release baseline is unreadable (#7194)", (operation) => { - arrangeListing({ - appliedNames: [], - gatewayNames: [], - tier: null, - agent: "hermes", - }); - moduleMocks.getBaselineExclusions.mockReturnValue([ - { - version: 1, - agent: "hermes", - key: "another_entry", - digest: "c".repeat(64), - acknowledgedAt: "2026-07-18T00:00:00.000Z", - }, - { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, +describe("policy list live state", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.listPresets.mockReturnValue([ + { file: "npm.yaml", name: "npm", description: "npm registry" }, + { file: "pypi.yaml", name: "pypi", description: "Python packages" }, ]); - moduleMocks.getBaselineExclusionTransition.mockReturnValue({ - id: "00000000-0000-4000-8000-000000000001", - operation, - exclusion: { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - targetLiveDigest: operation === "restore" ? "b".repeat(64) : null, - startedAt: "2026-07-19T00:00:00.000Z", - }); - moduleMocks.getSandboxBaselineEntryDigest.mockImplementation(() => { - throw new Error("release baseline unavailable"); - }); - - expect(() => listSandboxPolicies("test-sandbox")).not.toThrow(); - - const output = printedText(); - expect(output).toContain("another_entry (release baseline unreadable — inspection required)"); - expect(output).toContain(`repair required — interrupted ${operation}; rebuild blocked`); - expect(output).toContain(`nemoclaw test-sandbox policy ${operation} nous_research`); - expect(moduleMocks.getSandboxBaselineEntryDigest).toHaveBeenCalledOnce(); - expect(moduleMocks.getSandboxBaselineEntryDigest).toHaveBeenCalledWith( - "test-sandbox", - "another_entry", - ); + mocks.listCustomPresets.mockReturnValue([]); + mocks.getAppliedPresets.mockReturnValue(["npm"]); + mocks.getGatewayPresets.mockReturnValue(["npm"]); }); - it("tags active tier-default presets with their tier provenance (#5774)", () => { - arrangeListing({ - appliedNames: ["npm", "pypi"], - gatewayNames: ["npm", "pypi"], - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).toContain("● npm [from balanced tier]"); - expect(output).toContain("● pypi [from balanced tier]"); + it("marks presets from the current OpenShell policy as active", () => { + listSandboxPolicies("alpha"); + expect(output()).toContain("● npm [user-added]"); + expect(output()).toContain("○ pypi"); }); - it("keeps tier attribution when a custom registry entry shadows a tier preset (#5774)", () => { - moduleMocks.getCustomPolicies.mockReturnValue([ - { name: "npm", description: "sandbox-scoped custom npm policy" }, + it("lists namespaced custom presets derived from live policy", () => { + mocks.listCustomPresets.mockReturnValue([ + { file: "corp.yaml", name: "corp", description: "custom OpenShell policy" }, ]); - arrangeListing({ - appliedNames: ["npm"], - gatewayNames: ["npm"], - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).toContain("● npm [from balanced tier]"); - expect(output).not.toContain("● npm [user-added]"); - }); - - it("tags openclaw-pricing as an OpenClaw agent preset (#5774)", () => { - arrangeListing({ - appliedNames: ["openclaw-pricing"], - gatewayNames: ["openclaw-pricing"], - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - expect(printedText()).toContain("● openclaw-pricing [from openclaw agent]"); - }); - - it("tags nous-* presets as Hermes agent presets on Hermes (#5774)", () => { - arrangeListing({ - appliedNames: ["nous-web"], - gatewayNames: ["nous-web"], - tier: "open", - agent: "hermes", - }); - - listSandboxPolicies("test-sandbox"); - - expect(printedText()).toContain("● nous-web [from hermes agent]"); - }); - - it("tags presets outside the tier and agent defaults as user-added (#5774)", () => { - arrangeListing({ - appliedNames: ["discord"], - gatewayNames: ["discord"], - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - expect(printedText()).toContain("● discord [user-added]"); - }); - - it("omits the provenance tag for inactive presets (#5774)", () => { - arrangeListing({ - appliedNames: ["npm"], - gatewayNames: ["npm"], - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).toMatch(/○ pypi —/); - expect(output).not.toMatch(/○ pypi \[/); - }); - - it("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"); + mocks.getAppliedPresets.mockReturnValue(["corp"]); + mocks.getGatewayPresets.mockReturnValue(["corp"]); + listSandboxPolicies("alpha"); + expect(output()).toContain("● corp [user-added]"); }); - it.each([ - { - agent: "hermes", - preset: "openclaw-pricing", - forbidden: "[from openclaw agent]", - }, - { agent: "openclaw", preset: "nous-web", forbidden: "[from hermes agent]" }, - ])("does not use another agent's provenance for $preset (#5774)", ({ - agent, - preset, - forbidden, - }) => { - arrangeListing({ - appliedNames: [preset], - gatewayNames: [preset], - tier: "balanced", - agent, - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).toContain(`● ${preset} [user-added]`); - expect(output).not.toContain(forbidden); - }); - - it("falls back to user-added when policyTier is missing (#5774)", () => { - arrangeListing({ - appliedNames: ["npm"], - gatewayNames: ["npm"], - tier: null, - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).toContain("● npm [user-added]"); - expect(output).not.toContain("[from balanced tier]"); - }); - - it("does not trust tier provenance for gateway-only desync (#5774)", () => { - arrangeListing({ - appliedNames: [], - gatewayNames: ["npm"], - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).not.toContain("● npm [from balanced tier]"); - expect(output).toContain( - "● npm [source unverified] — npm and Yarn registry access (active on gateway, missing from local state)", - ); - }); - - it("marks registry-only provenance as gateway-unreachable (#5774)", () => { - arrangeListing({ - appliedNames: ["npm"], - gatewayNames: null, - tier: "balanced", - agent: "openclaw", - }); - - listSandboxPolicies("test-sandbox"); - - const output = printedText(); - expect(output).not.toContain("● npm [from balanced tier]"); - expect(output).toContain("● npm [source unverified (gateway unreachable)]"); + it("does not report a durable baseline exclusion or repair ledger", () => { + listSandboxPolicies("alpha"); + expect(output()).not.toContain("repair required"); + expect(output()).not.toContain("Baseline exclusions"); }); }); diff --git a/src/lib/actions/sandbox/policy-channel-lock.test.ts b/src/lib/actions/sandbox/policy-channel-lock.test.ts index b685349f3e8..c9690fdb4bf 100644 --- a/src/lib/actions/sandbox/policy-channel-lock.test.ts +++ b/src/lib/actions/sandbox/policy-channel-lock.test.ts @@ -36,13 +36,7 @@ describe("policy and channel sandbox mutation locking", () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes", - policies: ["pypi"], }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([ - { version: 1, agent: "hermes", key: "nous_research", digest: "reviewed-digest" }, - ]); - vi.spyOn(registry, "getBaselineExclusionTransition").mockReturnValue(null); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); vi.spyOn(registry, "getDisabledChannels").mockReturnValue(["telegram"]); diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 147ab08ef0c..9dd10c5fd1c 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -97,9 +97,7 @@ beforeEach(() => { getSandboxMock = vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "test-sandbox", agent: null, - policies: ["pypi"], }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); vi.spyOn(onboardSession, "updateSession").mockReturnValue( @@ -319,21 +317,20 @@ describe("addSandboxPolicy", () => { expected: "curl is not in the preset binary allowlist, so curl probes can fail", detail: "https://discord.com/api/v10/gateway", }, - ])("prints validation guidance when $preset is selected interactively", async ({ - preset, - expected, - detail, - }) => { - selectFromListMock.mockResolvedValue(preset); - - await addSandboxPolicy("test-sandbox"); - - expect(printedText()).toContain(expected); - expect(printedText()).toContain(detail); - expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", preset, { - suppressDisclosure: true, - }); - }); + ])( + "prints validation guidance when $preset is selected interactively", + async ({ preset, expected, detail }) => { + selectFromListMock.mockResolvedValue(preset); + + await addSandboxPolicy("test-sandbox"); + + expect(printedText()).toContain(expected); + expect(printedText()).toContain(detail); + expect(applyPresetMock).toHaveBeenCalledWith("test-sandbox", preset, { + suppressDisclosure: true, + }); + }, + ); it("prints Discord validation guidance when the preset name is provided", async () => { await addSandboxPolicy("test-sandbox", { preset: "discord", yes: true }); diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 396a0e20194..5b177fb1b38 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -80,9 +80,7 @@ beforeEach(() => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: null, - policies: ["pypi"], }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); vi.spyOn(onboardSession, "updateSession").mockReturnValue( diff --git a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts index b1b0a71030e..decd0bc5d8b 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -12,7 +12,6 @@ import { MessagingWorkflowPlanner, } from "../../messaging"; import * as policies from "../../policy"; -import * as runner from "../../runner"; import type { SandboxEntry } from "../../state/registry/types"; import * as registry from "../../state/registry"; import { removeSandboxChannel, startSandboxChannel, stopSandboxChannel } from "./policy-channel"; @@ -59,7 +58,6 @@ describe("policy channel remove/enable flows", () => { const current = { name: "alpha", agent: "hermes", - policies: ["whatsapp"], messaging: { schemaVersion: 1, plan, @@ -89,9 +87,7 @@ describe("policy channel remove/enable flows", () => { expect(String(command)).toContain( "/sandbox/.hermes/profiles/dashboard-home/platforms/whatsapp/session", ); - expect(String(command)).toContain( - "/sandbox/.hermes/dashboard-home/platforms/whatsapp/session", - ); + expect(String(command)).toContain("/sandbox/.hermes/dashboard-home/platforms/whatsapp/session"); } async function removeWhatsappNonInteractive() { @@ -150,7 +146,9 @@ describe("policy channel remove/enable flows", () => { it.each([ { scenario: "exec transport", execStatus: 0, usesSsh: false }, { scenario: "SSH fallback", execStatus: 1, usesSsh: true }, - ])("clears every Hermes WhatsApp session path through $scenario", async ({ execStatus, usesSsh }) => { + ])( + "clears every Hermes WhatsApp session path through $scenario", + async ({ execStatus, usesSsh }) => { const { updateSandbox } = await arrangeHermesWhatsappRemoval(); vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue({ status: execStatus, @@ -180,36 +178,35 @@ describe("policy channel remove/enable flows", () => { expect(transport.mock.invocationCallOrder[0]).toBeLessThan( updateSandbox.mock.invocationCallOrder[0], ); - }); + }, + ); it("keeps channel state unchanged when both Hermes cleanup transports fail", async () => { - const { rebuildSandbox, removePreset, updateSandbox } = await arrangeHermesWhatsappRemoval(); - const runOpenshell = vi.spyOn(openshellRuntime, "runOpenshell"); - vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue({ - status: 1, - stdout: "", - stderr: "exec unavailable", - }); - vi.mocked(processRecovery.executeSandboxCommand).mockReturnValue({ - status: 1, - stdout: "", - stderr: "ssh unavailable", - }); + const { rebuildSandbox, removePreset, updateSandbox } = await arrangeHermesWhatsappRemoval(); + const runOpenshell = vi.spyOn(openshellRuntime, "runOpenshell"); + vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue({ + status: 1, + stdout: "", + stderr: "exec unavailable", + }); + vi.mocked(processRecovery.executeSandboxCommand).mockReturnValue({ + status: 1, + stdout: "", + stderr: "ssh unavailable", + }); - await expect(removeWhatsappNonInteractive()).rejects.toThrow("process.exit(1)"); + await expect(removeWhatsappNonInteractive()).rejects.toThrow("process.exit(1)"); - expectHermesSessionCleanup( - vi.mocked(processRecovery.executeSandboxExecCommand).mock.calls[0]?.[1], - ); - expectHermesSessionCleanup( - vi.mocked(processRecovery.executeSandboxCommand).mock.calls[0]?.[1], - ); + expectHermesSessionCleanup( + vi.mocked(processRecovery.executeSandboxExecCommand).mock.calls[0]?.[1], + ); + expectHermesSessionCleanup(vi.mocked(processRecovery.executeSandboxCommand).mock.calls[0]?.[1]); - expect(runOpenshell).not.toHaveBeenCalled(); - expect(updateSandbox).not.toHaveBeenCalled(); - expect(removePreset).not.toHaveBeenCalled(); - expect(rebuildSandbox).not.toHaveBeenCalled(); - }); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(updateSandbox).not.toHaveBeenCalled(); + expect(removePreset).not.toHaveBeenCalled(); + expect(rebuildSandbox).not.toHaveBeenCalled(); + }); it("supports stop dry runs for configured Hermes channels", async () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes" }); @@ -233,7 +230,7 @@ describe("policy channel remove/enable flows", () => { const updateSandboxSpy = vi.spyOn(registry, "updateSandbox"); const applyPresetSpy = vi.spyOn(policies, "applyPreset"); const rebuildSpy = vi.spyOn(policyChannelDependencies, "rebuildSandbox"); - vi.spyOn(runner, "runCapture").mockReturnValue("version: 1\nnetwork_policies: {}\n"); + vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); await expect( startSandboxChannel("alpha", { channel: "telegram", dryRun: true }), ).resolves.toBeUndefined(); @@ -258,29 +255,7 @@ describe("policy channel remove/enable flows", () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha" }); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); vi.spyOn(registry, "getDisabledChannels").mockReturnValue(["telegram"]); - const liveTelegramPolicy = [ - "version: 1", - "network_policies:", - " telegram_bot:", - " name: telegram_bot", - " endpoints:", - " - host: api.telegram.org", - " port: 443", - " protocol: rest", - " enforcement: enforce", - // An already-matching live policy carries the materialized provider. - " credential_binding:", - " provider: alpha-telegram-bridge", - " 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/node }", - "", - ].join("\n"); - vi.spyOn(runner, "runCapture").mockReturnValue(liveTelegramPolicy); + vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("match"); await expect( startSandboxChannel("alpha", { channel: "telegram", dryRun: true }), diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 5c4cd667768..69d2e6d603f 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -157,6 +157,7 @@ function withSandboxMutationLockUnlessPreview( */ export interface AddSandboxChannelDependencies { readonly googlechatNonInteractiveAudienceCapability?: GooglechatNonInteractiveAudienceCapability; + readonly upsertMessagingProviders?: typeof policyChannelDependencies.upsertMessagingProviders; } const messagingManifestRegistry = createBuiltInChannelManifestRegistry(); @@ -308,9 +309,7 @@ async function addSandboxPolicyUnlocked( // preset files in place (for example to add `tls: skip` endpoints), so // compare the preset content against the live gateway policy and fall // through to a normal re-apply when it drifted. - const customNames = registry - .getCustomPolicies(sandboxName) - .map((entry: { name: string }) => entry.name); + const customNames = policies.listCustomPresets(sandboxName).map((entry) => entry.name); if (customNames.includes(preset.name)) { // A custom preset owns this name, so the built-in content is the // wrong comparison baseline; re-applying it would clobber the custom @@ -403,9 +402,7 @@ async function addSandboxPolicyUnlocked( } const needsOpenClawNpmDisclosure = answer === "npm" && (sandboxAgent === null || sandboxAgent === "openclaw"); - const npmBaselineExcluded = - needsOpenClawNpmDisclosure && - registry.getBaselineExclusions(sandboxName).some((entry) => entry.key === "npm_registry"); + const npmBaselineExcluded = false; if (needsOpenClawNpmDisclosure && !npmBaselineExcluded) { policies.logOpenClawNpmCompatibilityDisclosure(); } @@ -430,7 +427,6 @@ async function addSandboxPolicyUnlocked( if (!policies.applyPreset(sandboxName, answer, { suppressDisclosure: true })) { process.exit(1); } - syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "add"); refreshSandboxPolicyContextFile(sandboxName); } @@ -508,9 +504,6 @@ async function applyExternalPreset( suppressDisclosure: true, }); if (result !== false) { - // Custom presets share the registry slot with built-ins (customPolicies - // in policy/index.ts:684), so they need the same session-sync. - syncSessionPolicyPresetsWithRegistry(sandboxName, loaded.presetName, "add"); refreshSandboxPolicyContextFile(sandboxName); } return result !== false; @@ -526,74 +519,28 @@ export function listSandboxPolicies(sandboxName: string) { const builtin = policies.listPresets({ agent: sandboxEntry?.agent ?? null }); const custom = policies.listCustomPresets(sandboxName); const allPresets = [...builtin, ...custom]; - const registryPresets = policies.getAppliedPresets(sandboxName); // getGatewayPresets returns null when gateway is unreachable, or an // array of matched preset names when reachable (possibly empty). const gatewayPresets = policies.getGatewayPresets(sandboxName); const provenanceContext = { - tierName: sandboxEntry?.policyTier ?? null, agentName: sandboxEntry?.agent ?? null, }; console.log(""); console.log(` Policy presets for sandbox '${sandboxName}':`); allPresets.forEach((p: { name: string; description: string }) => { - const inRegistry = registryPresets.includes(p.name); - const inGateway = gatewayPresets ? gatewayPresets.includes(p.name) : null; + const observedInOpenShell = gatewayPresets ? gatewayPresets.includes(p.name) : null; console.log( formatPolicyListPresetRow({ preset: p, provenanceContext, - inRegistry, - inGateway, + observedInOpenShell, }), ); }); - const exclusions = registry.getBaselineExclusions(sandboxName); - const exclusionTransition = registry.getBaselineExclusionTransition(sandboxName); - if (exclusions.length > 0 || exclusionTransition) { - console.log(""); - console.log(" Baseline exclusions (unsupported egress removed):"); - const listed = new Map(exclusions.map((exclusion) => [exclusion.key, exclusion])); - if (exclusionTransition) { - listed.set(exclusionTransition.exclusion.key, exclusionTransition.exclusion); - } - for (const exclusion of listed.values()) { - const isPending = exclusionTransition?.exclusion.key === exclusion.key; - // A repair command must remain visible even if the current agent - // baseline cannot be loaded. Resolving that baseline is part of the - // explicit retry, not a prerequisite for displaying the journal. - let currentDigest: string | null | undefined; - if (isPending) { - currentDigest = null; - } else { - try { - currentDigest = policies.getSandboxBaselineEntryDigest(sandboxName, exclusion.key); - } catch { - currentDigest = undefined; - } - } - const status = isPending - ? `${YW}repair required — interrupted ${exclusionTransition.operation}; rebuild blocked${R}` - : currentDigest === undefined - ? `${YW}release baseline unreadable — inspection required${R}` - : currentDigest === null - ? `${YW}baseline entry removed — restore to clear${R}` - : currentDigest === exclusion.digest - ? "active" - : `${YW}baseline changed — re-review required${R}`; - console.log(` - ${exclusion.key} (${status})`); - if (isPending) { - console.log( - ` Re-run: ${CLI_NAME} ${sandboxName} policy ${exclusionTransition.operation} ${exclusion.key}`, - ); - } - } - } - if (gatewayPresets === null) { console.log(""); // A null gateway result can be a transient Docker daemon outage rather @@ -606,7 +553,7 @@ export function listSandboxPolicies(sandboxName: string) { retryCommand: "policy-list", }); } else { - console.log(" ⚠ Could not query gateway — showing local state only."); + console.log(" ⚠ Could not query OpenShell — applied policy state is unavailable."); } } console.log(""); @@ -849,6 +796,7 @@ async function applyChannelAddToGatewayAndRegistry( channelName: string, acquired: Record, applyPolicyAfterAttachment?: () => boolean, + upsertMessagingProviders = policyChannelDependencies.upsertMessagingProviders, ): Promise { const sandboxAgent = registry.getSandbox(sandboxName)?.agent; const staticProviderType = staticMessagingProviderTypeForChannel(channelName, sandboxAgent); @@ -900,18 +848,14 @@ async function applyChannelAddToGatewayAndRegistry( console.error(` ${gatewayStartGuidance(gatewayName)}`); process.exit(1); } - policyChannelDependencies.revalidateChannelProviderPolicyAuthority(sandboxName, gatewayName); + policyChannelDependencies.revalidateChannelProviderPolicy(sandboxName, gatewayName); try { // bestEffort: failures throw (instead of process.exit inside the helper) // so a partial add can be torn down below before exiting. - const providerNames = policyChannelDependencies.upsertMessagingProviders( - tokenDefs, - gatewayName, - { - bestEffort: true, - requireExactBindings: true, - }, - ); + const providerNames = upsertMessagingProviders(tokenDefs, gatewayName, { + bestEffort: true, + requireExactBindings: true, + }); for (const providerName of providerNames) { revalidateMessagingProviderAttachmentTarget(sandboxName, gatewayName); const attached = runOpenshell( @@ -1494,7 +1438,13 @@ async function addSandboxChannelUnlocked( ) { process.exit(1); } - await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); + await applyChannelAddToGatewayAndRegistry( + sandboxName, + canonical, + {}, + undefined, + dependencies.upsertMessagingProviders, + ); if (!MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan)) { console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); removeChannelPresetIfPresent(sandboxName, canonical); @@ -1546,6 +1496,7 @@ async function addSandboxChannelUnlocked( applyChannelPresetIfAvailable(sandboxName, canonical, "add", { disclosedPresetState, }), + dependencies.upsertMessagingProviders, ); if (registeredBridge === null) { await rollbackChannelAdd(sandboxName, channelDef, canonical, { @@ -1672,7 +1623,6 @@ export function applyChannelPresetIfAvailable( ); return false; } - syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "add"); refreshSandboxPolicyContextFile(sandboxName); return true; } catch (err) { @@ -1747,55 +1697,6 @@ function clearSandboxChannelDurableState(sandboxName: string, channelName: strin return true; } -// Mirror a registry-side preset add/remove into `session.policyPresets`. -// Without this, a later `rebuild` re-enters onboard resume, reads the -// stale session, and narrows the preset back away — see #3437 follow-up. -// Best-effort: registry has already succeeded; failure paths log and -// swallow so the caller's flow is never broken by a session I/O error. -function syncSessionPolicyPresetsWithRegistry( - sandboxName: string, - presetName: string, - action: "add" | "remove", -): void { - let session: ReturnType; - try { - session = onboardSession.loadSession(); - } catch { - return; - } - // No session = nothing to sync. Foreign sandbox = leave its intent alone. - if (!session) return; - if (session.sandboxName !== sandboxName) return; - - const current = Array.isArray(session.policyPresets) ? session.policyPresets : []; - const has = current.includes(presetName); - // Skip the file write when the desired state already holds. - if (action === "add" && has) return; - if (action === "remove" && !has) return; - - try { - onboardSession.updateSession((s) => { - const arr = Array.isArray(s.policyPresets) ? [...s.policyPresets] : []; - if (action === "add") { - if (!arr.includes(presetName)) arr.push(presetName); - } else { - const idx = arr.indexOf(presetName); - if (idx >= 0) arr.splice(idx, 1); - } - s.policyPresets = arr; - return s; - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error( - ` ${YW}⚠${R} Could not record '${presetName}' preset ${action} in onboard session: ${msg}`, - ); - console.error( - ` Registry is consistent; rerun '${CLI_NAME} ${sandboxName} policy-${action === "add" ? "add" : "remove"} ${presetName}' after rebuild if needed.`, - ); - } -} - // Mirror of applyChannelPresetIfAvailable. When the channel-named built-in // preset is currently applied to the sandbox, un-apply it so `policy-list` // no longer reports it active and the L7 proxy stops allow-listing the @@ -1804,11 +1705,9 @@ function syncSessionPolicyPresetsWithRegistry( export function removeChannelPresetIfPresent(sandboxName: string, channelName: string): boolean { const builtinPresets = new Set(policies.listPresets().map((p) => p.name)); if (!builtinPresets.has(channelName)) { - syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove"); return true; } if (!policies.getAppliedPresets(sandboxName).includes(channelName)) { - syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove"); return true; } try { @@ -1820,7 +1719,6 @@ export function removeChannelPresetIfPresent(sandboxName: string, channelName: s ); return false; } - syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove"); refreshSandboxPolicyContextFile(sandboxName); return true; } catch (err) { @@ -1871,20 +1769,8 @@ async function removeSandboxChannelUnlocked( const isQrChannel = channelUsesInSandboxQrPairing(channel); const registryEntry = registry.getSandbox(sandboxName); - let sessionForSandbox: ReturnType = null; - try { - sessionForSandbox = onboardSession.loadSession(); - } catch { - sessionForSandbox = null; - } - const sessionPolicyPresets = - sessionForSandbox?.sandboxName === sandboxName && Array.isArray(sessionForSandbox.policyPresets) - ? sessionForSandbox.policyPresets - : []; const hasChannelResidue = registry.getConfiguredMessagingChannelsFromEntry(registryEntry).includes(canonical) || - (registryEntry?.policies || []).includes(canonical) || - sessionPolicyPresets.includes(canonical) || policies.getAppliedPresets(sandboxName).includes(canonical); // The public Google Chat endpoint must stop before credentials, providers, @@ -2099,17 +1985,11 @@ async function removeSandboxPolicyUnlocked( const dryRun = Boolean(options.dryRun); const skipConfirm = Boolean(options.yes || options.force || isNonInteractive()); - // Remove-able presets = built-in presets + custom presets applied via - // --from-file / --from-dir (tracked in registry.customPolicies). + // Custom preset names are decoded from their namespaced live OpenShell keys. const builtinPresets = policies.listPresets(); const customPresets = policies.listCustomPresets(sandboxName); const allPresets = [...builtinPresets, ...customPresets]; - // `policy list` reports a preset as active when either the registry or the - // gateway holds it, so removal has to accept the same set. A preset the - // gateway enforces but the registry never recorded is exactly the state - // `policy list` flags as "active on gateway, missing from local state", and - // removePreset() reconciles it without needing the registry entry. Null means - // the gateway could not be queried, which is not evidence of absence. (#9295) + // Active preset names come from the current OpenShell policy. const applied = policies.getAppliedPresets(sandboxName); const gatewayPresets = policies.getGatewayPresets(sandboxName); const removable = gatewayPresets ? [...new Set([...applied, ...gatewayPresets])] : applied; @@ -2149,19 +2029,7 @@ async function removeSandboxPolicyUnlocked( } if (!answer) return; - // 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.loadPresetForSandbox(sandboxName, answer); - if (!presetContent) { - const entry = customPresets.find((p: { name: string }) => p.name === answer); - if (entry) { - const persisted = registry - .getCustomPolicies(sandboxName) - .find((p: { name: string }) => p.name === answer); - presetContent = persisted ? persisted.content : null; - } - } + const presetContent = policies.loadPresetForSandbox(sandboxName, answer); if (!presetContent) return; const endpoints = policies.getPresetEndpoints(presetContent); @@ -2182,7 +2050,6 @@ async function removeSandboxPolicyUnlocked( if (!policies.removePreset(sandboxName, answer)) { process.exit(1); } - syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "remove"); refreshSandboxPolicyContextFile(sandboxName); } @@ -2270,8 +2137,6 @@ async function excludeSandboxBaselineUnlocked( } if (!policies.excludeBaselineEntry(sandboxName, key, digest)) { - // A failed cross-system mutation can leave a durable repair journal. Keep - // the in-sandbox context aligned before returning the nonzero result. refreshSandboxPolicyContextFile(sandboxName); process.exit(1); } @@ -2302,14 +2167,6 @@ async function restoreSandboxBaselineUnlocked( process.exit(1); } - const isExcluded = registry.getBaselineExclusions(sandboxName).some((entry) => entry.key === key); - const pendingTransition = registry.getBaselineExclusionTransition(sandboxName); - const isPendingForKey = pendingTransition?.exclusion.key === key; - if (!isExcluded && !isPendingForKey) { - console.error(` Baseline entry '${key}' is not excluded for '${sandboxName}'.`); - process.exit(1); - } - const baseline = policies.resolveSandboxBaselinePolicy(sandboxName); if (!baseline) { console.error(` Could not read the baseline policy for sandbox '${sandboxName}'.`); @@ -2326,7 +2183,7 @@ async function restoreSandboxBaselineUnlocked( ); } else { console.log( - ` ${YW}⚠${R} The current baseline no longer defines '${key}'; clearing the exclusion record only.`, + ` ${YW}⚠${R} The current baseline no longer defines '${key}'; no change is needed.`, ); } diff --git a/src/lib/actions/sandbox/policy-explain.test.ts b/src/lib/actions/sandbox/policy-explain.test.ts index d70e861b2e6..12faf2318ae 100644 --- a/src/lib/actions/sandbox/policy-explain.test.ts +++ b/src/lib/actions/sandbox/policy-explain.test.ts @@ -21,7 +21,6 @@ function fakeContext(sandboxName: string): PolicyContext { tier: null, activePresets: [], knownUnappliedPresets: [], - baselineExclusions: [], approvalPath: { inspect: `nemoclaw ${sandboxName} policy-list`, add: `nemoclaw ${sandboxName} policy-add `, @@ -204,18 +203,16 @@ describe("writePolicyContextToSandbox", () => { expect(result.reason).toContain("denied"); }); - it.each( - [ - "rm -rf", - "curl http://attacker", - "whoami", - "nc attacker", - "/etc/passwd", - "shutdown -h", - "/etc/shadow", - "evil", - ], - )( + it.each([ + "rm -rf", + "curl http://attacker", + "whoami", + "nc attacker", + "/etc/passwd", + "shutdown -h", + "/etc/shadow", + "evil", + ])( "encodes hostile markdown payloads as base64 so they cannot break out of the write command [%s]", (token) => { const hostile = [ diff --git a/src/lib/actions/sandbox/policy-list-render.test.ts b/src/lib/actions/sandbox/policy-list-render.test.ts index 574bb1c6420..80ecdc2363e 100644 --- a/src/lib/actions/sandbox/policy-list-render.test.ts +++ b/src/lib/actions/sandbox/policy-list-render.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 // Regression for #5967: `nemoclaw policy-list` must render `● discord` -// (and any enabled messaging channel preset) once it is recorded in the registry -// and active on the gateway. This is the reporter's observation step — the +// (and any enabled messaging channel preset) once it is active in OpenShell. +// This is the reporter's observation step — the // rendered marker the operator actually reads — complementing the merge/persist // tests that cover the upstream state policy-list consumes. @@ -88,13 +88,13 @@ describe("listSandboxPolicies rendering (#5967)", () => { expect(lineFor("discord")).not.toContain("● discord"); }); - it("flags a registry/gateway mismatch when Discord is recorded but not active on the gateway", () => { + it("does not invent local ownership when the two live policy views disagree", () => { mocked.getAppliedPresets.mockReturnValue(["discord", "npm"]); mocked.getGatewayPresets.mockReturnValue(["npm"]); listSandboxPolicies("nemoclaw-5967"); expect(lineFor("discord")).toContain("○ discord"); - expect(lineFor("discord")).toContain("recorded locally, not active on gateway"); + expect(lineFor("discord")).not.toContain("recorded locally"); }); }); diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.test.ts b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts index b407820f0bb..0b4e71e34aa 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts @@ -1,310 +1,106 @@ // 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 fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as sandboxState from "../../state/sandbox"; -import { - normalizeRebuildObservabilityPolicyPresets, - normalizeRebuildTargetPolicyPresets, - normalizeRebuildWebSearchPolicyPresets, - type RebuildBackupPhaseInput, - runRebuildBackupPhase, -} from "./rebuild-backup-phase"; +const mocks = vi.hoisted(() => ({ + getSandboxPolicy: vi.fn(), + secureTempFile: vi.fn(), +})); -describe("rebuild web-search policy normalization", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); +vi.mock("./policy-get", () => ({ getSandboxPolicy: mocks.getSandboxPolicy })); +vi.mock("../../onboard/temp-files", async (importOriginal) => ({ + ...(await importOriginal()), + secureTempFile: mocks.secureTempFile, +})); - it("keeps only the durable Tavily provider and removes stale nous-web", () => { - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "brave", "nous-web", "tavily"], - { name: "alpha", agent: "hermes" }, - { fetchEnabled: true, provider: "tavily" }, - ), - ).toEqual(["npm", "tavily"]); - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "brave"], - { name: "alpha", agent: "hermes" }, - { fetchEnabled: true, provider: "tavily" }, - ), - ).toEqual(["npm", "tavily"]); - }); +import { type RebuildBackupPhaseInput, runRebuildBackupPhase } from "./rebuild-backup-phase"; - it("removes both built-in providers for an authoritative disable, except a tier egress default (#10404)", () => { - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "brave", "tavily"], - { name: "alpha", agent: "openclaw" }, - null, - ), - ).toEqual(["npm"]); - // Balanced and Open both default `brave` as tier egress, so a rebuild that - // declines web search keeps it and still drops `tavily`, which neither tier - // defaults. - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "brave", "tavily"], - { name: "alpha", agent: "openclaw", policyTier: "balanced" }, - null, - ), - ).toEqual(["npm", "brave"]); - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "brave", "tavily"], - { name: "alpha", agent: "openclaw", policyTier: "open" }, - null, - ), - ).toEqual(["npm", "brave"]); - }); - - it.each(["hermes", "langchain-deepagents-code"])( - "removes OpenClaw-only brave from a Balanced-tier %s rebuild", - (agent) => { - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "brave"], - { name: "alpha", agent, policyTier: "balanced" }, - null, - ), - ).toEqual(["npm"]); - }, - ); +const temporaryDirectories: string[] = []; - it("preserves DCode's standalone Tavily and excludes custom names from built-in replay", () => { - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "tavily"], - { name: "alpha", agent: "langchain-deepagents-code" }, - null, - ), - ).toEqual(["npm", "tavily"]); - expect( - normalizeRebuildWebSearchPolicyPresets( - ["npm", "tavily"], - { - name: "alpha", - agent: "openclaw", - customPolicies: [{ name: "tavily", content: "allow: []" }], - }, - null, - ), - ).toEqual(["npm"]); +beforeEach(() => { + mocks.getSandboxPolicy.mockReset().mockReturnValue({ + yaml: "version: 1\nnetwork_policies: {}\n", }); - - it("keeps a finalized custom-only built-in selection empty instead of resetting it", () => { - const result = runRebuildBackupPhase({ - sandboxName: "alpha", - sandboxEntry: { - name: "alpha", - agent: "openclaw", - policies: ["tavily"], - customPolicies: [{ name: "tavily", content: "allow: []" }], - policyPresetsFinalized: true, - }, - staleRecovery: false, - preparedRecoveryManifest: { - policyPresets: ["tavily"], - customPolicies: [{ name: "tavily", content: "allow: []" }], - } as never, - messagingPlan: null, - webSearchConfig: null, - log: vi.fn(), - bail: (message): never => { - throw new Error(message); - }, - relockShieldsIfNeeded: () => true, - }); - - expect(result?.policyPresets).toEqual([]); - expect(result?.sessionPolicyPresets).toEqual([]); - expect(result?.backupWasForceSkipped).toBe(false); + mocks.secureTempFile.mockReset().mockImplementation(() => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-policy-default-")); + temporaryDirectories.push(directory); + return path.join(directory, "policy.yaml"); }); +}); - it("removes inactive built-in Hermes channel egress during rebuild", () => { - const customDiscordPolicy = { name: "discord", content: "network_policies: {}\n" }; - const preparedRecoveryManifest = { - policyPresets: ["discord"], - customPolicies: [customDiscordPolicy], - } as never; - - const result = runRebuildBackupPhase({ - sandboxName: "alpha", - sandboxEntry: { - name: "alpha", - agent: "hermes", - policies: ["discord"], - customPolicies: [customDiscordPolicy], - policyPresetsFinalized: true, - }, - staleRecovery: false, - preparedRecoveryManifest, - messagingPlan: { - schemaVersion: 1, - sandboxName: "alpha", - agent: "hermes", - workflow: "rebuild", - channels: [ - { - channelId: "slack", - displayName: "Slack", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }, - webSearchConfig: null, - log: vi.fn(), - bail: (message): never => { - throw new Error(message); - }, - relockShieldsIfNeeded: () => true, - }); - - expect(result?.policyPresets).toEqual(["slack"]); - expect(result?.backupManifest).toBe(preparedRecoveryManifest); - expect(result?.backupManifest?.customPolicies).toEqual([customDiscordPolicy]); - }); +afterEach(() => { + vi.restoreAllMocks(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); - it("records when --force skips a total backup failure", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(sandboxState, "backupSandboxState").mockReturnValue({ - success: false, - backedUpDirs: [], - backedUpFiles: [], - failedDirs: [".openclaw"], - failedFiles: ["openclaw.json"], +describe("rebuild policy handoff", () => { + it("captures the current OpenShell base policy in a private transaction file", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-policy-test-")); + temporaryDirectories.push(directory); + const policyPath = path.join(directory, "policy.yaml"); + mocks.secureTempFile.mockReturnValue(policyPath); + mocks.getSandboxPolicy.mockReturnValue({ + yaml: "version: 1\nnetwork_policies:\n host_changed: {}\n", }); - - const result = runRebuildBackupPhase({ - sandboxName: "alpha", - sandboxEntry: { name: "alpha", agent: "openclaw", policies: [] }, - staleRecovery: false, - preparedRecoveryManifest: null, - messagingPlan: null, - webSearchConfig: null, - force: true, - log: vi.fn(), - bail: (message): never => { - throw new Error(message); + const result = runRebuildBackupPhase( + { + sandboxName: "alpha", + sandboxEntry: { name: "alpha" }, + staleRecovery: false, + preparedRecoveryManifest: null, + messagingPlan: null, + webSearchConfig: null, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + relockShieldsIfNeeded: vi.fn(() => true), }, - relockShieldsIfNeeded: () => true, - }); - - expect(result?.backupManifest).toBeNull(); - expect(result?.backupWasForceSkipped).toBe(true); - }); - - it("removes stale built-in observability egress from disabled and restricted rebuild targets", () => { - expect( - normalizeRebuildObservabilityPolicyPresets(["npm", "observability-otlp-local"], { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - }), - ).toEqual(["npm"]); - expect( - normalizeRebuildObservabilityPolicyPresets(["npm", "observability-otlp-local"], { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "restricted", - }), - ).toEqual(["npm"]); - expect( - normalizeRebuildObservabilityPolicyPresets(["npm"], { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - }), - ).toEqual(["npm", "observability-otlp-local"]); - }); + vi.fn(() => null), + ); - it("leaves a same-name custom observability policy for exact custom replay", () => { - expect( - normalizeRebuildObservabilityPolicyPresets(["npm", "observability-otlp-local"], { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "restricted", - customPolicies: [{ name: "observability-otlp-local", content: "network_policies: {}" }], - }), - ).toEqual(["npm"]); + expect(result?.policySourcePath).toBe(policyPath); + expect(fs.readFileSync(policyPath, "utf8")).toContain("host_changed"); + expect(fs.statSync(policyPath).mode & 0o777).toBe(0o600); + expect(result).not.toHaveProperty("policyPresets"); }); - it("does not add built-in observability when a differently named custom policy owns its key", () => { - expect( - normalizeRebuildObservabilityPolicyPresets(["npm", "observability-otlp-local"], { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - customPolicies: [ - { - name: "corp-otel", - content: - "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", - }, - ], - }), - ).toEqual(["npm"]); - }); - - it("keeps fresh agent-required additions while suppressing stale restricted observability", () => { - expect( - normalizeRebuildTargetPolicyPresets( - ["npm", "future-agent-required", "observability-otlp-local"], + it("never reconstructs a missing live policy from NemoClaw state", () => { + expect(() => + runRebuildBackupPhase( { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: " Restricted ", + sandboxName: "alpha", + sandboxEntry: { name: "alpha" }, + staleRecovery: true, + preparedRecoveryManifest: null, + messagingPlan: null, + webSearchConfig: null, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + relockShieldsIfNeeded: vi.fn(() => true), }, - null, + vi.fn(() => null), ), - ).toEqual(["npm", "future-agent-required"]); + ).toThrow(/will not reconstruct policy from NemoClaw state/); }); - - it.each(["openclaw", "hermes", "langchain-deepagents-code", "pi"] as const)( - "repairs a Personal rebuild target missing its tier-defining preset: %s", - (agent) => { - expect( - normalizeRebuildTargetPolicyPresets( - ["npm"], - { name: "alpha", agent, policyTier: " Personal " }, - null, - ), - ).toEqual(["personal-open-internet", "npm"]); - }, - ); }); -describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { +describe("rebuild backup safety", () => { const completeMarkedManifest = { agentType: "openclaw", dir: "/sandbox/.openclaw", backupPath: "/tmp/custom-openclaw-backup", reconcileOpenClawImagePluginProvenance: true, openclawImagePluginInstalls: [], - } as never; + } as Record; function customOpenClawInput(overrides: Record = {}): RebuildBackupPhaseInput { return { @@ -319,7 +115,7 @@ describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { messagingPlan: null, webSearchConfig: null, log: vi.fn(), - bail: (message: string): never => { + bail: (message): never => { throw new Error(message); }, relockShieldsIfNeeded: vi.fn(() => true), @@ -327,36 +123,35 @@ describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { } as RebuildBackupPhaseInput; } - it("blocks a live custom image with missing registry provenance before backup", () => { - const backupStateForRebuild = vi.fn(); + it("blocks a live custom image with missing plugin provenance before backup", () => { + const backup = vi.fn(); const input = customOpenClawInput(); - const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(() => runRebuildBackupPhase(input, backupStateForRebuild)).toThrow( + expect(() => runRebuildBackupPhase(input, backup)).toThrow( "Custom-image OpenClaw plugin provenance is unavailable.", ); - - expect(backupStateForRebuild).not.toHaveBeenCalled(); + expect(backup).not.toHaveBeenCalled(); expect(input.relockShieldsIfNeeded).toHaveBeenCalledWith(true); - expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("new sandbox name")); - expect(errorLog).not.toHaveBeenCalledWith( - expect.stringContaining("NEMOCLAW_RECREATE_WITHOUT_BACKUP"), - ); - errorLog.mockRestore(); }); - it("uses a marked prepared manifest when registry provenance is missing", () => { - const backupStateForRebuild = vi.fn(); - const input = customOpenClawInput({ preparedRecoveryManifest: completeMarkedManifest }); - - const result = runRebuildBackupPhase(input, backupStateForRebuild); + it("uses a marked prepared manifest while still capturing live OpenShell policy", () => { + const backup = vi.fn(); + const backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-recovery-")); + temporaryDirectories.push(backupPath); + const preparedManifest = { ...completeMarkedManifest, backupPath } as never; + const result = runRebuildBackupPhase( + customOpenClawInput({ preparedRecoveryManifest: preparedManifest }), + backup, + ); - expect(result?.backupManifest).toBe(completeMarkedManifest); - expect(backupStateForRebuild).not.toHaveBeenCalled(); + expect(result?.backupManifest).toEqual(preparedManifest); + expect(result?.policySourcePath).toMatch(/rebuild-policy-handoff\.[a-f0-9]{64}\.yaml$/u); + expect(backup).not.toHaveBeenCalled(); }); - it("blocks an unmarked legacy prepared manifest before deletion", () => { - const backupStateForRebuild = vi.fn(); + it("blocks an unmarked legacy prepared manifest before replacement", () => { + const backup = vi.fn(); const input = customOpenClawInput({ preparedRecoveryManifest: { agentType: "openclaw", @@ -365,16 +160,16 @@ describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { openclawImagePluginInstalls: [], }, }); + vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(() => runRebuildBackupPhase(input, backupStateForRebuild)).toThrow( + expect(() => runRebuildBackupPhase(input, backup)).toThrow( "Custom-image OpenClaw plugin provenance is unavailable.", ); - - expect(backupStateForRebuild).not.toHaveBeenCalled(); + expect(backup).not.toHaveBeenCalled(); }); - it("revalidates a newly generated backup manifest before deletion", () => { - const backupStateForRebuild = vi.fn(() => ({ + it("revalidates a newly generated backup manifest before replacement", () => { + const backup = vi.fn(() => ({ agentType: "openclaw", dir: "/sandbox/.openclaw", backupPath: "/tmp/incomplete-custom-openclaw-backup", @@ -388,11 +183,36 @@ describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { openclawImagePluginInstalls: [], }, }); + vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(() => runRebuildBackupPhase(input, backupStateForRebuild as never)).toThrow( + expect(() => runRebuildBackupPhase(input, backup as never)).toThrow( "Custom-image OpenClaw plugin provenance is unavailable.", ); + expect(backup).toHaveBeenCalledOnce(); + }); + + it("records when --force skips a total filesystem backup failure", () => { + const backup = vi.fn(() => null); + const result = runRebuildBackupPhase( + { + sandboxName: "alpha", + sandboxEntry: { name: "alpha", agent: "openclaw" }, + staleRecovery: false, + preparedRecoveryManifest: null, + messagingPlan: null, + webSearchConfig: null, + force: true, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + relockShieldsIfNeeded: vi.fn(() => true), + }, + backup as never, + ); - expect(backupStateForRebuild).toHaveBeenCalledOnce(); + expect(result?.backupManifest).toBeNull(); + expect(result?.backupWasForceSkipped).toBe(true); + expect(result?.policySourcePath).toMatch(/policy\.yaml$/u); }); }); diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index 12406f918e1..575600cb5aa 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -1,30 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { type WebSearchConfig, webSearchProviderForConfig } from "../../inference/web-search"; +import fs from "node:fs"; +import path from "node:path"; + +import type { WebSearchConfig } from "../../inference/web-search"; import type { SandboxMessagingPlan } from "../../messaging"; -import { - mergeRebuildMessagingPolicyPresets, - pruneInactiveMessagingPolicyPresets, -} from "../../onboard/messaging-policy-presets"; -import { - isDcodeAgent, - isInactiveObservabilityPolicyPreset, - OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - requiredObservabilityPolicyPresets, -} from "../../onboard/observability-policy-presets"; -import { isStaleBuiltinWebSearchPolicyPreset } from "../../onboard/policy-preset-reconciliation"; -import { resolveRecreatePolicyPresets } from "../../onboard/policy-preset-persistence"; -import { - ensureRequiredTierPolicyPresets, - filterSuppressedAgentRequiredPresets, -} from "../../onboard/policy-tier-suppression"; -import { parsePresetPolicyKeys } from "../../policy"; -import { getTier } from "../../policy/tiers"; +import { cleanupTempDir, secureTempFile } from "../../onboard/temp-files"; import { hasCompleteOpenClawImagePluginProvenance } from "../../state/openclaw-plugin-restore"; -import { hasAuthoritativeOpenClawImagePluginProvenance } from "../../state/sandbox"; +import { + hasAuthoritativeOpenClawImagePluginProvenance, + readRebuildPolicyHandoff, + writeRebuildPolicyHandoff, +} from "../../state/sandbox"; import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight"; import { backupSandboxStateForRebuild, type RebuildSandboxEntry } from "./rebuild-flow-helpers"; +import * as policyGet from "./policy-get"; + +export { clearRebuildPolicyHandoff, writeRebuildPolicyHandoff } from "../../state/sandbox"; export type RebuildBackupManifest = Exclude< ReturnType, @@ -47,18 +40,7 @@ export interface RebuildBackupPhaseInput { export interface RebuildBackupPhaseResult { backupManifest: RebuildBackupManifest; backupWasForceSkipped: boolean; - policyPresets: string[]; - sessionPolicyPresets: string[] | null; -} - -export function excludePolicyPresetsByName( - presets: readonly string[], - excludedNames: readonly (string | undefined)[], -): string[] { - const excluded = new Set( - excludedNames.filter((name): name is string => typeof name === "string" && name.length > 0), - ); - return presets.filter((name) => !excluded.has(name)); + policySourcePath: string; } function bailForUnsafeOpenClawPluginProvenance(input: RebuildBackupPhaseInput): never { @@ -73,103 +55,16 @@ function bailForUnsafeOpenClawPluginProvenance(input: RebuildBackupPhaseInput): return input.bail("Custom-image OpenClaw plugin provenance is unavailable."); } -/** Align built-in web-search egress with the durable provider selection. */ -export function normalizeRebuildWebSearchPolicyPresets( - presets: readonly string[], - sandboxEntry: RebuildSandboxEntry, - webSearchConfig: WebSearchConfig | null, -): string[] { - const customPresetNames = new Set( - (sandboxEntry.customPolicies ?? []).map((policy) => policy.name), - ); - const selectedProvider = webSearchConfig ? webSearchProviderForConfig(webSearchConfig) : null; - const preserveStandaloneDcodeTavily = - selectedProvider === null && sandboxEntry.agent === "langchain-deepagents-code"; - const normalizedTierName = sandboxEntry.policyTier?.trim().toLowerCase(); - const tier = normalizedTierName ? getTier(normalizedTierName) : null; - const normalized = presets.filter((name) => { - // Exact custom content is replayed from backupManifest.customPolicies. - // Never substitute a same-name built-in during onboard or restore. - if (customPresetNames.has(name)) return false; - if (preserveStandaloneDcodeTavily && name === "tavily") return true; - // Same provenance exemption the onboard reuse path applies: a tier's own - // egress default (`brave` on Balanced/Open) is not a stale web-search - // leftover, so rebuilding a sandbox with web search declined must not - // narrow it. (#10404) - return !isStaleBuiltinWebSearchPolicyPreset(name, { - webSearchConfig, - customPresetNames, - tier, - agent: sandboxEntry.agent, - }); - }); - if ( - selectedProvider && - !customPresetNames.has(selectedProvider) && - !normalized.includes(selectedProvider) - ) { - normalized.push(selectedProvider); - } - return [...new Set(normalized)]; -} - -/** Align built-in observability egress with the durable opt-in and policy tier. */ -export function normalizeRebuildObservabilityPolicyPresets( - presets: readonly string[], - sandboxEntry: RebuildSandboxEntry, -): string[] { - const customPresetNames = new Set( - (sandboxEntry.customPolicies ?? []).map((policy) => policy.name.trim().toLowerCase()), - ); - const customOwnsObservabilityPolicy = (sandboxEntry.customPolicies ?? []).some((policy) => - parsePresetPolicyKeys(policy.content).includes(OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET), - ); - const customOwnsObservability = - customPresetNames.has(OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET) || customOwnsObservabilityPolicy; - const activePresets = presets.filter((name) => { - const normalizedName = name.trim().toLowerCase(); - if (normalizedName !== OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET) return true; - // Custom content is replayed separately from the captured manifest. Its - // registry name may differ from the network-policy key it owns, so neither - // form may be substituted with the built-in preset. - if (customOwnsObservability) return false; - return ( - isDcodeAgent(sandboxEntry.agent) && - !isInactiveObservabilityPolicyPreset(name, { - agent: sandboxEntry.agent, - observabilityEnabled: sandboxEntry.observabilityEnabled, - customPresetNames, - }) - ); - }); - if (!customOwnsObservability) { - for (const requiredPreset of requiredObservabilityPolicyPresets( - sandboxEntry.agent, - sandboxEntry.observabilityEnabled, - )) { - if (!activePresets.includes(requiredPreset)) activePresets.push(requiredPreset); - } - } - return filterSuppressedAgentRequiredPresets( - [...new Set(activePresets)], - sandboxEntry.policyTier, - sandboxEntry.agent, - ); -} - -/** Normalize the complete replacement target, including fresh inner-onboard additions. */ -export function normalizeRebuildTargetPolicyPresets( - presets: readonly string[], - sandboxEntry: RebuildSandboxEntry, - webSearchConfig: WebSearchConfig | null, -): string[] { - return ensureRequiredTierPolicyPresets( - sandboxEntry.policyTier, - normalizeRebuildObservabilityPolicyPresets( - normalizeRebuildWebSearchPolicyPresets([...new Set(presets)], sandboxEntry, webSearchConfig), - sandboxEntry, - ), - ); +export function captureRebuildPolicySource( + sandboxName: string, + policySourcePath?: string, +): string | null { + const policy = policyGet.getSandboxPolicy(sandboxName).yaml; + if (!policy) return null; + const resolvedPolicySourcePath = + policySourcePath ?? secureTempFile("nemoclaw-rebuild-policy", ".yaml"); + fs.writeFileSync(resolvedPolicySourcePath, policy, { mode: 0o600 }); + return resolvedPolicySourcePath; } export function runRebuildBackupPhase( @@ -200,7 +95,7 @@ export function runRebuildBackupPhase( ) { return bailForUnsafeOpenClawPluginProvenance(input); } - const backupManifest = + let backupManifest = preparedRecoveryManifest ?? backupStateForRebuild( input.sandboxName, @@ -223,46 +118,42 @@ export function runRebuildBackupPhase( const backupWasForceSkipped = input.force === true && !input.staleRecovery && backupManifest === null; - const registryPolicyPresets = Array.isArray(input.sandboxEntry.policies) - ? input.sandboxEntry.policies.filter( - (value: unknown): value is string => typeof value === "string", - ) - : []; - const disabledChannels = [...(input.messagingPlan?.disabledChannels ?? [])]; - const enabledChannelIds = (input.messagingPlan?.channels ?? []) - .filter((channel) => !channel.disabled) - .map((channel) => channel.channelId); - const mergedPolicyPresets = mergeRebuildMessagingPolicyPresets( - backupManifest?.policyPresets, - registryPolicyPresets, - enabledChannelIds, - disabledChannels, - ); - const activeMessagingPolicyPresets = input.messagingPlan - ? pruneInactiveMessagingPolicyPresets( - mergedPolicyPresets, - enabledChannelIds, - new Set( - (input.sandboxEntry.customPolicies ?? []).map((policy) => - policy.name.trim().toLowerCase(), - ), - ), - ) - : mergedPolicyPresets; - const policyPresets = normalizeRebuildTargetPolicyPresets( - activeMessagingPolicyPresets, - input.sandboxEntry, - input.webSearchConfig, - ); - const sessionPolicyPresets = resolveRecreatePolicyPresets( - policyPresets, - input.sandboxEntry.policyPresetsFinalized === true, - // Rebuild now replays exact custom policy content after recreate, so the - // built-in selection can independently preserve an intentional empty set. - false, - {}, - true, - ).policyPresets; - - return { backupManifest, backupWasForceSkipped, policyPresets, sessionPolicyPresets }; + const retainedPolicy = backupManifest ? readRebuildPolicyHandoff(backupManifest) : null; + if (input.staleRecovery && !retainedPolicy) { + return input.bail( + "The live OpenShell policy and its verified rebuild handoff are unavailable. Rebuild will not reconstruct policy from NemoClaw state.", + ); + } + const retainedHandoff = backupManifest?.rebuildPolicyHandoff; + const policySourcePath = + retainedPolicy && backupManifest && retainedHandoff + ? fs.realpathSync(path.join(backupManifest.backupPath, retainedHandoff.file)) + : captureRebuildPolicySource(input.sandboxName); + if (!policySourcePath) { + return input.bail( + "The current OpenShell policy could not be captured before sandbox replacement.", + ); + } + if (backupManifest && !retainedPolicy) { + try { + backupManifest = writeRebuildPolicyHandoff( + backupManifest, + fs.readFileSync(policySourcePath, "utf8"), + ); + const handoff = backupManifest.rebuildPolicyHandoff; + if (!handoff) throw new Error("rebuild policy handoff was not published"); + return { + backupManifest, + backupWasForceSkipped, + policySourcePath: fs.realpathSync(path.join(backupManifest.backupPath, handoff.file)), + }; + } catch (error) { + return input.bail( + `The current OpenShell policy could not be retained for rebuild recovery: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + cleanupTempDir(policySourcePath, "nemoclaw-rebuild-policy"); + } + } + return { backupManifest, backupWasForceSkipped, policySourcePath }; } diff --git a/src/lib/actions/sandbox/rebuild-baseline-transition-preflight.test.ts b/src/lib/actions/sandbox/rebuild-baseline-transition-preflight.test.ts deleted file mode 100644 index 9fd0e7511c4..00000000000 --- a/src/lib/actions/sandbox/rebuild-baseline-transition-preflight.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - assertMcpDestroyNotPending: vi.fn(), - bail: vi.fn(), - confirmRebuildIntent: vi.fn(), - countActiveSessions: vi.fn(), - getSandbox: vi.fn(), - prepareTargets: vi.fn(), -})); - -vi.mock("../../state/registry", async (importOriginal) => ({ - ...(await importOriginal()), - getSandbox: mocks.getSandbox, -})); - -vi.mock("./mcp-bridge-state", async (importOriginal) => ({ - ...(await importOriginal()), - assertMcpDestroyNotPending: mocks.assertMcpDestroyNotPending, -})); - -vi.mock("./rebuild-preflight-confirmation", async (importOriginal) => ({ - ...(await importOriginal()), - confirmRebuildIntent: mocks.confirmRebuildIntent, - countActiveSandboxSessionsForRebuild: mocks.countActiveSessions, - createRebuildCommandContext: vi.fn(() => ({ - bail: mocks.bail, - log: vi.fn(), - requestedToolDisclosure: undefined, - requestedDcodeAutoApprovalMode: undefined, - requestedObservabilityEnabled: undefined, - skipConfirm: true, - })), -})); - -vi.mock("./rebuild-preflight-target-phase", async (importOriginal) => ({ - ...(await importOriginal()), - prepareRebuildTargetPreflights: mocks.prepareTargets, -})); - -import { runRebuildPreflightPhase } from "./rebuild-preflight-phase"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("rebuild baseline transition preflight (#7194)", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getSandbox.mockReturnValue({ - name: "alpha", - baselineExclusionTransition: { - id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", - operation: "restore", - exclusion: { - version: 1, - agent: "openclaw", - key: "agents.openclaw.default", - digest: "a".repeat(64), - }, - startedAt: "2026-07-19T00:00:00.000Z", - targetLiveDigest: "b".repeat(64), - }, - }); - }); - - it("stops before session probes, confirmation, MCP checks, or target preparation", async () => { - await expect(runRebuildPreflightPhase("alpha", ["--yes"])).resolves.toBeNull(); - - expect(mocks.bail).toHaveBeenCalledWith( - "Pending baseline policy restore for 'agents.openclaw.default' blocks rebuild.", - 1, - ); - expect(mocks.countActiveSessions).not.toHaveBeenCalled(); - expect(mocks.assertMcpDestroyNotPending).not.toHaveBeenCalled(); - expect(mocks.confirmRebuildIntent).not.toHaveBeenCalled(); - expect(mocks.prepareTargets).not.toHaveBeenCalled(); - }); -}); - -describe("rebuild MCP destroy marker preflight (#7794)", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getSandbox.mockReturnValue({ - name: "alpha", - agent: "openclaw", - mcp: { - bridges: {}, - destroyPreparedAt: "2026-06-27T01:00:00.000Z", - }, - }); - mocks.assertMcpDestroyNotPending.mockImplementation(() => { - throw new Error("Sandbox 'alpha' has an incomplete MCP destroy transaction"); - }); - }); - - it("prints the safe-abort diagnostic and stops before later rebuild phases", async () => { - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - - await expect(runRebuildPreflightPhase("alpha", ["--yes"])).resolves.toBeNull(); - - const output = error.mock.calls.flat().join("\n"); - expect(output).toContain("Rebuild preflight failed:"); - expect(output).toContain("a pending MCP destroy transaction blocks rebuild."); - expect(output).toContain("Resolve the pending MCP state before retrying rebuild."); - expect(output).toContain("Aborting rebuild"); - expect(output).toContain("sandbox is untouched, no data was lost."); - expect(mocks.bail).toHaveBeenCalledWith( - "Sandbox 'alpha' has an incomplete MCP destroy transaction", - ); - expect(mocks.confirmRebuildIntent).not.toHaveBeenCalled(); - expect(mocks.prepareTargets).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts deleted file mode 100644 index ff8dc935287..00000000000 --- a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; -import { - configureDcodeSession, - makeDcodeSandboxEntry, -} from "../../../../test/helpers/rebuild-dcode-flow-helpers"; -import { - createRebuildFlowHarness, - installRebuildFlowTestHooks, - makePreparedRecoveryManifest, -} from "../../../../test/helpers/rebuild-flow-dcode-harness"; - -describe("rebuildSandbox DCode flow: recovery", () => { - installRebuildFlowTestHooks({ acceptThirdPartySoftware: true }); - - it("recreates non-Ready DCode from a validated backup without requiring a live route (#6195)", async () => { - const recoveryManifest = { - ...makePreparedRecoveryManifest(), - agentType: "langchain-deepagents-code", - agentVersion: "0.1.12", - dir: "/sandbox/.deepagents", - }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - sandboxInventory: { - sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], - }, - preDeleteLatestManifest: recoveryManifest, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { - throwOnError: true, - recoveryManifest, - }), - ).resolves.toBeUndefined(); - - expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.runOpenshellSpy).toHaveBeenCalledWith( - ["sandbox", "delete", "-g", "nemoclaw", "alpha"], - expect.objectContaining({ ignoreError: true }), - ); - expect(harness.onboardSpy).toHaveBeenCalledOnce(); - expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( - "alpha", - recoveryManifest.backupPath, - { targetAgentType: "langchain-deepagents-code" }, - ); - }); - it("replays captured custom policies during stale DCode recovery without a backup (#6195)", async () => { - const customPolicy = { - name: "custom-egress", - content: "network_policies:\n custom-egress: {}\n", - sourcePath: "/tmp/custom-egress.yaml", - }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: { - ...makeDcodeSandboxEntry(), - customPolicies: [customPolicy], - policyPresetsFinalized: true, - }, - sandboxInventory: { sandboxes: [] }, - reconciledSandboxGatewayState: { state: "missing", output: "" }, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.applyPresetSpy).not.toHaveBeenCalled(); - expect(harness.applyPresetContentSpy).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ policies: [], policyPresetsFinalized: true }), - ); - }); - - it("removes transient observability egress after rebuilding a restricted DCode sandbox", async () => { - let policyTierSeenDuringOnboard: string | undefined; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - applyPreset: () => true, - backupPolicyPresets: ["npm", "observability-otlp-local"], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets: ["observability-otlp-local"], - sandboxEntry: { - ...makeDcodeSandboxEntry(), - observabilityEnabled: true, - policies: ["npm", "observability-otlp-local"], - policyPresetsFinalized: true, - policyTier: " Restricted ", - }, - onboard: () => { - policyTierSeenDuringOnboard = process.env.NEMOCLAW_POLICY_TIER; - }, - }); - configureDcodeSession(harness); - harness.session.observabilityEnabled = true; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(policyTierSeenDuringOnboard).toBe("restricted"); - expect(harness.onboardSpy).toHaveBeenCalledWith( - expect.objectContaining({ observabilityRequestedExplicitly: false }), - ); - expect(harness.session.observabilityRequestedExplicitly).toBe(false); - expect(harness.applyPresetSpy).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { - agentVersion: "0.2.0", - policies: ["npm"], - policyTier: "restricted", - policyPresetsFinalized: true, - }); - }); - - it("restores the required observability preset on a balanced DCode rebuild", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - applyPreset: () => true, - backupPolicyPresets: ["npm"], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets: [], - sandboxEntry: { - ...makeDcodeSandboxEntry(), - observabilityEnabled: true, - policies: ["npm"], - policyPresetsFinalized: true, - policyTier: "balanced", - }, - }); - configureDcodeSession(harness); - harness.session.observabilityEnabled = true; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ - policies: ["npm", "observability-otlp-local"], - policyTier: "balanced", - policyPresetsFinalized: true, - }), - ); - }); - - it.each([ - { - label: "enables", - flag: "--observability", - before: false, - expected: true, - expectedObservabilityApplyCalls: [["alpha", "observability-otlp-local"]] as const, - backupPresets: [] as string[], - gatewayPresets: [] as string[], - }, - { - label: "disables", - flag: "--no-observability", - before: true, - expected: false, - expectedObservabilityApplyCalls: [] as const, - backupPresets: ["observability-otlp-local"], - gatewayPresets: ["observability-otlp-local"], - }, - ])("$label observability transactionally while preserving managed MCP state", async ({ - flag, - before, - expected, - expectedObservabilityApplyCalls, - backupPresets, - gatewayPresets, - }) => { - const mcpEntry = { server: "search", providerName: "mcp-search" }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - applyPreset: () => true, - backupPolicyPresets: backupPresets, - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets, - mcpPreparation: { - entries: [mcpEntry], - detachedProviderEntries: [mcpEntry], - scrubbedAdapterEntries: [], - }, - sandboxEntry: { - ...makeDcodeSandboxEntry(), - observabilityEnabled: before, - policies: backupPresets, - policyPresetsFinalized: true, - policyTier: "balanced", - mcp: { - bridges: { search: mcpEntry }, - managedServerNames: ["search"], - }, - }, - }); - configureDcodeSession(harness); - harness.session.observabilityEnabled = before; - - await expect( - harness.rebuildSandbox("alpha", ["--yes", flag], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.onboardSpy).toHaveBeenCalledWith( - expect.objectContaining({ - observabilityEnabled: expected, - observabilityRequestedExplicitly: true, - }), - ); - expect(harness.session.observabilityEnabled).toBe(expected); - expect(harness.session.observabilityRequestedExplicitly).toBe(true); - const observabilityApplyCalls = harness.applyPresetSpy.mock.calls.filter( - ([sandboxName, presetName]) => - sandboxName === "alpha" && presetName === "observability-otlp-local", - ); - expect(observabilityApplyCalls).toEqual(expectedObservabilityApplyCalls); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ - policies: expected ? ["observability-otlp-local"] : [], - policyTier: "balanced", - policyPresetsFinalized: true, - }), - ); - }); - - it("preserves a fresh agent-required preset introduced by inner onboard", async () => { - const freshRequiredPreset = "future-dcode-required"; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - applyPreset: () => true, - backupPolicyPresets: ["npm"], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets: [freshRequiredPreset], - onboard: (session) => { - session.policyPresets = ["npm", freshRequiredPreset]; - }, - sandboxEntry: { - ...makeDcodeSandboxEntry(), - policies: ["npm"], - policyPresetsFinalized: true, - policyTier: "balanced", - }, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ - policies: ["npm", freshRequiredPreset], - policyPresetsFinalized: true, - }), - ); - }); - - it("never removes or persists DCode base-policy keys detected as broad presets", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - backupPolicyPresets: [], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets: ["github", "pypi"], - sandboxEntry: { - ...makeDcodeSandboxEntry(), - observabilityEnabled: false, - policies: [], - policyPresetsFinalized: true, - policyTier: "balanced", - }, - }); - configureDcodeSession(harness); - harness.session.observabilityEnabled = false; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.removePresetSpy).not.toHaveBeenCalled(); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ policies: [], policyPresetsFinalized: true }), - ); - }); - - it("does not narrow a differently named custom policy owning observability egress", async () => { - const customPolicy = { - name: "corp-otel", - content: - "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", - sourcePath: "/tmp/corp-otel.yaml", - }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - backupPolicyPresets: [], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets: ["observability-otlp-local"], - sandboxEntry: { - ...makeDcodeSandboxEntry(), - customPolicies: [customPolicy], - observabilityEnabled: false, - policies: [], - policyPresetsFinalized: true, - policyTier: "balanced", - }, - }); - configureDcodeSession(harness); - harness.session.observabilityEnabled = false; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.applyPresetContentSpy).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(harness.removePresetSpy).not.toHaveBeenCalled(); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ policies: [], policyPresetsFinalized: true }), - ); - }); - - it("fails after recording recovery state when restricted egress removal cannot be verified", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - applyPreset: () => true, - backupPolicyPresets: ["npm", "observability-otlp-local"], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], - gatewayPresets: ["observability-otlp-local"], - verificationUnavailableAfterPresetRemoval: true, - sandboxEntry: { - ...makeDcodeSandboxEntry(), - observabilityEnabled: true, - policies: ["npm", "observability-otlp-local"], - policyPresetsFinalized: true, - policyTier: "restricted", - }, - }); - configureDcodeSession(harness); - harness.session.observabilityEnabled = true; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Rebuild completed with unverified live policy reconciliation for 'alpha'."); - - expect(harness.registryUpdateSpy).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ - policies: ["npm", "observability-otlp-local"], - policyTier: "restricted", - policyPresetsFinalized: undefined, - }), - ); - expect(harness.relockSpy).toHaveBeenCalled(); - }); - - it("rejects an observability override for a non-DCode sandbox before mutation", async () => { - const harness = createRebuildFlowHarness({ - agentName: "openclaw", - sandboxEntry: { - name: "alpha", - agent: "openclaw", - nemoclawVersion: "0.1.0", - }, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes", "--observability"], { throwOnError: true }), - ).rejects.toThrow("Unsupported rebuild observability override"); - - expect(harness.openShieldsSpy).not.toHaveBeenCalled(); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts index 7407778b1c2..e9ea11090dc 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -136,46 +136,6 @@ describe("rebuild destroy phase", () => { vi.restoreAllMocks(); }); - it("blocks rebuild before MCP or sandbox mutation when baseline repair is pending (#7178)", async () => { - const bail = vi.fn((message: string): never => { - throw new Error(message); - }); - - await expect( - runRebuildDestroyPhase({ - sandboxName: "alpha", - sandboxEntry: { - name: "alpha", - baselineExclusionTransition: { - id: "tx-1", - operation: "restore", - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "approved-digest", - }, - targetLiveDigest: "current-digest", - startedAt: "2026-07-19T00:00:00.000Z", - }, - }, - staleRecovery: false, - recreateJournal: stubRecreateJournal(), - backupManifest: null, - log: vi.fn(), - bail, - relockShieldsIfNeeded: vi.fn(() => true), - onDeleted: vi.fn(), - }), - ).rejects.toThrow("Pending baseline policy restore"); - - expect(mocks.prepareMcpForRebuild).not.toHaveBeenCalled(); - expect(bail).toHaveBeenCalledWith( - "Pending baseline policy restore for 'nous_research' blocks rebuild.", - 1, - ); - }); - it("retains unexpected delete-edge diagnostics without logging credentials (#6195)", async () => { const secret = `nvapi-${"a".repeat(32)}`; const log = vi.fn(); @@ -319,54 +279,57 @@ describe("rebuild destroy phase", () => { gatewayPort: 29080, }, ], - ])("refuses deletion when the registry %s changes before MCP preparation (#7062)", async (_label, currentEntry) => { - mocks.getSandbox.mockReturnValue(currentEntry); - mocks.prepareMcpForRebuild.mockResolvedValue({ - entries: [{ server: "github" }], - detachedProviderEntries: [{ server: "github" }], - scrubbedAdapterEntries: [], - }); - const relockShieldsIfNeeded = vi.fn(() => true); - - await expect( - runRebuildDestroyPhase({ - sandboxName: "alpha", - sandboxEntry: { - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - gatewayPort: 8080, - }, - staleRecovery: false, - recreateJournal: stubRecreateJournal(), - backupManifest: null, - force: true, - log: vi.fn(), - bail: vi.fn((message: string): never => { - throw new Error(message); + ])( + "refuses deletion when the registry %s changes before MCP preparation (#7062)", + async (_label, currentEntry) => { + mocks.getSandbox.mockReturnValue(currentEntry); + mocks.prepareMcpForRebuild.mockResolvedValue({ + entries: [{ server: "github" }], + detachedProviderEntries: [{ server: "github" }], + scrubbedAdapterEntries: [], + }); + const relockShieldsIfNeeded = vi.fn(() => true); + + await expect( + runRebuildDestroyPhase({ + sandboxName: "alpha", + sandboxEntry: { + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + gatewayPort: 8080, + }, + staleRecovery: false, + recreateJournal: stubRecreateJournal(), + backupManifest: null, + force: true, + log: vi.fn(), + bail: vi.fn((message: string): never => { + throw new Error(message); + }), + relockShieldsIfNeeded, + onDeleted: vi.fn(), }), - relockShieldsIfNeeded, - onDeleted: vi.fn(), - }), - ).rejects.toThrow("Sandbox delete target changed during rebuild preparation."); - - expect(mocks.getSandbox).toHaveBeenCalledTimes(2); - expect(mocks.prepareMcpForRebuild.mock.invocationCallOrder[0]).toBeLessThan( - mocks.getSandbox.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY, - ); - expect(mocks.runOpenshell).not.toHaveBeenCalled(); - expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith( - "alpha", - [{ server: "github" }], - [], - ); - expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); - expect(mocks.stopNimContainer).not.toHaveBeenCalled(); - expect(mocks.stopNimContainerByName).not.toHaveBeenCalled(); - expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); - }); + ).rejects.toThrow("Sandbox delete target changed during rebuild preparation."); + + expect(mocks.getSandbox).toHaveBeenCalledTimes(2); + expect(mocks.prepareMcpForRebuild.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getSandbox.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY, + ); + expect(mocks.runOpenshell).not.toHaveBeenCalled(); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith( + "alpha", + [{ server: "github" }], + [], + ); + expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); + expect(mocks.stopNimContainer).not.toHaveBeenCalled(); + expect(mocks.stopNimContainerByName).not.toHaveBeenCalled(); + expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); + }, + ); - it("refuses sandbox deletion when read-only MCP state drifts at the delete edge (#7062)", async () => { + it("refuses sandbox deletion and invokes recovery when MCP state drifts at the delete edge", async () => { const revalidateBeforeDelete = vi.fn().mockRejectedValue(new Error("live policy drifted")); mocks.prepareMcpForRebuild.mockResolvedValue({ entries: [{}], @@ -393,13 +356,13 @@ describe("rebuild destroy phase", () => { onDeleted: vi.fn(), }), ).rejects.toThrow( - "Failed to revalidate read-only MCP recovery before sandbox deletion: live policy drifted", + "Failed to revalidate MCP recovery before sandbox deletion: live policy drifted", ); expect(revalidateBeforeDelete).toHaveBeenCalledOnce(); expect(mocks.runOpenshell).not.toHaveBeenCalled(); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); - expect(mocks.reattachMcpAfterDeleteFailure).not.toHaveBeenCalled(); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledWith("alpha", [], []); expect(mocks.stopNimContainer).not.toHaveBeenCalled(); expect(mocks.stopNimContainerByName).not.toHaveBeenCalled(); expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index 9688c4cfa82..59af7b62240 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -22,7 +22,6 @@ import { prepareMcpForRebuild, reattachMcpAfterDeleteFailure, } from "./rebuild-mcp-phase"; -import { blockRebuildOnPendingBaselineTransition } from "./rebuild-preflight-guards"; import type { RebuildRecreateJournal, RebuildRecreateSourcePresence, @@ -42,7 +41,9 @@ export interface RebuildDestroyPhaseInput { bail: RebuildBail; relockShieldsIfNeeded: (sandboxStillExists: boolean) => boolean; force?: boolean; - validateAfterMcpPreparation?: () => Promise; + validateAfterMcpPreparation?: ( + preparation: McpRebuildPreparation, + ) => Promise; validateAtDeleteEdge?: () => RebuildDeleteValidationResult; onDeleted: () => void; onDeleteStateAmbiguous?: () => void; @@ -241,8 +242,6 @@ export async function runRebuildDestroyPhase( const deleteTarget = resolveRebuildDeleteTarget(sandboxName, input.sandboxEntry); const { gatewayName } = deleteTarget; - if (blockRebuildOnPendingBaselineTransition(input.sandboxEntry, sandboxName, bail)) return null; - // Step 3: Delete sandbox without tearing down gateway or session. // sandboxDestroy() cleans up the gateway when it's the last sandbox and // nulls session.sandboxName — both break the immediate onboard --resume. @@ -288,7 +287,7 @@ export async function runRebuildDestroyPhase( if (validateAfterMcpPreparation) { let validation: RebuildDeleteValidationResult; try { - validation = await validateAfterMcpPreparation(); + validation = await validateAfterMcpPreparation(preparation); } catch (error) { const detail = error instanceof Error ? error.message : String(error); log(`Unexpected DCode replacement validation failure: ${redactFull(detail)}`); @@ -335,10 +334,17 @@ export async function runRebuildDestroyPhase( await mcpPreparation.revalidateBeforeDelete?.(); mcpPreparation.assertDeleteEdgeUnchanged?.(); } catch (error) { + const mcpRecoveryFailure = await reattachMcpAfterDeleteFailure( + sandboxName, + rebuildDetachedMcpProviderEntries, + rebuildScrubbedMcpAdapterEntries, + ); relockShieldsIfNeeded(true); const detail = error instanceof Error ? error.message : String(error); bail( - `Failed to revalidate read-only MCP recovery before sandbox deletion: ${redactFull(detail)}`, + mcpRecoveryFailure + ? `Failed to revalidate MCP recovery before sandbox deletion: ${redactFull(detail)} MCP provider recovery also failed: ${mcpRecoveryFailure}` + : `Failed to revalidate MCP recovery before sandbox deletion: ${redactFull(detail)}`, ); return null; } diff --git a/src/lib/actions/sandbox/rebuild-durable-config.test.ts b/src/lib/actions/sandbox/rebuild-durable-config.test.ts index cf61fca72f0..fc9387c0df6 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.test.ts @@ -108,23 +108,11 @@ describe("resolveRebuildDurableConfig", () => { expect(config.toolDisclosureError).toBeNull(); }); - it("uses a legacy built-in Brave policy for a nonmatching session", () => { - const session = createSession({ sandboxName: "other", webSearchConfig: null }); - const config = resolveRebuildDurableConfig( - "alpha", - { name: "alpha", policies: ["brave"], nemoclawVersion: "0.1.0" }, - session, - ); - expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "brave" }); - }); - it("does not mistake a legacy custom policy named brave for web search", () => { const config = resolveRebuildDurableConfig( "alpha", { name: "alpha", - policies: ["brave"], - customPolicies: [{ name: "brave", content: "allow: []" }], nemoclawVersion: "0.1.0", }, createSession({ sandboxName: "other" }), @@ -137,7 +125,6 @@ describe("resolveRebuildDurableConfig", () => { "alpha", { name: "alpha", - policies: ["brave"], webSearchEnabled: false, fromDockerfile: null, }, @@ -233,37 +220,6 @@ describe("resolveRebuildDurableConfig", () => { expect(config.webSearchError).toBeNull(); }); - it("recovers provider-less Tavily for an explicitly enabled DCode selection", () => { - const config = resolveRebuildDurableConfig( - "alpha", - { - name: "alpha", - agent: "langchain-deepagents-code", - policies: ["tavily"], - webSearchEnabled: true, - nemoclawVersion: "0.1.0", - }, - createSession({ sandboxName: "other", webSearchConfig: null }), - ); - expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); - expect(config.webSearchError).toBeNull(); - }); - - it.each([null, "hermes"])('migrates a provider-less Tavily policy for agent "%s"', (agent) => { - const config = resolveRebuildDurableConfig( - "alpha", - { - name: "alpha", - agent, - policies: ["tavily"], - nemoclawVersion: "0.1.0", - }, - createSession({ sandboxName: "other", webSearchConfig: null }), - ); - expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); - expect(config.webSearchError).toBeNull(); - }); - it("backfills a legacy enabled provider from the matching Tavily session", () => { const config = resolveRebuildDurableConfig( "alpha", @@ -290,7 +246,6 @@ describe("resolveRebuildDurableConfig", () => { { name: "alpha", agent: "langchain-deepagents-code", - policies: ["tavily"], nemoclawVersion: "0.1.0", }, createSession({ sandboxName: "other" }), @@ -303,28 +258,11 @@ describe("resolveRebuildDurableConfig", () => { "alpha", { name: "alpha", - policies: ["tavily"], - customPolicies: [{ name: "tavily", content: "allow: []" }], - nemoclawVersion: "0.1.0", - }, - createSession({ sandboxName: "other", webSearchConfig: null }), - ); - expect(config.webSearchConfig).toBeNull(); - }); - - it("fails closed when provider-less durable policies select both web-search providers", () => { - const config = resolveRebuildDurableConfig( - "alpha", - { - name: "alpha", - policies: ["brave", "tavily"], - webSearchEnabled: true, nemoclawVersion: "0.1.0", }, createSession({ sandboxName: "other", webSearchConfig: null }), ); expect(config.webSearchConfig).toBeNull(); - expect(config.webSearchError).toContain("more than one provider"); }); it("lets an explicit provider resolve stale dual-policy state", () => { @@ -332,7 +270,6 @@ describe("resolveRebuildDurableConfig", () => { "alpha", { name: "alpha", - policies: ["brave", "tavily"], webSearchEnabled: true, webSearchProvider: "tavily", nemoclawVersion: "0.1.0", @@ -343,39 +280,6 @@ describe("resolveRebuildDurableConfig", () => { expect(config.webSearchError).toBeNull(); }); - it("uses the unshadowed provider when the other policy name is custom", () => { - const config = resolveRebuildDurableConfig( - "alpha", - { - name: "alpha", - policies: ["brave", "tavily"], - customPolicies: [{ name: "brave", content: "allow: []" }], - webSearchEnabled: true, - nemoclawVersion: "0.1.0", - }, - createSession({ sandboxName: "other", webSearchConfig: null }), - ); - expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); - expect(config.webSearchError).toBeNull(); - }); - - it("fails closed when the managed provider is shadowed by a custom same-name policy", () => { - const config = resolveRebuildDurableConfig( - "alpha", - { - name: "alpha", - policies: ["tavily"], - customPolicies: [{ name: "tavily", content: "allow: []" }], - webSearchEnabled: true, - webSearchProvider: "tavily", - nemoclawVersion: "0.1.0", - }, - createSession({ sandboxName: "other", webSearchConfig: null }), - ); - expect(config.webSearchConfig).toBeNull(); - expect(config.webSearchError).toContain("conflicts with a custom same-name policy"); - }); - it("fails closed for an invalid durable web-search provider", () => { const config = resolveRebuildDurableConfig( "alpha", diff --git a/src/lib/actions/sandbox/rebuild-durable-config.ts b/src/lib/actions/sandbox/rebuild-durable-config.ts index d42e0cc4c3c..9e532fa29c1 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.ts @@ -118,10 +118,7 @@ function normalizeHermesAuthMethod(value: unknown): "oauth" | "api_key" | null { } function builtinWebSearchPolicyProviders(entry: RebuildSandboxEntry): WebSearchProvider[] { - const customPolicyNames = new Set(entry.customPolicies?.map((policy) => policy.name) ?? []); - return (["brave", "tavily"] as const).filter( - (provider) => entry.policies?.includes(provider) === true && !customPolicyNames.has(provider), - ); + return (["brave", "tavily"] as const).filter((provider) => entry.webSearchProvider === provider); } export function resolveRebuildDurableConfig( @@ -142,7 +139,6 @@ export function resolveRebuildDurableConfig( (!resolvedSelection.model || session.model === resolvedSelection.model) ? session : null; - const customPolicyNames = new Set(entry.customPolicies?.map((policy) => policy.name) ?? []); const policyProviders = builtinWebSearchPolicyProviders(entry); const migrationPolicyProviders = entry.webSearchEnabled === true || entry.agent !== DCODE_AGENT_NAME @@ -188,10 +184,6 @@ export function resolveRebuildDurableConfig( sessionWebSearchProvider ?? migrationPolicyProviders[0] ?? "brave"; - if (customPolicyNames.has(webSearchProvider)) { - webSearchError = `managed web-search provider '${webSearchProvider}' conflicts with a custom same-name policy`; - webSearchProvider = null; - } } const recordedToolDisclosure = entry.toolDisclosure !== undefined && entry.toolDisclosure !== null diff --git a/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts b/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts index c0f907c1711..2f140a22a54 100644 --- a/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts @@ -214,7 +214,7 @@ describe("rebuildSandbox flow: credential preflight", () => { hydrateCredentialEnv: () => "host-provider-key", runOpenshell: (args) => args[0] === "provider" ? (providerLookups.shift() ?? registeredProvider)(args) : undefined, - staleRecovery: true, + staleRecovery: false, }); configureSession(harness, "compatible-endpoint", "COMPATIBLE_API_KEY", { endpointUrl: "https://inference.example.test/v1", @@ -247,7 +247,7 @@ describe("rebuildSandbox flow: credential preflight", () => { return credentialHydrations < 3 ? "host-provider-key" : null; }, runOpenshell: providerRuntime([]), - staleRecovery: true, + staleRecovery: false, }); configureSession(harness, "compatible-endpoint", "COMPATIBLE_API_KEY", { endpointUrl: "https://inference.example.test/v1", @@ -287,7 +287,7 @@ describe("rebuildSandbox flow: credential preflight", () => { args[0] === "provider" ? (providerLookups.shift() ?? indeterminateProvider)(args) : undefined, - staleRecovery: true, + staleRecovery: false, }); configureSession(harness, "compatible-endpoint", "COMPATIBLE_API_KEY", { endpointUrl: "https://inference.example.test/v1", @@ -392,25 +392,25 @@ describe("rebuildSandbox flow: credential preflight", () => { expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); }); - it.each([ - "ollama-local", - "vllm-local", - ])("migrates a legacy %s target away from OPENAI_API_KEY (#2519)", async (provider) => { - const harness = createRebuildFlowHarness({ - sandboxEntry: { provider, model: MODEL, credentialEnv: "OPENAI_API_KEY" }, - }); - configureSession(harness, provider, "OPENAI_API_KEY"); + it.each(["ollama-local", "vllm-local"])( + "migrates a legacy %s target away from OPENAI_API_KEY (#2519)", + async (provider) => { + const harness = createRebuildFlowHarness({ + sandboxEntry: { provider, model: MODEL, credentialEnv: "OPENAI_API_KEY" }, + }); + configureSession(harness, provider, "OPENAI_API_KEY"); - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); - const output = harness.logSpy.mock.calls.flat().map(String).join("\n"); - expect(output).toContain("GH #2519"); - expect(output).toContain(provider); - expect(harness.session.credentialEnv).toBeNull(); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); - }); + const output = harness.logSpy.mock.calls.flat().map(String).join("\n"); + expect(output).toContain("GH #2519"); + expect(output).toContain(provider); + expect(harness.session.credentialEnv).toBeNull(); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); + }, + ); it("fails closed when a matching session omits the remote target credential", async () => { const harness = createRebuildFlowHarness({ diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts index 27ba7f67f1a..e092330e06c 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.test.ts @@ -44,8 +44,6 @@ function makeBackupResult(): ReturnType dir: "/sandbox/.deepagents", backupPath: "/tmp/nemoclaw-rebuild-backup", blueprintDigest: null, - policyPresets: [], - customPolicies: [], } as ReturnType["manifest"], }; } @@ -56,8 +54,6 @@ function makeSandboxEntry(): Parameters[1] agent: "langchain-deepagents-code", provider: null, model: null, - policies: [], - customPolicies: [], nimContainer: null, } satisfies Parameters[1]; } diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index 877874b8baa..f05a230ff0d 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -25,7 +25,6 @@ import { recoverNamedGatewayRuntime, } from "../../gateway-runtime-action"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; -import { removeStaleRebuildDockerOrphan } from "../../onboard/openshell-docker-sandbox-containers"; import { captureSandboxListWithGatewayRecovery, printSandboxListFailureWithRecoveryContext, @@ -55,6 +54,11 @@ export type RebuildLiveState = { staleRegistrySnapshot: ReturnType | null; }; +export type RebuildLiveStateOptions = { + /** A digest-verified policy handoff bound to the prepared recovery manifest. */ + authoritativeRecoveryPolicyAvailable?: boolean; +}; + export type RebuildAgentBaseImageOptions = { resolutionHint?: SandboxBaseImageResolutionMetadata | null; forceBaseImageRefresh?: boolean; @@ -154,6 +158,7 @@ export async function resolveRebuildLiveState( sb: RebuildSandboxEntry, log: (msg: string) => void, bail: (msg: string, code?: number) => never, + options: RebuildLiveStateOptions = {}, ): Promise { const recordedGateway = resolveSandboxGatewayName(sb); log(`Checking sandbox liveness on ${recordedGateway}: openshell sandbox list`); @@ -202,34 +207,27 @@ export async function resolveRebuildLiveState( } if (reconciled.state === "missing") { - // Source boundary: the local registry is the durable NemoClaw intent record, - // while OpenShell owns live sandbox presence. A missing live sandbox on a - // healthy named gateway can come from external deletion or failed prior - // provisioning, so rebuild recovers from registry metadata instead of - // treating the preserved local entry as corrupt. Keep until OpenShell exposes - // an atomic recreate-from-registry recovery API. - try { - removeStaleRebuildDockerOrphan(sandboxName, sb.openshellDriver, log); - } catch (error) { - bail( - `Stale-recovery Docker orphan cleanup failed: ${error instanceof Error ? error.message : String(error)}.`, + if (options.authoritativeRecoveryPolicyAvailable === true) { + log( + "Stale-sandbox recovery: the sandbox is absent, but its transaction-bound policy handoff is intact", ); - return null; + return { staleRecovery: true, staleRegistrySnapshot: loadRegistry() }; } console.log(""); - console.log( + console.error( ` ${YW}⚠${R} Sandbox '${sandboxName}' is registered locally but absent from the live OpenShell gateway.`, ); - console.log( - " No live workspace state to back up — recreating from the preserved registry metadata.", + console.error( + " Rebuild cannot recover its missing OpenShell policy or live workspace from NemoClaw registry metadata.", ); - log( - "Stale-sandbox recovery: live sandbox missing on healthy named gateway; skipping backup/restore and recreating from registry metadata", + console.error(" To create a clean replacement:"); + console.error(` 1. ${CLI_NAME} ${sandboxName} destroy --yes`); + console.error(` 2. ${CLI_NAME} onboard`); + console.error( + " The missing sandbox's state cannot be recovered unless you have a separate snapshot to restore after onboarding.", ); - return { - staleRecovery: true, - staleRegistrySnapshot: JSON.parse(JSON.stringify(loadRegistry())), - }; + bail("Cannot rebuild an absent sandbox without its authoritative OpenShell policy."); + return null; } if (reconciled.state === "gateway_schema_mismatch") { diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 2a2b5bb7378..3e1a3b4719b 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -1,14 +1,20 @@ // 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, vi } from "vitest"; import { expectNoSandboxDelete } from "../../../../test/helpers/rebuild-delete-assertions"; import { createRebuildFlowHarness, + createHarnessTempDir, installRebuildFlowTestHooks, originalSandboxName, + policyGet, portableAgentLifecycle, snapshotEnv, + tempFiles, } from "../../../../test/helpers/rebuild-flow-generic-harness"; import { makePreparedRecoveryManifest } from "./rebuild-flow-test-fixtures"; @@ -50,13 +56,15 @@ describe("rebuildSandbox flow: lifecycle", () => { expectNoSandboxDelete(harness.runOpenshellSpy); }); - it("backs up once, recreates, restores, reapplies policy, and relocks on a successful OpenClaw rebuild", async ({ + it("backs up once, recreates with the captured OpenShell policy, restores, and relocks on a successful OpenClaw rebuild", async ({ onTestFinished, }) => { const restoreEnv = snapshotEnv(["NEMOCLAW_RECREATE_WITHOUT_BACKUP"]); onTestFinished(restoreEnv); process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP = "0"; let innerBackupMarker: string | undefined; + let recreatedPolicy: string | undefined; + let rebuildPolicySourcePath: string | undefined; const mcpEntry = { server: "github", url: "https://mcp.example.test/mcp", @@ -67,22 +75,31 @@ describe("rebuildSandbox flow: lifecycle", () => { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", }; + const completePolicy = [ + "version: 1", + "network_policies:", + " durable_user_policy: {}", + " mcp_bridge_github:", + " endpoints:", + " - credential_binding:", + " provider: nemoclaw-mcp-alpha-github", + "", + ].join("\n"); const harness = createRebuildFlowHarness({ applyPreset: () => true, - backupPolicyPresets: ["npm", "bad", "throw", "mcp-bridge-github"], - sandboxEntry: { - policies: ["npm", "mcp-bridge-github"], - policyPresetsFinalized: true, - policyTier: "balanced", - }, + sandboxEntry: {}, mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], + policyHandoff: completePolicy, }, - onboard: () => { + onboard: (_session, options) => { innerBackupMarker = process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP; + rebuildPolicySourcePath = String(options.rebuildPolicySourcePath); + recreatedPolicy = fs.readFileSync(rebuildPolicySourcePath, "utf8"); }, }); + vi.mocked(policyGet.getSandboxPolicy).mockReset().mockReturnValue({ yaml: completePolicy }); await expect( harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), @@ -107,11 +124,17 @@ describe("rebuildSandbox flow: lifecycle", () => { nonInteractive: true, recreateSandbox: true, authoritativeResumeConfig: true, - rebuildPolicyPresets: ["npm", "bad", "throw"], autoYes: true, }), ); expect(innerBackupMarker).toBe("1"); + expect(policyGet.getSandboxPolicy).toHaveBeenCalledOnce(); + expect(recreatedPolicy).toContain("durable_user_policy"); + expect(recreatedPolicy).toContain("mcp_bridge_github"); + expect(recreatedPolicy).toContain("nemoclaw-mcp-alpha-github"); + expect(rebuildPolicySourcePath).toBeDefined(); + expect(fs.existsSync(rebuildPolicySourcePath!)).toBe(false); + expect(fs.existsSync(path.dirname(rebuildPolicySourcePath!))).toBe(true); expect(process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP).toBe("0"); expect(harness.registryUpdateSpy).toHaveBeenCalledWith( "alpha", @@ -129,29 +152,23 @@ describe("rebuildSandbox flow: lifecycle", () => { expect(harness.registryUpdateSpy.mock.invocationCallOrder[0]).toBeLessThan( harness.runOpenshellSpy.mock.invocationCallOrder[deleteCall], ); - expect(harness.session.policyPresets).toEqual(["npm", "bad", "throw"]); expect(harness.session.steps.gateway.status).toBe("complete"); expect(harness.session.steps.preflight.status).toBe("complete"); expect(harness.session.steps.sandbox.status).toBe("pending"); - expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( - "alpha", - "/tmp/nemoclaw-rebuild-backup", - { targetAgentType: "openclaw" }, - ); + expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith("alpha", harness.backupPath, { + targetAgentType: "openclaw", + }); expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); expect(harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( "Preserving journaled source registry entry across sandbox recreation", ); - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "npm"); - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "bad"); - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "throw"); - expect(harness.applyPresetSpy).not.toHaveBeenCalledWith("alpha", "mcp-bridge-github"); + expect(harness.applyPresetSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).toHaveBeenCalledWith( + expect.objectContaining({ rebuildPolicySourcePath: expect.any(String) }), + ); expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { agentVersion: "0.2.0", - policies: ["npm", "bad", "throw"], - policyTier: "balanced", - policyPresetsFinalized: true, }); expect(harness.executeSandboxExecCommandSpy).toHaveBeenCalledWith( "alpha", @@ -162,132 +179,60 @@ describe("rebuildSandbox flow: lifecycle", () => { expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); expect(process.env.NEMOCLAW_SANDBOX_NAME).toBe(originalSandboxName); expect(harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( - "rebuilt successfully", + "rebuild completed", ); }); - it("keeps the original sandbox when the shared route drifts at the delete edge (#7798)", async () => { + it("keeps the original sandbox when the post-MCP OpenShell policy is unavailable", async () => { + const policyDirectory = createHarnessTempDir("nemoclaw-rebuild-policy-cleanup-"); + vi.spyOn(tempFiles, "secureTempFile").mockReturnValue( + path.join(policyDirectory, "policy.yaml"), + ); + const mcpEntry = { + server: "github", + providerName: "nemoclaw-mcp-alpha-github", + }; const harness = createRebuildFlowHarness({ - revalidateRebuildRouteBeforeDelete: () => ({ - ok: false, - message: "Shared inference route changed before sandbox deletion.", - }), + mcpPreparation: { + entries: [mcpEntry], + detachedProviderEntries: [mcpEntry], + }, }); + vi.mocked(policyGet.getSandboxPolicy) + .mockReset() + .mockReturnValueOnce({ yaml: "version: 1\nnetwork_policies: {}\n" }) + .mockReturnValue({ yaml: "" }); await expect( harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Shared inference route changed before sandbox deletion."); + ).rejects.toThrow("OpenShell policy became unavailable before sandbox deletion"); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledOnce(); expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).toHaveBeenCalledOnce(); expect(harness.onboardSpy).not.toHaveBeenCalled(); expectNoSandboxDelete(harness.runOpenshellSpy); + expect(fs.existsSync(policyDirectory)).toBe(false); }); - it("keeps baseline exclusions durable through successful replacement onboarding (#7194)", async () => { - const harness = createRebuildFlowHarness({ - sandboxEntry: { - baselineExclusions: [ - { - version: 1, - agent: "openclaw", - key: "openclaw_docs", - digest: "baseline-digest", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: "2026.6.10", - }, - ], - }, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha"); - expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).toHaveBeenCalledOnce(); - expect(harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( - "Preserving journaled source registry entry across sandbox recreation", - ); - expect(harness.restoreSandboxEntrySpy).not.toHaveBeenCalled(); - expect(harness.restoreSandboxEntryIfMissingSpy).not.toHaveBeenCalled(); - }); - - it("rejects a schema-invalid recorded-agent baseline before registry or live sandbox mutation (#7194)", async () => { + it("keeps the original sandbox when the shared route drifts at the delete edge (#7798)", async () => { const harness = createRebuildFlowHarness({ - agentPolicyAdditionsContent: ` -version: 1 -network_policies: - unsafe_entry: - name: unsafe_entry - endpoints: - - host: api.example.test - port: 443 - access: full -`, - preflightWithProductionBaselineResolver: true, - sandboxEntry: { - agent: "hermes", - baselineExclusions: [ - { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "baseline-digest", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ], - }, + revalidateRebuildRouteBeforeDelete: () => ({ + ok: false, + message: "Shared inference route changed before sandbox deletion.", + }), }); await expect( - harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), - ).rejects.toThrow("Replacement onboarding preflight failed"); + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Shared inference route changed before sandbox deletion."); - expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( - "does not satisfy the shipped sandbox policy schema", - ); - expect(harness.registryUpdateSpy).not.toHaveBeenCalled(); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.prepareMcpBridgesForRebuildSpy).not.toHaveBeenCalled(); - expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledOnce(); + expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).toHaveBeenCalledOnce(); expect(harness.onboardSpy).not.toHaveBeenCalled(); expectNoSandboxDelete(harness.runOpenshellSpy); }); - it("keeps baseline-exclusion retry metadata when inner replacement creation fails (#7194)", async () => { - const harness = createRebuildFlowHarness({ - sandboxEntry: { - baselineExclusions: [ - { - version: 1, - agent: "openclaw", - key: "openclaw_docs", - digest: "baseline-digest", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: "2026.6.10", - }, - ], - }, - onboard: () => { - throw new Error("injected replacement create failure"); - }, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), - ).rejects.toThrow("Recreate failed"); - - expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); - expect(harness.restoreSandboxEntrySpy).not.toHaveBeenCalled(); - expect(harness.restoreSandboxEntryIfMissingSpy).not.toHaveBeenCalled(); - expect(harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( - "Preserving journaled source registry entry across sandbox recreation", - ); - }); - it("waits for post-delete sandbox absence before inner onboarding (#7194)", async () => { const events: string[] = []; let sandboxGetAttempts = 0; @@ -451,86 +396,6 @@ network_policies: ); }); - it("relocks the recreated sandbox when recovery artifact cleanup fails (#9833)", async () => { - const recoveryArtifactPath = "/tmp/shields-external-policy-alpha.yaml"; - const harness = createRebuildFlowHarness({ - staleRecovery: true, - clearShieldsState: () => { - throw new Error( - `Could not remove external Shields policy recovery artifact '${recoveryArtifactPath}': permission denied`, - ); - }, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow( - `Could not remove external Shields policy recovery artifact '${recoveryArtifactPath}': permission denied`, - ); - - expect(harness.onboardSpy).toHaveBeenCalledOnce(); - expect(harness.relockSpy).toHaveBeenLastCalledWith( - "alpha", - expect.any(Object), - true, - "nemoclaw", - ); - }); - - it("uses the no-exec MCP preparation path when recovering an absent sandbox", async () => { - const overrideEnvVar = "NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF"; - const restoreEnv = snapshotEnv([overrideEnvVar]); - const disposeImageRef = vi.fn(() => true); - process.env[overrideEnvVar] = "nemoclaw-hermes-sandbox-base-local:image-caller"; - const mcpEntry = { - server: "github", - agent: "openclaw", - adapter: "mcporter", - url: "https://mcp.example.test/mcp", - env: ["GITHUB_TOKEN"], - providerName: "alpha-mcp-github", - policyName: "mcp-bridge-github", - addedAt: "2026-06-01T00:00:00.000Z", - }; - try { - const harness = createRebuildFlowHarness({ - staleRecovery: true, - sandboxEntry: { mcp: { bridges: { github: mcpEntry } } }, - baseImagePreflight: { - ok: true, - imageRef: "nemoclaw-hermes-sandbox-base-local:image-preflighted", - overrideEnvVar, - disposeImageRef, - }, - mcpPreparation: { - entries: [mcpEntry], - detachedProviderEntries: [], - scrubbedAdapterEntries: [], - }, - onboard: () => { - expect(process.env[overrideEnvVar]).toBe( - "nemoclaw-hermes-sandbox-base-local:image-preflighted", - ); - }, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(process.env[overrideEnvVar]).toBe("nemoclaw-hermes-sandbox-base-local:image-caller"); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.prepareMcpBridgesForAbsentSandboxRebuildSpy).toHaveBeenCalledWith("alpha"); - expect(harness.prepareMcpBridgesForRebuildSpy).not.toHaveBeenCalled(); - expect(harness.warnUnpreservedUserManagedFilesSpy).not.toHaveBeenCalled(); - expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).not.toHaveBeenCalled(); - expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); - expect(disposeImageRef).toHaveBeenCalledOnce(); - } finally { - restoreEnv(); - } - }); - it("disposes the base-image handoff when live-state preflight fails (#7144)", async () => { const disposeImageRef = vi.fn(() => true); const harness = createRebuildFlowHarness({ @@ -616,75 +481,4 @@ network_policies: restoreEnv(); } }); - - it("restores enabled messaging presets while pruning disabled ones from final policies", async () => { - const disabledSlackPlan = { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "rebuild", - channels: [ - { channelId: "telegram", disabled: false }, - { channelId: "discord", disabled: false }, - { channelId: "whatsapp", disabled: false }, - { channelId: "wechat", disabled: false }, - { channelId: "slack", disabled: true }, - ], - disabledChannels: ["slack"], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; - const harness = createRebuildFlowHarness({ - applyPreset: () => true, - backupPolicyPresets: ["slack", "npm", "pypi", "telegram"], - buildMessagingRebuildPlan: () => disabledSlackPlan, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.applyPresetSpy.mock.calls.map((call) => call[1])).toEqual([ - "npm", - "pypi", - "telegram", - "discord", - "whatsapp", - "wechat", - ]); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { - agentVersion: "0.2.0", - policies: ["npm", "pypi", "telegram", "discord", "whatsapp", "wechat"], - policyTier: null, - policyPresetsFinalized: undefined, - }); - }); - - it("preserves a finalized empty policy selection and its tier", async () => { - const harness = createRebuildFlowHarness({ - applyPreset: () => true, - backupPolicyPresets: [], - sandboxEntry: { - policies: [], - policyPresetsFinalized: true, - policyTier: "restricted", - }, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.session.policyPresets).toEqual([]); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { - agentVersion: "0.2.0", - policies: [], - policyTier: "restricted", - policyPresetsFinalized: true, - }); - }); }); diff --git a/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts b/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts index 9fadcf83056..e6aff0e03f9 100644 --- a/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts @@ -1,6 +1,7 @@ // 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"; @@ -8,6 +9,7 @@ import { expectNoSandboxDelete } from "../../../../test/helpers/rebuild-delete-a import { createRebuildFlowHarness, installRebuildFlowTestHooks, + policyGet, } from "../../../../test/helpers/rebuild-flow-generic-harness"; import { fingerprintSandboxLiveIdentity } from "../../onboard/sandbox-recreate-transaction"; import { @@ -227,6 +229,56 @@ describe("rebuildSandbox flow: recovery", () => { return interrupted.session.checkpoint; } + it("retains the exact policy handoff across a failed recreate and consumes it on retry", async () => { + const policyDocument = "version: 1\nnetwork_policies:\n host_preserved: {}\n"; + const interrupted = createRebuildFlowHarness({ + captureOpenshell: sandboxGetProbes([SOURCE_PROBE, null]), + onboard: () => { + throw new Error("replacement create failed"); + }, + }); + policyGet.getSandboxPolicy.mockReset().mockReturnValue({ yaml: policyDocument }); + + await expect( + interrupted.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Recreate failed"); + + const persistedManifest = JSON.parse( + fs.readFileSync(path.join(interrupted.backupPath, "rebuild-manifest.json"), "utf8"), + ) as { rebuildPolicyHandoff: { file: string } } & Record; + const handoffPath = path.join( + interrupted.backupPath, + persistedManifest.rebuildPolicyHandoff.file, + ); + expect(fs.readFileSync(handoffPath, "utf8")).toBe(policyDocument); + expect(fs.existsSync(path.join(interrupted.backupPath, ".nemoclaw-rebuild-recovery.json"))).toBe( + true, + ); + let recreatedPolicy = ""; + const restarted = createRebuildFlowHarness({ + staleRecovery: true, + captureOpenshell: sandboxGetProbes([null]), + onboard: (_session, options) => { + recreatedPolicy = fs.readFileSync(String(options.rebuildPolicySourcePath), "utf8"); + }, + }); + restarted.session.checkpoint = interrupted.session.checkpoint; + policyGet.getSandboxPolicy.mockReset().mockReturnValue({ yaml: "" }); + + await expect( + restarted.rebuildSandbox("alpha", ["--yes"], { + throwOnError: true, + recoveryManifest: persistedManifest as never, + }), + ).resolves.toBeUndefined(); + + expect(recreatedPolicy).toBe(policyDocument); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(fs.existsSync(path.join(interrupted.backupPath, ".nemoclaw-rebuild-recovery.json"))).toBe( + false, + ); + }); + function restartFromJournaledSource(probes: readonly (string | null)[], checkpoint: unknown) { const restarted = createRebuildFlowHarness({ captureOpenshell: sandboxGetProbes(probes), @@ -402,41 +454,6 @@ describe("rebuildSandbox flow: recovery", () => { expect(harness.relockSpy).toHaveBeenCalled(); }); - it("prunes the disabled Teams preset from the final registry policies after rebuild", async () => { - const disabledTeamsPlan = { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "rebuild", - channels: [], - disabledChannels: ["teams"], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; - const harness = createRebuildFlowHarness({ - applyPreset: () => true, - backupPolicyPresets: ["teams", "npm"], - buildMessagingRebuildPlan: () => disabledTeamsPlan, - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "npm"); - expect(harness.applyPresetSpy).not.toHaveBeenCalledWith("alpha", "teams"); - expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { - agentVersion: "0.2.0", - policies: ["npm"], - policyTier: null, - policyPresetsFinalized: undefined, - }); - }); - it("aborts before backup/delete when messaging manifest staging fails", async () => { const harness = createRebuildFlowHarness({ buildMessagingRebuildPlan: () => { @@ -506,10 +523,7 @@ describe("rebuildSandbox flow: recovery", () => { }; const harness = createRebuildFlowHarness({ defaultSandbox: "alpha", - sandboxEntry: { - policies: ["npm", "mcp-bridge-github"], - policyPresetsFinalized: true, - }, + sandboxEntry: {}, mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], @@ -525,7 +539,7 @@ describe("rebuildSandbox flow: recovery", () => { expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); expect(harness.restoreSandboxEntrySpy.mock.calls).toEqual([ - [expect.objectContaining({ name: "alpha", policies: ["npm", "mcp-bridge-github"] })], + [expect.objectContaining({ name: "alpha" })], ]); }); @@ -548,7 +562,7 @@ describe("rebuildSandbox flow: recovery", () => { it("fails the rebuild while surfacing incomplete OpenClaw post-restore work", async () => { const harness = createRebuildFlowHarness({ - sandboxEntry: { policyPresetsFinalized: true, policyTier: "balanced" }, + sandboxEntry: {}, executeSandboxCommand: () => ({ status: 1, stdout: "", stderr: "hash refresh failed" }), repairMutableConfigPerms: () => ({ applied: false, @@ -566,34 +580,26 @@ describe("rebuildSandbox flow: recovery", () => { await expect( harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("OpenClaw config integrity verification failed after rebuild"); + ).rejects.toThrow("State restore remained incomplete after rebuilding 'alpha'"); const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("rebuilt but some post-restore steps were incomplete"); expect(output).toContain("State restore was incomplete"); expect(output).toContain("Mutable config permissions were not verified"); expect(output).toContain("Mutable OpenClaw config hash was not refreshed"); - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "bad"); - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "throw"); - expect(harness.errorSpy).toHaveBeenCalledWith(expect.stringContaining("bad, throw")); expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); expect(harness.registryUpdateSpy).toHaveBeenCalledWith("alpha", { agentVersion: "0.2.0", - policies: ["npm"], - policyTier: "balanced", - policyPresetsFinalized: undefined, }); - expect(output).toContain("Policy presets failed to reapply: bad, throw"); }); - it("reports both MCP and policy recovery when both restores are incomplete", async () => { + it("reports MCP recovery when bridge restoration is incomplete", async () => { const mcpEntry = { server: "github", providerName: "nemoclaw-mcp-alpha-github", }; const harness = createRebuildFlowHarness({ applyPreset: () => false, - backupPolicyPresets: ["npm"], mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], @@ -608,7 +614,6 @@ describe("rebuildSandbox flow: recovery", () => { const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("rebuilt but some post-restore steps were incomplete"); expect(output).toContain("MCP bridge definitions were preserved but not fully refreshed"); - expect(output).toContain("Policy presets failed to reapply: npm"); expect(output).not.toContain("rebuilt successfully"); expect(harness.errorSpy).toHaveBeenCalledWith( expect.stringContaining("MCP bridge restore incomplete: MCP restore boom"), diff --git a/src/lib/actions/sandbox/rebuild-flow-target-credentials.test.ts b/src/lib/actions/sandbox/rebuild-flow-target-credentials.test.ts index c815270c985..2ab326ca100 100644 --- a/src/lib/actions/sandbox/rebuild-flow-target-credentials.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-target-credentials.test.ts @@ -73,48 +73,6 @@ describe("rebuildSandbox flow: target credentials", () => { expectNoSandboxDelete(harness.runOpenshellSpy); }); - it("preserves legacy Brave web search during a nonmatching-session rebuild", async () => { - const harness = createRebuildFlowHarness({ - applyPreset: () => true, - sandboxEntry: { policies: ["brave"], webSearchEnabled: undefined }, - sessionSandboxName: "some-other-sandbox", - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.ensureValidatedBraveSearchCredentialSpy).toHaveBeenCalledWith( - { fetchEnabled: true, provider: "brave" }, - true, - ); - expect(harness.session.webSearchConfig).toEqual({ fetchEnabled: true, provider: "brave" }); - }); - - it("reconciles stale Brave policy state to the durable Tavily provider", async () => { - const harness = createRebuildFlowHarness({ - applyPreset: (name) => name === "tavily", - backupPolicyPresets: ["brave"], - sandboxEntry: { - policies: ["brave"], - webSearchEnabled: true, - webSearchProvider: "tavily", - }, - sessionSandboxName: "some-other-sandbox", - }); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.applyPresetSpy).toHaveBeenCalledWith("alpha", "tavily"); - expect(harness.applyPresetSpy).not.toHaveBeenCalledWith("alpha", "brave"); - expect(harness.session.webSearchConfig).toEqual({ - fetchEnabled: true, - provider: "tavily", - }); - }); - it("restores the caller Tavily credential environment after rebuild", async () => { const restoreEnv = snapshotEnv(["TAVILY_API_KEY"]); process.env.TAVILY_API_KEY = "caller-tavily-key"; diff --git a/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts b/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts index 2b6166bd336..4dbb7e2c7d8 100644 --- a/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-target-image.test.ts @@ -413,7 +413,6 @@ describe("rebuildSandbox flow: target image", () => { fromDockerfile: "/tmp/unrelated.Dockerfile", }; harness.session.webSearchConfig = { fetchEnabled: true }; - harness.session.policyPresets = ["foreign-preset"]; harness.session.gpuPassthrough = true; await expect( @@ -431,7 +430,7 @@ describe("rebuildSandbox flow: target image", () => { expect(harness.session.endpointUrl).not.toBe(staleEndpoint); expect(harness.session.metadata).toMatchObject({ fromDockerfile: null }); expect(harness.session.webSearchConfig).toBeNull(); - expect(harness.session.policyPresets).toEqual(["npm", "bad", "throw"]); + expect(harness.session).not.toHaveProperty("policyPresets"); expect(harness.session.gpuPassthrough).toBe(false); expect(harness.runOpenshellSpy).toHaveBeenCalledWith( ["sandbox", "delete", "-g", "nemoclaw", "alpha"], @@ -514,7 +513,7 @@ describe("rebuildSandbox flow: target image", () => { const errors = harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(errors).toContain("Recreate failed after sandbox was destroyed"); - expect(errors).toContain("Backup is preserved at: /tmp/nemoclaw-rebuild-backup"); + expect(errors).toContain(`Backup is preserved at: ${harness.backupPath}`); expect(errors).toContain("onboard --resume"); } finally { restoreEnv(); diff --git a/src/lib/actions/sandbox/rebuild-flow-test-fixtures.ts b/src/lib/actions/sandbox/rebuild-flow-test-fixtures.ts index 182a4c2184e..bd7a6beb259 100644 --- a/src/lib/actions/sandbox/rebuild-flow-test-fixtures.ts +++ b/src/lib/actions/sandbox/rebuild-flow-test-fixtures.ts @@ -1,6 +1,10 @@ // 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"; + export function makeActiveTeamsMessagingPlan() { return { schemaVersion: 1, @@ -82,7 +86,17 @@ export function makeActiveTeamsMessagingPlan() { }; } +const preparedRecoveryTempDirs: string[] = []; + +export function cleanupPreparedRecoveryManifests(): void { + for (const directory of preparedRecoveryTempDirs.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + export function makePreparedRecoveryManifest() { + const backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-recovery-")); + preparedRecoveryTempDirs.push(backupPath); return { version: 1, sandboxName: "alpha", @@ -94,9 +108,7 @@ export function makePreparedRecoveryManifest() { backedUpDirs: ["workspace"], stateFiles: [], dir: "/sandbox/.openclaw", - backupPath: "/tmp/rebuild-backups/alpha/2026-07-01T06-50-42-044Z", + backupPath, blueprintDigest: null, - policyPresets: ["npm"], - customPolicies: [], }; } diff --git a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts index 7355f5fa8fa..eaf47775078 100644 --- a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts @@ -7,7 +7,6 @@ import * as gatewayDrift from "../../adapters/openshell/gateway-drift"; import * as openshellRuntime from "../../adapters/openshell/runtime"; import * as gatewayRuntime from "../../gateway-runtime-action"; import * as dockerDriverRecovery from "../../onboard/docker-driver-sandbox-recovery"; -import * as openshellDockerContainers from "../../onboard/openshell-docker-sandbox-containers"; import * as registry from "../../state/registry"; import * as registryPersistence from "../../state/registry/persistence"; import { type RebuildSandboxEntry, resolveRebuildLiveState } from "./rebuild-flow-helpers"; @@ -16,10 +15,6 @@ import { runRebuildGatewayIntentPreflight, } from "./rebuild-preflight-guards"; -const removeStaleRebuildDockerOrphan = openshellDockerContainers.removeStaleRebuildDockerOrphan; -type QueryDockerContainers = typeof openshellDockerContainers.queryOpenShellDockerSandboxContainers; -type ForceRemoveDockerContainer = (containerId: string) => { status?: number | null }; - const driftIssue: gatewayDrift.OpenShellStateRpcIssue = { kind: "image_drift", drift: { @@ -42,7 +37,6 @@ function makeSandboxEntry(gatewayName = "nemoclaw", gatewayPort = 8080): Rebuild name: "alpha", provider: "ollama-local", model: "nvidia/nemotron", - policies: [], nimContainer: null, agent: null, nemoclawVersion: "0.1.0", @@ -62,8 +56,6 @@ describe("rebuild gateway drift preflight", () => { let recoverNamedGatewayRuntimeSpy: MockInstance; let getNamedGatewayLifecycleStateSpy: MockInstance; let recoverDockerDriverSandboxSpy: MockInstance; - let queryDockerContainersSpy: ReturnType>; - let forceRemoveDockerContainerSpy: ReturnType>; let errorSpy: MockInstance; let logSpy: MockInstance; @@ -91,19 +83,6 @@ describe("rebuild gateway drift preflight", () => { recoverDockerDriverSandboxSpy = vi .spyOn(dockerDriverRecovery, "recoverDockerDriverSandbox") .mockReturnValue({ recovered: false, via: null }); - queryDockerContainersSpy = vi - .fn() - .mockReturnValue({ ok: true, ids: [] }); - forceRemoveDockerContainerSpy = vi - .fn() - .mockReturnValue({ status: 0 }); - vi.spyOn(openshellDockerContainers, "removeStaleRebuildDockerOrphan").mockImplementation( - (sandboxName, openshellDriver, log) => - removeStaleRebuildDockerOrphan(sandboxName, openshellDriver, log, { - queryContainers: queryDockerContainersSpy, - forceRemove: forceRemoveDockerContainerSpy, - }), - ); vi.spyOn(registry, "getSandbox").mockReturnValue(makeSandboxEntry() as never); vi.spyOn(registryPersistence, "load").mockReturnValue({ sandboxes: { alpha: makeSandboxEntry() }, @@ -168,7 +147,7 @@ describe("rebuild gateway drift preflight", () => { activeGateway: "nemoclaw", }, ])( - "recovers $recordedGateway as stale even while $activeGateway is ambiently active, since the sandbox RPC is gateway-pinned (#4497)", + "refuses missing $recordedGateway even while $activeGateway is ambiently active, after the gateway-pinned lookup (#4497)", async ({ recordedGateway, recordedPort, activeGateway }) => { const entry = makeSandboxEntry(recordedGateway, recordedPort); const registrySnapshot = { sandboxes: { alpha: entry } }; @@ -184,10 +163,9 @@ describe("rebuild gateway drift preflight", () => { } as never); const behaviorLog = vi.fn(); - const result = await resolveRebuildLiveState("alpha", entry, behaviorLog, bail); - - expect(result).toEqual({ staleRecovery: true, staleRegistrySnapshot: registrySnapshot }); - expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); + await expect(resolveRebuildLiveState("alpha", entry, behaviorLog, bail)).rejects.toThrow( + "Cannot rebuild an absent sandbox without its authoritative OpenShell policy", + ); expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); expect(runOpenshellSpy).toHaveBeenCalledWith( ["gateway", "select", recordedGateway], @@ -204,104 +182,12 @@ describe("rebuild gateway drift preflight", () => { expect.anything(), ); expect(recoverDockerDriverSandboxSpy).toHaveBeenCalledWith("alpha"); - expect(registryPersistence.load).toHaveBeenCalledOnce(); - expect(logSpy.mock.calls.flat().join("\n")).toContain( + expect(registryPersistence.load).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.flat().join("\n")).toContain( "absent from the live OpenShell gateway", ); - expect(behaviorLog.mock.calls.flat().join("\n")).toContain("Stale-sandbox recovery"); - }, - ); - - it("removes one exactly labeled Docker orphan before a registry-only rebuild (#8720)", async () => { - const entry = { ...makeSandboxEntry(), openshellDriver: "docker" }; - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - captureOpenshellSpy - .mockReturnValueOnce({ status: 0, output: "" }) - .mockReturnValueOnce({ status: 1, output: "Error: sandbox not found" }); - queryDockerContainersSpy - .mockReturnValueOnce({ ok: true, ids: ["orphan-container-id"] }) - .mockReturnValueOnce({ ok: true, ids: [] }); - - await expect(resolveRebuildLiveState("alpha", entry, vi.fn(), bail)).resolves.toMatchObject({ - staleRecovery: true, - }); - - expect(forceRemoveDockerContainerSpy).toHaveBeenCalledWith("orphan-container-id"); - expect(queryDockerContainersSpy).toHaveBeenCalledTimes(2); - }); - - it("preserves legacy stale recovery when Docker inspection is unavailable (#8720)", async () => { - const entry = makeSandboxEntry(); - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - captureOpenshellSpy - .mockReturnValueOnce({ status: 0, output: "" }) - .mockReturnValueOnce({ status: 1, output: "Error: sandbox not found" }); - queryDockerContainersSpy.mockReturnValue({ ok: false, ids: [], error: "docker unavailable" }); - - await expect(resolveRebuildLiveState("alpha", entry, vi.fn(), bail)).resolves.toMatchObject({ - staleRecovery: true, - }); - - expect(forceRemoveDockerContainerSpy).not.toHaveBeenCalled(); - expect(registryPersistence.load).toHaveBeenCalledOnce(); - }); - - it("refuses ambiguous labeled Docker orphan cleanup without removing either container (#8720)", async () => { - const entry = { ...makeSandboxEntry(), openshellDriver: "docker" }; - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - captureOpenshellSpy - .mockReturnValueOnce({ status: 0, output: "" }) - .mockReturnValueOnce({ status: 1, output: "Error: sandbox not found" }); - queryDockerContainersSpy.mockReturnValue({ ok: true, ids: ["first-id", "second-id"] }); - - await expect(resolveRebuildLiveState("alpha", entry, vi.fn(), bail)).rejects.toThrow( - "refusing ambiguous orphan cleanup", - ); - - expect(forceRemoveDockerContainerSpy).not.toHaveBeenCalled(); - expect(registryPersistence.load).not.toHaveBeenCalled(); - }); - - it.each([ - { - failure: "query failure", - queryResults: [{ ok: false, ids: [], error: "docker unavailable" }], - removeResult: { status: 0 }, - removeCalls: 0, - }, - { - failure: "removal failure", - queryResults: [{ ok: true, ids: ["orphan-container-id"] }], - removeResult: { status: 1 }, - removeCalls: 1, - }, - { - failure: "confirmation failure", - queryResults: [ - { ok: true, ids: ["orphan-container-id"] }, - { ok: true, ids: ["orphan-container-id"] }, - ], - removeResult: { status: 0 }, - removeCalls: 1, - }, - ])( - "fails closed before registry recovery on Docker orphan $failure (#8720)", - async ({ queryResults, removeResult, removeCalls }) => { - const entry = { ...makeSandboxEntry(), openshellDriver: "docker" }; - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - captureOpenshellSpy - .mockReturnValueOnce({ status: 0, output: "" }) - .mockReturnValueOnce({ status: 1, output: "Error: sandbox not found" }); - queryDockerContainersSpy.mockReturnValueOnce(queryResults[0] as never); - queryDockerContainersSpy.mockReturnValueOnce((queryResults[1] ?? queryResults[0]) as never); - forceRemoveDockerContainerSpy.mockReturnValue(removeResult); - - await expect(resolveRebuildLiveState("alpha", entry, vi.fn(), bail)).rejects.toThrow( - "Stale-recovery Docker orphan cleanup failed", - ); - - expect(forceRemoveDockerContainerSpy).toHaveBeenCalledTimes(removeCalls); - expect(registryPersistence.load).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.flat().join("\n")).toContain("nemoclaw alpha destroy --yes"); + expect(behaviorLog.mock.calls.flat().join("\n")).not.toContain("Stale-sandbox recovery"); }, ); @@ -309,7 +195,7 @@ describe("rebuild gateway drift preflight", () => { { gatewayName: "nemoclaw", gatewayPort: 8080 }, { gatewayName: "nemoclaw-12345", gatewayPort: 12345 }, ])( - "recovers $gatewayName and returns stale state after confirming the sandbox is absent (#4497)", + "recovers $gatewayName and refuses rebuild after confirming the sandbox is absent (#4497)", async ({ gatewayName, gatewayPort }) => { const entry = makeSandboxEntry(gatewayName, gatewayPort); const registrySnapshot = { sandboxes: { alpha: entry } }; @@ -325,13 +211,9 @@ describe("rebuild gateway drift preflight", () => { } as never); const behaviorLog = vi.fn(); - const result = await resolveRebuildLiveState("alpha", entry, behaviorLog, bail); - - expect(result).toEqual({ - staleRecovery: true, - staleRegistrySnapshot: registrySnapshot, - }); - expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); + await expect(resolveRebuildLiveState("alpha", entry, behaviorLog, bail)).rejects.toThrow( + "Cannot rebuild an absent sandbox without its authoritative OpenShell policy", + ); expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledOnce(); expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledWith({ gatewayName, @@ -352,14 +234,43 @@ describe("rebuild gateway drift preflight", () => { ); expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); expect(recoverDockerDriverSandboxSpy).toHaveBeenCalledWith("alpha"); - expect(registryPersistence.load).toHaveBeenCalledOnce(); - expect(logSpy.mock.calls.flat().join("\n")).toContain( + expect(registryPersistence.load).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.flat().join("\n")).toContain( "absent from the live OpenShell gateway", ); - expect(behaviorLog.mock.calls.flat().join("\n")).toContain("Stale-sandbox recovery"); + expect(errorSpy.mock.calls.flat().join("\n")).toContain("nemoclaw alpha destroy --yes"); + expect(behaviorLog.mock.calls.flat().join("\n")).not.toContain("Stale-sandbox recovery"); }, ); + it("permits absent-sandbox recovery only with a verified transaction policy handoff", async () => { + const entry = makeSandboxEntry(); + const registrySnapshot = { sandboxes: { alpha: entry } }; + vi.mocked(registryPersistence.load).mockReturnValue(registrySnapshot as never); + captureOpenshellSpy + .mockReturnValueOnce({ status: 0, output: "beta Ready" }) + .mockReturnValueOnce({ + status: 1, + output: "Error: × Not Found: sandbox not found", + }); + const behaviorLog = vi.fn(); + + await expect( + resolveRebuildLiveState("alpha", entry, behaviorLog, bail, { + authoritativeRecoveryPolicyAvailable: true, + }), + ).resolves.toEqual({ + staleRecovery: true, + staleRegistrySnapshot: registrySnapshot, + }); + + expect(registryPersistence.load).toHaveBeenCalledOnce(); + expect(behaviorLog.mock.calls.flat().join("\n")).toContain( + "transaction-bound policy handoff is intact", + ); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it("recovers the named gateway before a generic sandbox-list query fails (#10421)", async () => { const entry = makeSandboxEntry(); captureOpenshellSpy.mockReturnValueOnce({ diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts index 95d52c03a4b..3d5a33faced 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts @@ -251,41 +251,33 @@ describe("buildRebuildRecreateOnboardOpts", () => { expect(legacy.observabilityEnabled).toBe(false); }); - it("carries the authoritative restricted tier with observability into inner onboard", () => { + it("carries observability into inner onboard without policy shadow state", () => { const opts = buildRebuildRecreateOnboardOpts({ ...baseArgs, rebuildAgent: "langchain-deepagents-code", sb: { observabilityEnabled: true, - policyTier: "restricted", }, }); - expect(opts.policyTier).toBe("restricted"); + expect(opts).not.toHaveProperty("policyTier"); expect(opts.observabilityEnabled).toBe(true); }); - it("rejects an invalid recorded policy tier before destructive recreate work", () => { - expect(() => - buildRebuildRecreateOnboardOpts({ - ...baseArgs, - sb: { ...dashboard, policyTier: "unknown-tier" }, - }), - ).toThrow("Invalid recorded policy tier 'unknown-tier'."); - }); - - it.each([ - "openclaw", - "hermes", - ])("rejects malformed %s observability state before recreate onboarding", (rebuildAgent) => { - expect(() => - buildRebuildRecreateOnboardOpts({ - ...baseArgs, - rebuildAgent, - sb: { ...dashboard, observabilityEnabled: true }, - }), - ).toThrow("Recorded observability state is valid only for agent 'langchain-deepagents-code'."); - }); + it.each(["openclaw", "hermes"])( + "rejects malformed %s observability state before recreate onboarding", + (rebuildAgent) => { + expect(() => + buildRebuildRecreateOnboardOpts({ + ...baseArgs, + rebuildAgent, + sb: { ...dashboard, observabilityEnabled: true }, + }), + ).toThrow( + "Recorded observability state is valid only for agent 'langchain-deepagents-code'.", + ); + }, + ); it("forwards noGpu:true for legacy entries with gpuEnabled:false and no sandboxGpuMode", () => { const opts = buildRebuildRecreateOnboardOpts({ diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts index 7eea6fd5562..267bec2f34e 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts @@ -28,7 +28,6 @@ import type { } from "../../onboard/rebuild-route-handoff"; import { normalizeSandboxGpuMode } from "../../onboard/sandbox-gpu-mode"; import type { ManagedWorkloadRebuildHandoff } from "../../onboard/workload/rebuild"; -import { getTier } from "../../policy/tiers"; import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; import type { CheckpointGatewayAuthority } from "../../state/onboard-checkpoint-types"; import type { PreservedEnvFile } from "../../state/preserved-env"; @@ -45,7 +44,6 @@ export type RebuildGpuOptOutEntry = { toolDisclosure?: ToolDisclosure; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; observabilityEnabled?: boolean; - policyTier?: string | null; endpointSource?: InferenceEndpointSource | null; provider?: string | null; model?: string | null; @@ -134,7 +132,7 @@ export type RebuildRecreateOnboardOpts = { preparedImageRebuild?: PreparedImageRebuildHandoff; managedWorkloadRebuild?: ManagedWorkloadRebuildHandoff; rebuildPreservedEnv?: readonly PreservedEnvFile[]; - rebuildPolicyPresets?: readonly string[]; + rebuildPolicySourcePath?: string; hostMounts?: readonly import("../../state/registry/types").SandboxHostMount[]; autoYes: boolean; toolDisclosure: ToolDisclosure; @@ -144,7 +142,6 @@ export type RebuildRecreateOnboardOpts = { observabilityEnabled: boolean; /** Whether the rebuild command explicitly overrode the recorded observability state. */ observabilityRequestedExplicitly: boolean; - policyTier: string | null; baseImageResolutionHint: SandboxBaseImageResolutionMetadata | null; preResolvedBaseImageMetadata?: SandboxBaseImageResolutionMetadata; noGpu?: true; @@ -166,10 +163,6 @@ export function buildRebuildRecreateOnboardOpts(args: { } const gpuOverrides = getRebuildSandboxGpuOverrides(args.sb); const hostMounts = normalizePersistedSandboxHostMounts(args.sb?.hostMounts); - const rawPolicyTier = args.sb?.policyTier?.trim().toLowerCase() || null; - if (rawPolicyTier && !getTier(rawPolicyTier)) { - throw new Error(`Invalid recorded policy tier '${String(args.sb?.policyTier)}'.`); - } const targetGatewayName = resolveSandboxGatewayName(args.sb); const targetGatewayPort = resolveGatewayPortFromName(targetGatewayName); if (targetGatewayPort === null) { @@ -216,7 +209,6 @@ export function buildRebuildRecreateOnboardOpts(args: { dcodeAutoApprovalRequestedExplicitly: false, observabilityEnabled: args.sb?.observabilityEnabled === true, observabilityRequestedExplicitly: false, - policyTier: rawPolicyTier, baseImageResolutionHint: args.baseImageResolutionHint ?? null, ...(rebuildShouldOptOutGpu(args.sb) ? { noGpu: true as const } : {}), ...(hostMounts.length > 0 ? { hostMounts } : {}), diff --git a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts index f5a8e2b0688..2bd48a8725b 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const phaseMocks = vi.hoisted(() => ({ + clearPolicyHandoff: vi.fn(), + clearRecoveryBackup: vi.fn(), + cleanupPolicySource: vi.fn(), + findRecoveryBackup: vi.fn(), openRecreateJournal: vi.fn(), recoverCronRestore: vi.fn(), runBackup: vi.fn(), @@ -11,9 +15,20 @@ const phaseMocks = vi.hoisted(() => ({ runDestroy: vi.fn(), runPostRestore: vi.fn(), runPreflight: vi.fn(), + runRestore: vi.fn(), runShields: vi.fn(), })); +vi.mock("../../onboard/temp-files", async (importOriginal) => ({ + ...(await importOriginal()), + cleanupTempDir: phaseMocks.cleanupPolicySource, +})); + +vi.mock("../../state/sandbox", async (importOriginal) => ({ + ...(await importOriginal()), + clearRebuildPolicyHandoff: phaseMocks.clearPolicyHandoff, +})); + const gatewayAuthority = { gatewayName: "nemoclaw", gatewayPort: 8080, @@ -26,11 +41,15 @@ const gatewayAuthority = { } as const; vi.mock("./rebuild-recreate-journal", () => ({ + clearRebuildRecoveryBackup: phaseMocks.clearRecoveryBackup, + findRebuildRecoveryBackup: phaseMocks.findRecoveryBackup, fingerprintRebuildRecreateTargetIntent: () => "intent-1", openRebuildRecreateJournal: phaseMocks.openRecreateJournal, + recordRebuildRecoveryBackup: vi.fn(), })); -vi.mock("./rebuild-backup-phase", () => ({ +vi.mock("./rebuild-backup-phase", async (importOriginal) => ({ + ...(await importOriginal()), runRebuildBackupPhase: phaseMocks.runBackup, })); @@ -48,6 +67,10 @@ vi.mock("./rebuild-shields-phase", () => ({ runRebuildShieldsPhase: phaseMocks.runShields, })); +vi.mock("./rebuild-restore-phase", () => ({ + runRebuildRestorePhase: phaseMocks.runRestore, +})); + vi.mock("./rebuild-post-restore-phase", async (importOriginal) => ({ ...(await importOriginal()), recoverHermesCronRestore: phaseMocks.recoverCronRestore, @@ -59,6 +82,8 @@ import { rebuildSandbox } from "./rebuild"; describe("Hermes accepted replacement recovery", () => { const backupPath = "/tmp/nemoclaw-rebuild-backup"; + const recoveryBackupPath = "/tmp/nemoclaw-rebuild-backup-original"; + const policySourcePath = "/tmp/nemoclaw-rebuild-policy-test/policy.yaml"; const bail = vi.fn(); const cleanupDcodePreflight = vi.fn(); const completeAcceptedTarget = vi.fn(); @@ -70,9 +95,20 @@ describe("Hermes accepted replacement recovery", () => { vi.clearAllMocks(); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); + phaseMocks.clearPolicyHandoff.mockImplementation((manifest) => { + delete manifest.rebuildPolicyHandoff; + return true; + }); + phaseMocks.clearRecoveryBackup.mockImplementation(() => undefined); phaseMocks.recoverCronRestore.mockReturnValue("dispatch-reactivated"); + phaseMocks.findRecoveryBackup.mockReturnValue({ + backupPath: recoveryBackupPath, + timestamp: "2026-08-28T00-00-00-000Z", + }); + phaseMocks.runRestore.mockReturnValue({ restoreSucceeded: true }); + phaseMocks.runPostRestore.mockResolvedValue(undefined); phaseMocks.runPreflight.mockResolvedValue({ - sandboxEntry: { name: "alpha", customPolicies: [] }, + sandboxEntry: { name: "alpha" }, rebuildAgent: "hermes", versionCheck: {}, targetConfig: { @@ -119,10 +155,10 @@ describe("Hermes accepted replacement recovery", () => { backupPath, backedUpDirs: ["cron"], preservedEnv: [], + rebuildPolicyHandoff: { file: "current.yaml", sha256: "a".repeat(64) }, }, backupWasForceSkipped: false, - policyPresets: [], - sessionPolicyPresets: [], + policySourcePath, }); phaseMocks.openRecreateJournal.mockReturnValue({ id: "journal-1", @@ -149,17 +185,63 @@ describe("Hermes accepted replacement recovery", () => { return "dispatch-reactivated"; }); completeAcceptedTarget.mockImplementation(() => events.push("complete")); + phaseMocks.runRestore.mockImplementation(() => { + events.push("restore"); + return { restoreSucceeded: true }; + }); + phaseMocks.runPostRestore.mockImplementation(async () => { + events.push("post-restore"); + }); + phaseMocks.clearRecoveryBackup.mockImplementation(() => events.push("clear-recovery")); await expect( rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), ).resolves.toBeUndefined(); - expect(events).toEqual(["recover", "complete"]); + expect(events).toEqual(["recover", "restore", "post-restore", "clear-recovery", "complete"]); expect(log).toHaveBeenCalledWith( "Hermes cron restore recovery for accepted replacement: dispatch-reactivated", ); expect(phaseMocks.runDestroy).not.toHaveBeenCalled(); expect(phaseMocks.runCronRestoreTransaction).not.toHaveBeenCalled(); + expect(phaseMocks.runPostRestore).toHaveBeenCalledWith( + expect.objectContaining({ + backupManifest: expect.objectContaining({ backupPath: recoveryBackupPath }), + preparedBackupRecovery: true, + recoveryRecreate: true, + }), + ); + expect(phaseMocks.clearPolicyHandoff).toHaveBeenCalledOnce(); + expect(phaseMocks.cleanupPolicySource).not.toHaveBeenCalled(); + }); + + it("retires both the unused current policy handoff and the recovered transaction handoff", async () => { + const currentManifest = { + backupPath, + backedUpDirs: ["cron"], + preservedEnv: [], + rebuildPolicyHandoff: { file: "current.yaml", sha256: "a".repeat(64) }, + }; + const recoveryManifest = { + backupPath: recoveryBackupPath, + timestamp: "2026-08-28T00-00-00-000Z", + rebuildPolicyHandoff: { file: "recovery.yaml", sha256: "b".repeat(64) }, + }; + phaseMocks.runBackup.mockReturnValue({ + backupManifest: currentManifest, + backupWasForceSkipped: false, + policySourcePath, + }); + phaseMocks.findRecoveryBackup.mockReturnValue(recoveryManifest); + + await expect( + rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(phaseMocks.clearPolicyHandoff.mock.calls.map(([manifest]) => manifest)).toEqual([ + currentManifest, + recoveryManifest, + ]); }); it("reports an operator drain that remains after accepted replacement recovery (#7806)", async () => { @@ -193,6 +275,7 @@ describe("Hermes accepted replacement recovery", () => { " Correct the reported restore problem, then run `nemoclaw alpha recover`.", ); expect(phaseMocks.runDestroy).not.toHaveBeenCalled(); + expect(phaseMocks.clearRecoveryBackup).not.toHaveBeenCalled(); }); it("retains the replacement journal when the accepted target lacks recovery control (#7806)", async () => { @@ -213,5 +296,20 @@ describe("Hermes accepted replacement recovery", () => { expect.stringContaining("then run `nemoclaw alpha recover`"), ); expect(phaseMocks.runDestroy).not.toHaveBeenCalled(); + expect(phaseMocks.clearRecoveryBackup).not.toHaveBeenCalled(); + }); + + it("retains the replacement journal when its recovery backup is unavailable", async () => { + phaseMocks.findRecoveryBackup.mockReturnValue(null); + + await expect( + rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(bail).toHaveBeenCalledWith( + "Replacement state restoration is incomplete; the replacement journal was retained.", + ); + expect(completeAcceptedTarget).not.toHaveBeenCalled(); + expect(phaseMocks.runRestore).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts index 452294993e3..cd02dc35e5d 100644 --- a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts +++ b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts @@ -232,10 +232,8 @@ describe("rebuild local-provider recreation", () => { "30", ]); expect(calls.some((args) => args[0] === "provider" && args[1] === "update")).toBe(false); - expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( - "alpha", - "/tmp/nemoclaw-rebuild-backup", - { targetAgentType: "openclaw" }, - ); + expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith("alpha", harness.backupPath, { + targetAgentType: "openclaw", + }); }); }); diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.ts index 7cb1ed1497b..fb1a52180fb 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.ts @@ -171,7 +171,6 @@ export function postRestoreCompleted(status: { mcpBridgeRestoreUnverified: boolean; mutableConfigHashRefreshUnverified: boolean; mutablePermsRepairUnverified: boolean; - policyPresetRestoreIncomplete: boolean; restoreSucceeded: boolean; }): boolean { return ( @@ -180,8 +179,7 @@ export function postRestoreCompleted(status: { !status.mutablePermsRepairUnverified && !status.mutableConfigHashRefreshUnverified && !status.messagingHostForwardUnverified && - !status.mcpBridgeRestoreUnverified && - !status.policyPresetRestoreIncomplete + !status.mcpBridgeRestoreUnverified ); } diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 51003e38a2d..b34a813c3e6 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -1,6 +1,7 @@ // 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"; @@ -12,16 +13,18 @@ import { MESSAGING_CHANNEL_CONFIG_ENV_KEYS } from "../../messaging-channel-confi import { hydrateCredentialEnv } from "../../onboard/credential-env"; import { DOCKER_GPU_PATCH_NETWORK_ENV } from "../../onboard/docker-gpu-patch"; import { withPortableOnboardRetirementBoundary } from "../../onboard/portable-retirement-authority"; +import { cleanupTempDir } from "../../onboard/temp-files"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; import * as onboardSession from "../../state/onboard-session"; import { load as loadRegistry, REGISTRY_FILE } from "../../state/registry/persistence"; import { - excludePolicyPresetsByName, - normalizeRebuildTargetPolicyPresets, + captureRebuildPolicySource, + clearRebuildPolicyHandoff, + type RebuildBackupManifest, runRebuildBackupPhase, + writeRebuildPolicyHandoff, } from "./rebuild-backup-phase"; import { buildRefreshMutableOpenClawConfigHashCommand } from "./rebuild-config-hash"; -import { DCODE_AGENT_NAME } from "./rebuild-dcode-target"; import { runRebuildDestroyPhase } from "./rebuild-destroy-phase"; import { REBUILD_HERMES_DASHBOARD_ENV_KEYS } from "./rebuild-durable-config"; import { disposeRebuildAgentBaseImagePreflight } from "./rebuild-flow-helpers"; @@ -36,7 +39,6 @@ import { } from "./rebuild-post-restore-phase"; import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; import { - blockRebuildOnPendingBaselineTransition, assertSandboxRebuildCommandAvailable, revalidateManagedWorkloadRebuildBeforeDelete, revalidateRebuildRouteBeforeDelete, @@ -56,8 +58,11 @@ import { } from "./rebuild-prepared-recovery"; import { inspectRebuildGatewayProviderRegistration } from "./rebuild-provider-preflight"; import { + clearRebuildRecoveryBackup, + findRebuildRecoveryBackup, fingerprintRebuildRecreateTargetIntent, openRebuildRecreateJournal, + recordRebuildRecoveryBackup, } from "./rebuild-recreate-journal"; import { runRebuildRecreatePhase } from "./rebuild-recreate-phase"; import { createRebuildRegistryRollback } from "./rebuild-registry-rollback"; @@ -98,30 +103,31 @@ export async function rebuildSandbox( sessionFile: onboardSession.SESSION_FILE, stateDir: path.dirname(onboardSession.SESSION_FILE), }, - () => withMcpLifecycleLock(sandboxName, async () => { - assertSandboxRebuildCommandAvailable(sandboxName); - const scopedEnvKeys = [ - BRAVE_API_KEY_ENV, - TAVILY_API_KEY_ENV, - MESSAGING_SETUP_APPLIER_ENV_KEY, - "OPENSHELL_GATEWAY", - DOCKER_GPU_PATCH_NETWORK_ENV, - ...REBUILD_HERMES_DASHBOARD_ENV_KEYS, - ...MESSAGING_CHANNEL_CONFIG_ENV_KEYS, - ]; - const savedEnv = scopedEnvKeys.map((key) => [key, process.env[key]] as const); - try { - await rebuildSandboxUnlocked(sandboxName, options, opts); - } finally { - for (const key of scopedEnvKeys) delete process.env[key]; - Object.assign( - process.env, - Object.fromEntries( - savedEnv.filter((entry): entry is [string, string] => entry[1] !== undefined), - ), - ); - } - }), + () => + withMcpLifecycleLock(sandboxName, async () => { + assertSandboxRebuildCommandAvailable(sandboxName); + const scopedEnvKeys = [ + BRAVE_API_KEY_ENV, + TAVILY_API_KEY_ENV, + MESSAGING_SETUP_APPLIER_ENV_KEY, + "OPENSHELL_GATEWAY", + DOCKER_GPU_PATCH_NETWORK_ENV, + ...REBUILD_HERMES_DASHBOARD_ENV_KEYS, + ...MESSAGING_CHANNEL_CONFIG_ENV_KEYS, + ]; + const savedEnv = scopedEnvKeys.map((key) => [key, process.env[key]] as const); + try { + await rebuildSandboxUnlocked(sandboxName, options, opts); + } finally { + for (const key of scopedEnvKeys) delete process.env[key]; + Object.assign( + process.env, + Object.fromEntries( + savedEnv.filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + ); + } + }), { loadRegistry, withLifecycleLock: withMcpLifecycleLock }, ); } @@ -131,8 +137,26 @@ async function rebuildSandboxUnlocked( options: string[] | RebuildSandboxOptions, opts: RebuildSandboxExecutionOptions, ): Promise { + let executionOptions = opts; + if (!executionOptions.recoveryManifest) { + const transaction = onboardSession.loadSession()?.checkpoint?.sandboxRecreate; + const registryEntry = loadRegistry().sandboxes[sandboxName]; + if (transaction?.sandboxName === sandboxName && registryEntry) { + const retainedRecovery = findRebuildRecoveryBackup({ + sandboxName, + agentName: registryEntry.agent, + transactionId: transaction.id, + }); + if (retainedRecovery) { + executionOptions = { + ...executionOptions, + recoveryManifest: retainedRecovery, + }; + } + } + } const normalized = normalizeRebuildSandboxOptions(options); - const preflight = await runRebuildPreflightPhase(sandboxName, options, opts); + const preflight = await runRebuildPreflightPhase(sandboxName, options, executionOptions); if (!preflight) return; const { sandboxEntry, @@ -163,14 +187,13 @@ async function rebuildSandboxUnlocked( } = targetConfig; const { staleRecovery } = liveState; let preparedImage = initiallyPreparedImage; - const preservedCustomPolicies = (sandboxEntry.customPolicies ?? []).map((entry) => ({ - ...entry, - })); let recoveryManifest = validatedRecoveryManifest; + let rebuildPolicySourcePath: string | null = null; + let rebuildPolicySourceIsEphemeral = false; + let rebuildPolicyHandoffManifest: NonNullable | null = null; const preparedBackupRecovery = recoveryManifest !== null; const recoveryRecreate = staleRecovery || preparedBackupRecovery; try { - if (blockRebuildOnPendingBaselineTransition(sandboxEntry, sandboxName, bail)) return; let recoveryRegistrySnapshot = preparedBackupRecovery ? JSON.parse(JSON.stringify(loadRegistry())) : liveState.staleRegistrySnapshot; @@ -195,6 +218,7 @@ async function rebuildSandboxUnlocked( } = shieldsPhase; let sandboxStillExists = true; let sandboxExistenceAmbiguous = false; + let retainPolicyHandoffForRecovery = false; try { const preDeleteRecovery = revalidatePreparedRecoveryBeforeDelete( @@ -202,7 +226,7 @@ async function rebuildSandboxUnlocked( sandboxEntry, recoveryManifest, recoveryRegistrySnapshot, - opts.allowLegacyManagedImageRecovery === true, + executionOptions.allowLegacyManagedImageRecovery === true, bail, ); recoveryManifest = preDeleteRecovery.manifest; @@ -227,6 +251,41 @@ async function rebuildSandboxUnlocked( relockShieldsIfNeeded, }); if (!backup) return; + rebuildPolicySourcePath = backup.policySourcePath; + rebuildPolicySourceIsEphemeral = backup.backupManifest === null; + rebuildPolicyHandoffManifest = backup.backupManifest; + const publishPolicyHandoff = (policyDocument: string): boolean => { + if (!backup.backupManifest) { + try { + fs.writeFileSync(backup.policySourcePath, policyDocument, { + mode: 0o600, + }); + return true; + } catch { + return false; + } + } + try { + backup.backupManifest = writeRebuildPolicyHandoff(backup.backupManifest, policyDocument); + rebuildPolicyHandoffManifest = backup.backupManifest; + const handoff = backup.backupManifest.rebuildPolicyHandoff; + if (!handoff) return false; + backup.policySourcePath = path.join(backup.backupManifest.backupPath, handoff.file); + rebuildPolicySourcePath = backup.policySourcePath; + return true; + } catch { + return false; + } + }; + const capturePolicyHandoff = (): boolean => { + const capturedPath = captureRebuildPolicySource(sandboxName); + if (!capturedPath) return false; + try { + return publishPolicyHandoff(fs.readFileSync(capturedPath, "utf8")); + } finally { + cleanupTempDir(capturedPath, "nemoclaw-rebuild-policy"); + } + }; // Validate the completed backup artifact produced above, not the mutable live // tree. This gate therefore follows backup creation and precedes every @@ -319,11 +378,43 @@ async function rebuildSandboxUnlocked( onAuthorityRefusal: (lines) => bail(lines.join("\n")), }); recreateOptions.rebuildGatewayAuthority = recreateJournal.gatewayAuthority; + const rebuildRecoveryIdentity = { + sandboxName, + agentName: rebuildAgent, + transactionId: recreateJournal.id, + }; + if (!recreateJournal.acceptedTarget && backup.backupManifest) { + recordRebuildRecoveryBackup({ + ...rebuildRecoveryIdentity, + backupManifest: backup.backupManifest, + }); + } // An earlier run of this rebuild already registered and proved the // replacement. Retire its journal and stop before the destroy phase so a // restart converges to that sandbox instead of deleting it. if (recreateJournal.acceptedTarget) { + const recoveryBackup = findRebuildRecoveryBackup(rebuildRecoveryIdentity); + if (!recoveryBackup) { + console.error(""); + console.error( + " The accepted replacement still requires state restoration, but its transaction-bound backup is unavailable.", + ); + return bail( + "Replacement state restoration is incomplete; the replacement journal was retained.", + ); + } + if ( + backup.backupManifest?.rebuildPolicyHandoff && + backup.backupManifest.backupPath !== recoveryBackup.backupPath && + !clearRebuildPolicyHandoff(backup.backupManifest) + ) { + return bail( + "The unused current-run rebuild policy handoff could not be retired during recovery.", + ); + } + rebuildPolicyHandoffManifest = recoveryBackup; + retainPolicyHandoffForRecovery = true; // The accepted replacement belongs to an earlier run. Its persisted // gate is independent of the current backup's cron plan, so probe every // Hermes target before retiring the replacement journal. @@ -358,17 +449,47 @@ async function rebuildSandboxUnlocked( ); } } + const restored = runRebuildRestorePhase({ + sandboxName, + targetAgentType: rebuildAgent || "openclaw", + targetImageIsCustom: Boolean(fromDockerfile), + backupManifest: recoveryBackup, + log, + }); + await runRebuildPostRestorePhase({ + sandboxName, + sandboxEntry, + targetAgentName: rebuildAgent || "openclaw", + messagingPlan, + backupManifest: recoveryBackup, + mcpEntries: Object.values(sandboxEntry.mcp?.bridges ?? {}), + restoreSucceeded: restored.restoreSucceeded, + backupWasForceSkipped: false, + staleRecovery: false, + recoveryRecreate: true, + preparedBackupRecovery: true, + staleSandboxWasLocked, + versionCheck, + relockShieldsIfNeeded, + log, + bail, + }); + if (recoveryBackup.rebuildPolicyHandoff && !clearRebuildPolicyHandoff(recoveryBackup)) { + return bail("The bounded rebuild policy handoff could not be retired after recovery."); + } + clearRebuildRecoveryBackup({ + ...rebuildRecoveryIdentity, + backupManifest: recoveryBackup, + }); recreateJournal.completeAcceptedTarget(); - log(`Recovered journaled replacement ${recreateJournal.id} for '${sandboxName}'`); - console.log( - ` Sandbox '${sandboxName}' already holds the replacement from the interrupted rebuild.`, + retainPolicyHandoffForRecovery = false; + log( + `Recovered and restored journaled replacement ${recreateJournal.id} for '${sandboxName}'`, ); - if (backup.backupManifest) { - console.log(` State backup is preserved at: ${backup.backupManifest.backupPath}`); - } return; } + let preservedMcpPolicyHandoff = false; const mcpPreparation = await runRebuildDestroyPhase({ sandboxName, sandboxEntry, @@ -379,7 +500,21 @@ async function rebuildSandboxUnlocked( log, bail, relockShieldsIfNeeded, - validateAfterMcpPreparation: async () => { + validateAfterMcpPreparation: async (preparation) => { + if (preparation.policyHandoff !== undefined) { + try { + if (!publishPolicyHandoff(preparation.policyHandoff)) { + throw new Error("publish failed"); + } + preservedMcpPolicyHandoff = true; + } catch { + return { + ok: false, + message: + "The complete live OpenShell policy could not be retained after MCP teardown.", + }; + } + } const providerReconfigure = recreateOptions.rebuildProviderReconfigure; if (providerReconfigure && !hydrateCredentialEnv(providerReconfigure.credentialEnv)) { return { @@ -411,16 +546,37 @@ async function rebuildSandboxUnlocked( recreateOptions.targetGatewayPort, ); }, - validateAtDeleteEdge: () => - revalidateManagedWorkloadRebuildBeforeDelete( - sandboxName, - recreateOptions.managedWorkloadRebuild, - ) ?? revalidateRebuildRouteBeforeDelete(routePreflightReceipt), + validateAtDeleteEdge: () => { + const validation = + revalidateManagedWorkloadRebuildBeforeDelete( + sandboxName, + recreateOptions.managedWorkloadRebuild, + ) ?? revalidateRebuildRouteBeforeDelete(routePreflightReceipt); + if (!validation.ok) return validation; + // Live MCP teardown temporarily removes credential-bound rules from + // the source sandbox. Its preparation returned the complete + // pre-teardown OpenShell document above and independently revalidates + // the stripped source policy. Do not overwrite that handoff with the + // temporary teardown state at the delete edge. + if (preservedMcpPolicyHandoff) return validation; + // A stale-recovery sandbox is already absent. Preflight admitted this + // path only after digest-verifying the policy handoff bound to the + // prepared recovery manifest, so there is no live policy to recapture. + if (staleRecovery) return validation; + return capturePolicyHandoff() + ? validation + : { + ok: false, + message: "The current OpenShell policy became unavailable before sandbox deletion.", + }; + }, onDeleted: () => { sandboxStillExists = false; + retainPolicyHandoffForRecovery = true; }, onDeleteStateAmbiguous: () => { sandboxExistenceAmbiguous = true; + retainPolicyHandoffForRecovery = true; }, }); if (!mcpPreparation) return; @@ -444,7 +600,7 @@ async function rebuildSandboxUnlocked( rebuildsHermesSandbox: rebuildAgent === "hermes", hermesToolGateways, hasHermesToolGateways, - sessionPolicyPresets: backup.sessionPolicyPresets, + policySourcePath: backup.policySourcePath, credentialEnv, baseImagePreflight, recoveryRecreate, @@ -464,49 +620,12 @@ async function rebuildSandboxUnlocked( } if (!recreated) return; - const completedInnerSession = onboardSession.loadSession(); - const freshInnerOnboardPolicyPresets = - completedInnerSession?.sandboxName === sandboxName && - Array.isArray(completedInnerSession.policyPresets) - ? completedInnerSession.policyPresets - : []; - const targetPolicyPresets = excludePolicyPresetsByName( - normalizeRebuildTargetPolicyPresets( - [...backup.policyPresets, ...freshInnerOnboardPolicyPresets], - { - ...sandboxEntry, - observabilityEnabled: recreateOptions.observabilityEnabled, - }, - durableConfig.webSearchConfig, - ), - mcpPreparation.entries.map((entry) => entry.policyName), - ); - const capturedCustomPolicies = - backup.backupManifest?.customPolicies?.map((entry) => ({ ...entry })) ?? - preservedCustomPolicies; - const customPoliciesWithRegistryPinAuthority = capturedCustomPolicies.map((entry) => { - const { trustedPrivatePins: _capturedPinAuthority, ...captured } = entry; - const registryAuthority = preservedCustomPolicies.find( - (candidate) => - candidate.name === entry.name && - candidate.content === entry.content && - candidate.trustedPrivatePins?.contentDigest === entry.trustedPrivatePins?.contentDigest, - )?.trustedPrivatePins; - return { - ...captured, - ...(registryAuthority ? { trustedPrivatePins: registryAuthority } : {}), - }; - }); - const restore = () => runRebuildRestorePhase({ sandboxName, targetAgentType: rebuildAgent || "openclaw", targetImageIsCustom: Boolean(fromDockerfile), backupManifest: backup.backupManifest, - policyPresets: targetPolicyPresets, - customPolicies: customPoliciesWithRegistryPinAuthority, - reconcileManagedDcodeObservability: rebuildAgent === DCODE_AGENT_NAME, log, }); let hermesCronRestoreIdentity: HermesCronRestoreIdentity | undefined; @@ -547,10 +666,6 @@ async function rebuildSandboxUnlocked( restoreSucceeded: restored.restoreSucceeded, hermesCronRestoreIdentity, backupWasForceSkipped: backup.backupWasForceSkipped, - failedPresets: restored.failedPresets, - finalBuiltinPresets: restored.finalBuiltinPresets, - failedPresetRemovals: restored.failedPresetRemovals, - policyPresetReconciliationVerified: restored.policyPresetReconciliationVerified, staleRecovery, recoveryRecreate, preparedBackupRecovery, @@ -560,7 +675,33 @@ async function rebuildSandboxUnlocked( log, bail, }); + if (backup.backupManifest) { + if ( + backup.backupManifest.rebuildPolicyHandoff && + !clearRebuildPolicyHandoff(backup.backupManifest) + ) { + return bail("The bounded rebuild policy handoff could not be retired after rebuild."); + } + clearRebuildRecoveryBackup({ + ...rebuildRecoveryIdentity, + backupManifest: backup.backupManifest, + }); + } + retainPolicyHandoffForRecovery = false; } finally { + const handoffManifest = rebuildPolicyHandoffManifest; + if (handoffManifest?.rebuildPolicyHandoff && !retainPolicyHandoffForRecovery) { + runBestEffortRebuildCleanup( + () => clearRebuildPolicyHandoff(handoffManifest), + " Warning: bounded rebuild policy handoff could not be removed.", + ); + } else if (rebuildPolicySourcePath && rebuildPolicySourceIsEphemeral) { + const retainedPolicySourcePath = rebuildPolicySourcePath; + runBestEffortRebuildCleanup( + () => cleanupTempDir(retainedPolicySourcePath, "nemoclaw-rebuild-policy"), + ` Warning: temporary rebuild policy handoff could not be removed. Remove ${retainedPolicySourcePath} before retrying.`, + ); + } if (!rebuildShieldsWindow.relocked && !sandboxExistenceAmbiguous) { relockShieldsIfNeeded(sandboxStillExists); } diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index f4a0fbb3c97..296aded2e20 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -91,7 +91,6 @@ describe("rebuild post-restore phase", () => { vi.spyOn(registry, "getSandbox").mockImplementation( () => ({ agent: agentName === "openclaw" ? null : agentName }) as never, ); - vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); vi.spyOn(registry, "updateSandbox").mockReturnValue(true); vi.spyOn(messagingHostForward, "ensureMessagingHostForwardAfterRebuild").mockImplementation( () => { @@ -198,17 +197,11 @@ describe("rebuild post-restore phase", () => { await runRebuildPostRestorePhase(args); - expect( - sessionModels.reconcileStalePinnedSessionModelsAfterRebuild, - ).not.toHaveBeenCalled(); + expect(sessionModels.reconcileStalePinnedSessionModelsAfterRebuild).not.toHaveBeenCalled(); expect(rebuildMessaging.reapplyMessagingManifestAfterOpenClawDoctor).not.toHaveBeenCalled(); expect(shields.repairMutableConfigPerms).not.toHaveBeenCalled(); - expect( - rebuildHermesPostRestore.restartHermesGatewayAfterStateRestore, - ).not.toHaveBeenCalled(); - expect( - rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestore, - ).not.toHaveBeenCalled(); + expect(rebuildHermesPostRestore.restartHermesGatewayAfterStateRestore).not.toHaveBeenCalled(); + expect(rebuildHermesPostRestore.verifyHermesGatewayAfterStateRestore).not.toHaveBeenCalled(); expect(rebuildMcp.restoreMcpAfterRebuild).not.toHaveBeenCalled(); expect( rebuildConfigHash.refreshMutableOpenClawConfigHashAfterPostRestoreWrites, @@ -552,29 +545,6 @@ describe("rebuild post-restore phase", () => { ); }); - it("discloses carried-over baseline exclusions in the successful rebuild summary (#7194)", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([ - { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]); - - await runRebuildPostRestorePhase(input()); - - expect( - logSpy.mock.calls.some( - (call) => - typeof call[0] === "string" && - call[0].includes("Baseline exclusions carried over: nous_research"), - ), - ).toBe(true); - }); - it("points Hermes rebuilds to the replacement API token retrieval command (#7175)", async () => { agentName = "hermes"; @@ -763,12 +733,16 @@ describe("rebuild post-restore phase", () => { "Mutable OpenClaw config hash was not refreshed", "Messaging webhook forward was not verified", "MCP bridge definitions were preserved but not fully refreshed", - "Policy presets failed to reapply: messaging-telegram", - "Exact live policy reconciliation was incomplete; remove failed: messaging-discord", "Shields were previously enabled", ]; const offsets = ordered.map((fragment) => output.indexOf(fragment)); expect(offsets.every((offset) => offset >= 0)).toBe(true); expect(offsets).toEqual([...offsets].sort((left, right) => left - right)); + expect(args.bail).toHaveBeenCalledWith( + "State restore remained incomplete after rebuilding 'alpha'.", + ); + expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain( + "nemoclaw alpha rebuild", + ); }); }); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 97c03adfd15..a8d740559f4 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -6,8 +6,6 @@ import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { D, G, R, YW } from "../../cli/terminal-style"; import type { SandboxMessagingPlan } from "../../messaging"; -import { normalizePolicyTierName } from "../../onboard/policy-tier-suppression"; -import { BASELINE_EXCLUSION_SUPPORT_IMPACT } from "../../policy/baseline-exclusion"; import type * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import * as registry from "../../state/registry"; @@ -83,10 +81,6 @@ export interface RebuildPostRestorePhaseInput { restoreSucceeded: boolean; hermesCronRestoreIdentity?: HermesCronRestoreIdentity; backupWasForceSkipped: boolean; - failedPresets: string[]; - finalBuiltinPresets: string[]; - failedPresetRemovals: string[]; - policyPresetReconciliationVerified: boolean; staleRecovery: boolean; recoveryRecreate: boolean; preparedBackupRecovery: boolean; @@ -106,39 +100,6 @@ interface SuccessfulRebuildSummaryInput { expectedVersion: string | null; } -/** Disclose carried-over baseline exclusions and their support impact after a rebuild. */ -export function printBaselineExclusionsRebuildSummary( - sandboxName: string, - writeLine: (message: string) => void = console.log, -): void { - const exclusions = registry.getBaselineExclusions(sandboxName); - if (exclusions.length === 0) return; - const keys = exclusions.map((exclusion) => exclusion.key).join(", "); - writeLine( - ` Baseline exclusions carried over: ${keys} \u2014 ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`, - ); -} - -export function printSuccessfulRebuildSummary( - input: SuccessfulRebuildSummaryInput, - writeLine: (message: string) => void = console.log, -): void { - writeLine(` ${G}\u2713${R} Sandbox '${input.sandboxName}' rebuilt successfully`); - if (input.backupWasForceSkipped) { - writeLine( - ` ${YW}\u26a0${R} Backup was skipped via --force after a total backup failure \u2014 prior workspace state was not preserved.`, - ); - } else if (input.staleRecovery && !input.backupManifest) { - writeLine( - ` ${D}Recovered from a stale registry entry \u2014 no prior workspace state was available to restore.${R}`, - ); - } - if (input.expectedVersion) { - writeLine(` Now running: ${input.rebuiltAgentName} v${input.expectedVersion}`); - } - printBaselineExclusionsRebuildSummary(input.sandboxName, writeLine); -} - function printHermesApiTokenChangeNotice(sandboxName: string, targetAgentName: string): void { if (targetAgentName !== "hermes") { return; @@ -149,23 +110,6 @@ function printHermesApiTokenChangeNotice(sandboxName: string, targetAgentName: s ); } -export function resolveRestoredPolicyRegistryState( - sandboxEntry: Pick, - restoredBuiltinPresets: readonly string[], - failedPresets: readonly string[], - policyPresetReconciliationVerified = true, -): { policies: string[]; policyPresetsFinalized: true | undefined } { - return { - policies: [...new Set(restoredBuiltinPresets)], - policyPresetsFinalized: - sandboxEntry.policyPresetsFinalized === true && - failedPresets.length === 0 && - policyPresetReconciliationVerified - ? true - : undefined, - }; -} - /** * Repair agent state, restore MCP/forwarding, reconcile the registry, and report * the final transaction result. Boundary coverage: rebuild-flow.test.ts and @@ -186,10 +130,6 @@ export async function runRebuildPostRestorePhase( restoreSucceeded, hermesCronRestoreIdentity, backupWasForceSkipped, - failedPresets, - finalBuiltinPresets, - failedPresetRemovals, - policyPresetReconciliationVerified, staleRecovery, recoveryRecreate, preparedBackupRecovery, @@ -230,10 +170,6 @@ export async function runRebuildPostRestorePhase( let mutableConfigHashRefreshUnverified = false; let finalMutableConfigHashUnverified = false; let messagingHostForwardUnverified = false; - const policyPresetRestoreIncomplete = - failedPresets.length > 0 || - failedPresetRemovals.length > 0 || - !policyPresetReconciliationVerified; if (targetAgentName === "openclaw") { log("Running openclaw doctor --fix inside sandbox for post-upgrade structure repair"); @@ -391,24 +327,10 @@ export async function runRebuildPostRestorePhase( } else if (hermesGatewayRestoreState === "recovered") { console.log(` ${G}\u2713${R} Hermes gateway recovered after state restore`); } - const { policies: restoredBuiltinPresets, policyPresetsFinalized } = - resolveRestoredPolicyRegistryState( - { - policyPresetsFinalized: sb.policyPresetsFinalized, - }, - finalBuiltinPresets, - failedPresets, - policyPresetReconciliationVerified, - ); registry.updateSandbox(sandboxName, { agentVersion: agentDef.expectedVersion || null, - policies: restoredBuiltinPresets, - policyTier: normalizePolicyTierName(sb.policyTier), - policyPresetsFinalized, }); - log( - `Registry updated: agentVersion=${agentDef.expectedVersion}, policies=[${restoredBuiltinPresets.join(",")}], policyPresetsFinalized=${String(policyPresetsFinalized === true)}`, - ); + log(`Registry updated: agentVersion=${agentDef.expectedVersion}`); if (!relockShieldsIfNeeded(true)) { bail("Failed to re-apply shields lockdown."); @@ -434,18 +356,13 @@ export async function runRebuildPostRestorePhase( mutableConfigHashRefreshUnverified: mutableConfigHashRefreshUnverified || finalMutableConfigHashUnverified, mutablePermsRepairUnverified, - policyPresetRestoreIncomplete, restoreSucceeded, }); if (postRestoreComplete) { - printSuccessfulRebuildSummary({ - sandboxName, - backupManifest, - backupWasForceSkipped, - staleRecovery, - rebuiltAgentName, - expectedVersion: versionCheck.expectedVersion, - }); + console.log(` ${G}✓${R} Sandbox '${sandboxName}' rebuild completed`); + if (versionCheck.expectedVersion) { + console.log(` Now running: ${rebuiltAgentName} v${versionCheck.expectedVersion}`); + } } else { console.log( ` ${YW}\u26a0${R} Sandbox '${sandboxName}' rebuilt but some post-restore steps were incomplete`, @@ -477,27 +394,17 @@ export async function runRebuildPostRestorePhase( } printHermesGatewayRestoreRecovery(sandboxName, hermesGatewayRestoreState); printMcpRestoreRecovery(sandboxName, mcpBridgeRestoreUnverified); - printBaselineExclusionsRebuildSummary(sandboxName); - if (policyPresetRestoreIncomplete) { - if (failedPresets.length > 0) { - console.log( - ` Policy presets failed to reapply: ${failedPresets.join(", ")} \u2014 re-apply manually with \`${CLI_NAME} ${sandboxName} policy add\``, - ); - } - if (failedPresetRemovals.length > 0 || !policyPresetReconciliationVerified) { - console.log( - ` Exact live policy reconciliation was incomplete${failedPresetRemovals.length > 0 ? `; remove failed: ${failedPresetRemovals.join(", ")}` : ""} \u2014 reconcile manually with \`${CLI_NAME} ${sandboxName} policy add\` or \`${CLI_NAME} ${sandboxName} policy remove\``, - ); - } - } } if (recoveryRecreate && staleSandboxWasLocked) { console.log( ` ${YW}\u26a0${R} Shields were previously enabled but the recreated sandbox starts unlocked \u2014 run \`${CLI_NAME} ${sandboxName} shields up\` to restore lockdown.`, ); } - if (failedPresetRemovals.length > 0 || !policyPresetReconciliationVerified) { - bail(`Rebuild completed with unverified live policy reconciliation for '${sandboxName}'.`); + if (!restoreSucceeded) { + console.error( + ` State recovery remains incomplete. Correct the restore error, then run \`${CLI_NAME} ${sandboxName} rebuild\` again.`, + ); + bail(`State restore remained incomplete after rebuilding '${sandboxName}'.`); return; } if ( diff --git a/src/lib/actions/sandbox/rebuild-preflight-guards.ts b/src/lib/actions/sandbox/rebuild-preflight-guards.ts index 0d8ca2ae3aa..86a3f5234ba 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-guards.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-guards.ts @@ -314,26 +314,6 @@ export function getRebuildSandboxEntryOrBail( return sb; } -/** Keep the pending baseline-policy transaction guard identical at every rebuild boundary. */ -export function blockRebuildOnPendingBaselineTransition( - sandboxEntry: RebuildSandboxEntry, - sandboxName: string, - bail: RebuildBail, -): boolean { - const transition = sandboxEntry.baselineExclusionTransition; - if (!transition) return false; - - const key = transition.exclusion.key; - printRebuildPreflightFailure( - `baseline policy ${transition.operation} for '${key}' needs repair before rebuild.`, - `Re-run: ${CLI_NAME} ${sandboxName} policy ${transition.operation} ${key}`, - `Pending baseline policy ${transition.operation} for '${key}' blocks rebuild.`, - bail, - 1, - ); - return true; -} - export function isSingleAgentRebuildSupported( sb: registry.SandboxEntry & { agents?: unknown[] }, bail: RebuildBail, diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index caa7b676ab3..215042bea4a 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -10,7 +10,7 @@ import { type HermesCronRestorePlan, validateHermesCronRestoreBackup, } from "../../state/rebuild/hermes-cron-restore-backup"; -import type { RebuildManifest } from "../../state/sandbox"; +import { readRebuildPolicyHandoff, type RebuildManifest } from "../../state/sandbox"; import { assertMcpDestroyNotPending } from "./mcp-bridge-state"; import { preflightRebuildCredentials, @@ -42,7 +42,6 @@ import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; import { acquireRebuildOnboardLock, assertRebuildEntryUnchanged, - blockRebuildOnPendingBaselineTransition, checkRebuildGatewaySchemaPreflight, expectedRebuildEntryAfterVersionCheck, getRebuildSandboxEntryOrBail, @@ -137,7 +136,6 @@ export async function runRebuildPreflightPhase( } = createRebuildCommandContext(options, opts); const sandboxEntry = getRebuildSandboxEntryOrBail(sandboxName, bail); if (!sandboxEntry) return null; - if (blockRebuildOnPendingBaselineTransition(sandboxEntry, sandboxName, bail)) return null; const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); // #6376: refuse a stuck MCP destroy transaction up front — before backup, // image prep, or the old-sandbox delete. The only MCP marker check used to @@ -274,7 +272,16 @@ export async function runRebuildPreflightPhase( baseImagePreflight = preparedTarget.baseImagePreflight; preparedImage = preparedTarget.preparedImage; - const liveState = await resolveRebuildLiveState(sandboxName, expectedSandboxEntry, log, bail); + const liveState = await resolveRebuildLiveState( + sandboxName, + expectedSandboxEntry, + log, + bail, + { + authoritativeRecoveryPolicyAvailable: + recoveryManifest !== null && readRebuildPolicyHandoff(recoveryManifest) !== null, + }, + ); if (!liveState) return null; if (isDcodeRebuildAgent(rebuildAgent)) { const recoveryRecreate = liveState.staleRecovery || recoveryManifest !== null; diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index dd3cc163f44..86644d1f065 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -179,7 +179,6 @@ describe("prepared rebuild recovery", () => { name: "alpha", provider: "compatible-endpoint", model: "new-model", - policies: ["npm", "github"], agent: null, agentVersion: "0.1.0", nemoclawVersion: "0.0.71", diff --git a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts index 5a7a01a30f9..1055485d79c 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.test.ts @@ -1,6 +1,10 @@ // 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, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -22,11 +26,15 @@ import type { CheckpointGatewayAuthority } from "../../state/onboard-checkpoint- import type { Session } from "../../state/onboard-session"; import * as onboardSession from "../../state/onboard-session"; import * as registry from "../../state/registry"; +import type { RebuildManifest } from "../../state/sandbox"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; import { + clearRebuildRecoveryBackup, + findRebuildRecoveryBackup, fingerprintRebuildRecreateTargetIntent, observeRebuildSandbox, openRebuildRecreateJournal, + recordRebuildRecoveryBackup, } from "./rebuild-recreate-journal"; const SANDBOX_ID = "sbx-0d6f4c2a91"; @@ -37,7 +45,7 @@ const HOST_MOUNT = { sourceIdentity: { device: "66306", inode: "12345" }, } as const; const PRE_HOST_MOUNT_FINGERPRINT = - "99603c8bf987561b783e2f38a1dcf260703537e5a680cae2198605ab13e181fe"; + "831bd40537ec3112f056079c89476ef2d62ce30664d0d11573c98301de81139e"; const NON_DEFAULT_TARGET = { sandboxName: "alpha", @@ -61,6 +69,7 @@ const recreateOptions: RebuildRecreateOnboardOpts = { nonInteractive: true, recreateSandbox: true, authoritativeResumeConfig: true, + rebuildPolicySourcePath: "/tmp/current-policy.yaml", acceptThirdPartySoftware: true, agent: "langchain-deepagents-code", recreateProvider: "nvidia", @@ -80,7 +89,6 @@ const recreateOptions: RebuildRecreateOnboardOpts = { dcodeAutoApprovalRequestedExplicitly: false, observabilityEnabled: true, observabilityRequestedExplicitly: true, - policyTier: "restricted", baseImageResolutionHint: null, }; @@ -122,7 +130,6 @@ describe("rebuild replacement target fingerprint", () => { it.each([ { dcodeAutoApprovalMode: "thread-opt-in" }, { endpointSource: "onboard" }, - { policyTier: "balanced" }, { recreateProvider: "compatible-endpoint" }, { recreateModel: "model-b" }, { recreatePreferredInferenceApi: "anthropic" }, @@ -540,3 +547,68 @@ describe("rebuild replacement journal", () => { expect(session.checkpoint?.sandboxRecreate?.phase).toBe("planned"); }); }); + +describe("rebuild replacement recovery backup", () => { + const transactionId = "11111111-1111-4111-8111-111111111111"; + const otherTransactionId = "22222222-2222-4222-8222-222222222222"; + let backupPath: string; + let manifest: RebuildManifest; + + const identity = (selectedTransactionId = transactionId) => ({ + sandboxName: "alpha", + agentName: "openclaw", + transactionId: selectedTransactionId, + }); + + beforeEach(() => { + backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-recovery-test-")); + manifest = { + version: 1, + sandboxName: "alpha", + timestamp: "2026-08-28T00-00-00-000Z", + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + }; + }); + + afterEach(() => { + fs.rmSync(backupPath, { recursive: true, force: true }); + }); + + const deps = () => ({ + listBackups: () => [{ ...manifest, snapshotVersion: 1 }], + validateManifest: (_name: string, _agent: string | null | undefined, value: RebuildManifest) => + ({ ok: true, manifest: value }) as const, + }); + + it("binds, resolves, and clears one transaction backup", () => { + recordRebuildRecoveryBackup({ ...identity(), backupManifest: manifest }, deps()); + + const recordPath = path.join(backupPath, ".nemoclaw-rebuild-recovery.json"); + expect(fs.statSync(recordPath).mode & 0o777).toBe(0o600); + expect(findRebuildRecoveryBackup(identity(), deps())).toEqual( + expect.objectContaining({ backupPath, timestamp: manifest.timestamp }), + ); + + clearRebuildRecoveryBackup({ ...identity(), backupManifest: manifest }, deps()); + expect(fs.existsSync(recordPath)).toBe(false); + }); + + it("rejects another transaction and preserves the original binding", () => { + recordRebuildRecoveryBackup({ ...identity(), backupManifest: manifest }, deps()); + + expect(() => + recordRebuildRecoveryBackup( + { ...identity(otherTransactionId), backupManifest: manifest }, + deps(), + ), + ).toThrow("already belongs to another transaction"); + expect(findRebuildRecoveryBackup(identity(), deps())).not.toBeNull(); + expect(findRebuildRecoveryBackup(identity(otherTransactionId), deps())).toBeNull(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-recreate-journal.ts b/src/lib/actions/sandbox/rebuild-recreate-journal.ts index 4be98a4ff75..2a1ddf41c0f 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-journal.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-journal.ts @@ -1,6 +1,9 @@ // 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 { checkpointGatewayAuthority, gatewayOwnerFromCheckpoint, @@ -34,8 +37,214 @@ import type { } from "../../state/onboard-checkpoint-types"; import * as onboardSession from "../../state/onboard-session"; import * as registry from "../../state/registry"; +import { + listBackups, + type RebuildManifest, + type SnapshotEntry, + validateRebuildRecoveryManifest, +} from "../../state/sandbox"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; +const REBUILD_RECOVERY_FILE = ".nemoclaw-rebuild-recovery.json"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +type RebuildRecoveryBackupRecord = { + readonly schemaVersion: 1; + readonly transactionId: string; + readonly sandboxName: string; + readonly backupTimestamp: string; +}; + +type RebuildRecoveryBackupIdentity = { + readonly sandboxName: string; + readonly agentName: string | null | undefined; + readonly transactionId: string; +}; + +interface RebuildRecoveryBackupDeps { + readonly listBackups?: typeof listBackups; + readonly validateManifest?: typeof validateRebuildRecoveryManifest; +} + +function validateRecoveryIdentity(input: RebuildRecoveryBackupIdentity): void { + if (!UUID_PATTERN.test(input.transactionId)) { + throw new Error("Rebuild recovery transaction identity is invalid."); + } +} + +function recoveryPath(backupPath: string): string { + return path.join(backupPath, REBUILD_RECOVERY_FILE); +} + +function syncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, "r"); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function validatedRecoveryManifest( + input: RebuildRecoveryBackupIdentity, + manifest: RebuildManifest, + deps: RebuildRecoveryBackupDeps, +): RebuildManifest { + const validation = (deps.validateManifest ?? validateRebuildRecoveryManifest)( + input.sandboxName, + input.agentName, + manifest, + ); + if (!validation.ok) { + throw new Error(`Rebuild recovery backup is invalid: ${validation.reason}.`); + } + return validation.manifest; +} + +function parseRecoveryRecord(raw: string): RebuildRecoveryBackupRecord | null { + try { + const value = JSON.parse(raw) as Record; + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + JSON.stringify(Object.keys(value).sort()) !== + JSON.stringify(["backupTimestamp", "sandboxName", "schemaVersion", "transactionId"]) || + value.schemaVersion !== 1 || + typeof value.transactionId !== "string" || + !UUID_PATTERN.test(value.transactionId) || + typeof value.sandboxName !== "string" || + typeof value.backupTimestamp !== "string" + ) { + return null; + } + return value as RebuildRecoveryBackupRecord; + } catch { + return null; + } +} + +function readRecoveryRecord(backupPath: string): RebuildRecoveryBackupRecord | null { + const filePath = recoveryPath(backupPath); + let descriptor: number; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + const uid = process.getuid?.(); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1n || + (uid !== undefined && before.uid !== BigInt(uid)) || + (before.mode & 0o777n) !== 0o600n || + before.size < 1n || + before.size > 4096n + ) { + throw new Error("Rebuild recovery backup record authority is invalid."); + } + const raw = fs.readFileSync(descriptor, "utf8"); + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new Error("Rebuild recovery backup record changed while it was read."); + } + return parseRecoveryRecord(raw); + } finally { + fs.closeSync(descriptor); + } +} + +function recoveryRecordMatches( + record: RebuildRecoveryBackupRecord | null, + input: RebuildRecoveryBackupIdentity, + manifest: RebuildManifest, +): boolean { + return ( + record?.transactionId === input.transactionId && + record.sandboxName === input.sandboxName && + record.backupTimestamp === manifest.timestamp + ); +} + +/** Bind one published backup to the active outer rebuild transaction. */ +export function recordRebuildRecoveryBackup( + input: RebuildRecoveryBackupIdentity & { readonly backupManifest: RebuildManifest }, + deps: RebuildRecoveryBackupDeps = {}, +): void { + validateRecoveryIdentity(input); + const manifest = validatedRecoveryManifest(input, input.backupManifest, deps); + const existing = readRecoveryRecord(manifest.backupPath); + if (existing) { + if (!recoveryRecordMatches(existing, input, manifest)) { + throw new Error("Rebuild recovery backup already belongs to another transaction."); + } + return; + } + const record: RebuildRecoveryBackupRecord = { + schemaVersion: 1, + transactionId: input.transactionId, + sandboxName: input.sandboxName, + backupTimestamp: manifest.timestamp, + }; + const descriptor = fs.openSync( + recoveryPath(manifest.backupPath), + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + syncDirectory(manifest.backupPath); +} + +/** Find the exact backup bound to an interrupted replacement transaction. */ +export function findRebuildRecoveryBackup( + input: RebuildRecoveryBackupIdentity, + deps: RebuildRecoveryBackupDeps = {}, +): SnapshotEntry | null { + validateRecoveryIdentity(input); + for (const candidate of (deps.listBackups ?? listBackups)(input.sandboxName)) { + const record = readRecoveryRecord(candidate.backupPath); + if (record?.transactionId !== input.transactionId) continue; + const manifest = validatedRecoveryManifest(input, candidate, deps); + if (recoveryRecordMatches(record, input, manifest)) return candidate; + } + return null; +} + +/** Retire the bounded recovery record after restore and post-restore succeed. */ +export function clearRebuildRecoveryBackup( + input: RebuildRecoveryBackupIdentity & { readonly backupManifest: RebuildManifest }, + deps: RebuildRecoveryBackupDeps = {}, +): void { + validateRecoveryIdentity(input); + const manifest = validatedRecoveryManifest(input, input.backupManifest, deps); + if (!recoveryRecordMatches(readRecoveryRecord(manifest.backupPath), input, manifest)) { + throw new Error("Rebuild recovery backup record is missing or changed."); + } + fs.unlinkSync(recoveryPath(manifest.backupPath)); + syncDirectory(manifest.backupPath); +} + export type RebuildRecreateJournalTarget = SandboxRecreateTarget; export type RebuildSandboxObserver = SandboxRecreateObserver; @@ -73,7 +282,6 @@ export function fingerprintRebuildRecreateTargetIntent( | "toolDisclosure" | "dcodeAutoApprovalMode" | "observabilityEnabled" - | "policyTier" >, ): string { const hostMounts = (options.hostMounts ?? []).map( @@ -106,7 +314,6 @@ export function fingerprintRebuildRecreateTargetIntent( toolDisclosure: options.toolDisclosure, dcodeAutoApprovalMode: options.dcodeAutoApprovalMode, observabilityEnabled: options.observabilityEnabled, - policyTier: options.policyTier, }); } diff --git a/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts b/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts index f4873859e9e..1afaea6d6d7 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts @@ -67,6 +67,7 @@ const recreateOptions: RebuildRecreateOnboardOpts = { nonInteractive: true, recreateSandbox: true, authoritativeResumeConfig: true, + rebuildPolicySourcePath: "/tmp/current-policy.yaml", acceptThirdPartySoftware: true, agent: DCODE_AGENT, recreateProvider: "nvidia", @@ -85,7 +86,6 @@ const recreateOptions: RebuildRecreateOnboardOpts = { dcodeAutoApprovalRequestedExplicitly: false, observabilityEnabled: true, observabilityRequestedExplicitly: true, - policyTier: "restricted", baseImageResolutionHint: null, rebuildGatewayAuthority: STANDALONE_GATEWAY_AUTHORITY, }; @@ -125,7 +125,6 @@ function makeInput(overrides: Partial = {}): RebuildR name: "alpha", agent: DCODE_AGENT, observabilityEnabled: true, - policyTier: "restricted", }, sessionSnapshot: onboardSession.createSession({ sandboxName: "alpha", @@ -153,7 +152,7 @@ function makeInput(overrides: Partial = {}): RebuildR rebuildsHermesSandbox: false, hermesToolGateways: [], hasHermesToolGateways: false, - sessionPolicyPresets: ["observability-otlp-local"], + policySourcePath: "/tmp/current-policy.yaml", credentialEnv: "NVIDIA_API_KEY", baseImagePreflight: { ok: true, imageRef: null, overrideEnvVar: null }, recoveryRecreate: false, @@ -338,24 +337,6 @@ describe("runRebuildRecreatePhase handoff", () => { expect(onboardSpy).not.toHaveBeenCalled(); }); - it("pins the authoritative restricted tier during recreate and restores ambient policy input", async () => { - const previousPolicyTier = process.env.NEMOCLAW_POLICY_TIER; - process.env.NEMOCLAW_POLICY_TIER = "open"; - try { - let observedTier: string | undefined; - vi.spyOn(rebuildOnboardDependencies, "onboard").mockImplementation(async () => { - observedTier = process.env.NEMOCLAW_POLICY_TIER; - }); - - await expect(runRebuildRecreatePhase(makeInput())).resolves.toBe(true); - - expect(observedTier).toBe("restricted"); - expect(process.env.NEMOCLAW_POLICY_TIER).toBe("open"); - } finally { - restoreEnv("NEMOCLAW_POLICY_TIER", previousPolicyTier); - } - }); - it("does not take a second backup during the inner recreate", async () => { const previousRecreateWithoutBackup = process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP; delete process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP; @@ -400,7 +381,6 @@ describe("runRebuildRecreatePhase handoff", () => { name: "alpha", agent: "hermes", observabilityEnabled: true, - policyTier: "restricted", }, rebuildAgent: "hermes", rebuildsHermesSandbox: true, diff --git a/src/lib/actions/sandbox/rebuild-recreate-phase.ts b/src/lib/actions/sandbox/rebuild-recreate-phase.ts index 146bf74f0c3..502c3232756 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-phase.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-phase.ts @@ -15,7 +15,7 @@ import type { Session } from "../../state/onboard-session"; import * as onboardSession from "../../state/onboard-session"; import * as registry from "../../state/registry"; import { cloneSandboxHostMounts } from "../../state/registry/host-mount"; -import { excludePolicyPresetsByName, type RebuildBackupManifest } from "./rebuild-backup-phase"; +import type { RebuildBackupManifest } from "./rebuild-backup-phase"; import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight"; import type { RebuildDurableConfig } from "./rebuild-durable-config"; import { isolateAmbientRecreateEnv } from "./rebuild-env-isolation"; @@ -54,7 +54,7 @@ export interface RebuildRecreatePhaseInput { rebuildsHermesSandbox: boolean; hermesToolGateways: string[]; hasHermesToolGateways: boolean; - sessionPolicyPresets: string[] | null; + policySourcePath?: string; credentialEnv: string | null; baseImagePreflight: RebuildAgentBaseImagePreflight; recoveryRecreate: boolean; @@ -89,7 +89,7 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): rebuildsHermesSandbox, hermesToolGateways: rebuildHermesToolGateways, hasHermesToolGateways: hasRebuildHermesToolGateways, - sessionPolicyPresets: rebuildSessionPolicyPresets, + policySourcePath: rebuildPolicySourcePath, credentialEnv: rebuildCredentialEnv, baseImagePreflight: rebuildBaseImagePreflight, recoveryRecreate, @@ -102,16 +102,12 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): log, bail, } = input; + if (!rebuildPolicySourcePath) { + return input.bail("Rebuild has no captured OpenShell policy source."); + } console.log(""); console.log(" Creating new sandbox with current image..."); - const recreatePolicyPresets = Array.isArray(rebuildSessionPolicyPresets) - ? excludePolicyPresetsByName( - rebuildSessionPolicyPresets, - rebuildMcpEntries.map((entry) => entry.policyName), - ) - : null; - const rebuildGpuOverrides = getRebuildSandboxGpuOverrides(sb); log( `Session before update: sandboxName=${sessionBefore?.sandboxName}, status=${sessionBefore?.status}, resumable=${sessionBefore?.resumable}, provider=${sessionBefore?.provider}, model=${sessionBefore?.model}, sessionMatch=${sessionMatchesSandbox}`, @@ -202,11 +198,6 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): s.agent = rebuildAgent; s.messagingPlan = rebuildMessagingPlan; s.hermesToolGateways = rebuildsHermesSandbox ? rebuildHermesToolGateways : []; - // MCP preparation removes these generated policies before sandbox delete, - // and the dedicated post-rebuild phase restores them with their provider - // bindings. Do not ask inner onboarding to resolve their stale preset names - // as built-ins while the generated definitions are intentionally absent. - s.policyPresets = recreatePolicyPresets; s.gpuPassthrough = rebuildGpuOverrides.sessionGpuPassthrough; s.metadata.fromDockerfile = storedFromDockerfile; s.provider = resumeConfig.provider; @@ -265,9 +256,6 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): // this call; remove it when onboard accepts an explicit outer-backup handoff. process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP = "1"; if (rebuildMessagingPlan) MessagingSetupApplier.writePlanToEnv(rebuildMessagingPlan); - if (recreateOptions.policyTier) { - process.env.NEMOCLAW_POLICY_TIER = recreateOptions.policyTier; - } // Isolation removed the ambient reasoning inputs so an unrelated onboard // cannot steer this recreate (#5735). The recreate still has to reapply the // *recorded* compatible-endpoint reasoning configuration: both the recovered @@ -285,9 +273,7 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): await rebuildOnboardDependencies.onboard({ ...recreateOptions, rebuildGatewayAuthority, - ...(Array.isArray(recreatePolicyPresets) - ? { rebuildPolicyPresets: recreatePolicyPresets } - : {}), + rebuildPolicySourcePath, ...(rebuildsHermesSandbox && backupManifest?.preservedEnv ? { rebuildPreservedEnv: backupManifest.preservedEnv } : {}), diff --git a/src/lib/actions/sandbox/rebuild-recreate-reasoning.test.ts b/src/lib/actions/sandbox/rebuild-recreate-reasoning.test.ts index 55b1355942a..e078566aaf2 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-reasoning.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-reasoning.test.ts @@ -63,6 +63,7 @@ const recreateOptions: RebuildRecreateOnboardOpts = { nonInteractive: true, recreateSandbox: true, authoritativeResumeConfig: true, + rebuildPolicySourcePath: "/tmp/current-policy.yaml", acceptThirdPartySoftware: true, agent: "openclaw", recreateProvider: "compatible-endpoint", @@ -81,7 +82,6 @@ const recreateOptions: RebuildRecreateOnboardOpts = { dcodeAutoApprovalRequestedExplicitly: false, observabilityEnabled: false, observabilityRequestedExplicitly: false, - policyTier: null, baseImageResolutionHint: null, rebuildGatewayAuthority: GATEWAY_AUTHORITY, }; @@ -115,7 +115,7 @@ function makeInput(overrides: Partial = {}): RebuildR rebuildsHermesSandbox: false, hermesToolGateways: [], hasHermesToolGateways: false, - sessionPolicyPresets: ["telegram"], + policySourcePath: "/tmp/current-policy.yaml", credentialEnv: "COMPATIBLE_API_KEY", baseImagePreflight: { ok: true, imageRef: null, overrideEnvVar: null }, recoveryRecreate: true, diff --git a/src/lib/actions/sandbox/rebuild-registry-rollback.test.ts b/src/lib/actions/sandbox/rebuild-registry-rollback.test.ts index aa1f279d99e..948a477a4dd 100644 --- a/src/lib/actions/sandbox/rebuild-registry-rollback.test.ts +++ b/src/lib/actions/sandbox/rebuild-registry-rollback.test.ts @@ -10,7 +10,6 @@ function sandboxEntry(overrides: Partial = {}): SandboxEntry { return { name: "alpha", imageTag: "nemoclaw/alpha:old", - policies: ["github"], ...overrides, }; } @@ -73,7 +72,7 @@ describe("createRebuildRegistryRollback", () => { }); it("restores an ordinary removal receipt only when no replacement exists", () => { - const removed = sandboxEntry({ customPolicies: [{ name: "custom", content: "allow" }] }); + const removed = sandboxEntry(); const restoreSandboxEntryIfMissing = vi.fn(() => true); const log = vi.fn(); const rollback = createRebuildRegistryRollback( diff --git a/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts b/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts index 84b3e9a3369..093f692063d 100644 --- a/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts @@ -28,8 +28,6 @@ describe("rebuild restore target forwarding", () => { targetAgentType: "langchain-deepagents-code", targetImageIsCustom: true, backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never, - policyPresets: [], - customPolicies: [], reconcileManagedDcodeObservability: false, log: vi.fn(), }); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index 6e38a5c9c0d..aabc79a066d 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -1,50 +1,83 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as policies from "../../policy"; import * as sandboxConfig from "../../sandbox/config"; -import * as sandboxState from "../../state/sandbox"; -import { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; -import { - printSuccessfulRebuildSummary, - resolveRestoredPolicyRegistryState, -} from "./rebuild-post-restore-phase"; import { runRebuildRestorePhase } from "./rebuild-restore-phase"; import * as snapshotRestore from "./snapshot/restore-authority"; -const BUILTIN_OBSERVABILITY_CONTENT = - "network_policies:\n observability-otlp-local:\n name: observability-otlp-local\n"; - -type StandardRestoreOptions = Omit< - Parameters[0], - "targetAgentType" | "targetImageIsCustom" ->; +const backupManifest = { + agentType: "openclaw", + backupPath: "/tmp/rebuild-backup", +} as never; -function runStandardRebuildRestorePhase(options: StandardRestoreOptions) { - return runRebuildRestorePhase({ - ...options, - targetAgentType: "openclaw", - targetImageIsCustom: false, +describe("rebuild filesystem restore", () => { + afterEach(() => { + vi.restoreAllMocks(); }); -} -describe("rebuild policy restore fidelity", () => { - beforeEach(() => { - vi.spyOn(policies, "loadPresetForSandbox").mockImplementation((_sandboxName, presetName) => - presetName === "observability-otlp-local" ? BUILTIN_OBSERVABILITY_CONTENT : null, + it("restores through managed snapshot authority without replaying policy state", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const restore = vi + .spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority") + .mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + }); + + const result = runRebuildRestorePhase({ + sandboxName: "alpha", + targetAgentType: "openclaw", + targetImageIsCustom: false, + backupManifest, + log: vi.fn(), + }); + + expect(restore).toHaveBeenCalledWith( + "alpha", + backupManifest, + { targetAgentType: "openclaw" }, + { getSandbox: expect.any(Function) }, ); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); + expect(result).toEqual({ restoreSucceeded: true }); }); - afterEach(() => { - vi.restoreAllMocks(); + it("allows whole-state file restore only for an explicit custom image", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const restore = vi + .spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority") + .mockReturnValue({ + success: true, + restoredDirs: [], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + + runRebuildRestorePhase({ + sandboxName: "alpha", + targetAgentType: "openclaw", + targetImageIsCustom: true, + backupManifest, + log: vi.fn(), + }); + + expect(restore).toHaveBeenCalledWith( + "alpha", + backupManifest, + { + targetAgentType: "openclaw", + allowCustomImageWholeStateFileRestore: true, + }, + { getSandbox: expect.any(Function) }, + ); }); - it("migrates restored legacy Hermes dashboard state into its profile", () => { + it("migrates restored Hermes dashboard state into its current profile", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority").mockReturnValue({ success: true, @@ -62,7 +95,7 @@ describe("rebuild policy restore fidelity", () => { stateLockPlanInImage: true, } as const; vi.spyOn(sandboxConfig, "resolveAgentConfig").mockReturnValue(target); - const seedDashboard = vi + const migrate = vi .spyOn(sandboxConfig, "restoreHermesDashboardConfig") .mockReturnValue("converged"); const log = vi.fn(); @@ -71,58 +104,18 @@ describe("rebuild policy restore fidelity", () => { sandboxName: "hermes", targetAgentType: "hermes", targetImageIsCustom: false, - backupManifest: { agentType: "hermes", backupPath: "/tmp/rebuild-backup" } as never, - policyPresets: [], - customPolicies: [], - reconcileManagedDcodeObservability: false, + backupManifest, log, }); - expect(seedDashboard).toHaveBeenCalledWith("hermes", target); + expect(migrate).toHaveBeenCalledWith("hermes", target); expect(log).toHaveBeenCalledWith("Hermes dashboard state after restore: converged"); - expect(result.restoreSucceeded).toBe(true); - }); - - it("reports a failed Hermes dashboard migration as an incomplete restore", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority").mockReturnValue({ - success: true, - restoredDirs: ["dashboard-home"], - restoredFiles: [], - failedDirs: [], - failedFiles: [], - }); - vi.spyOn(sandboxConfig, "resolveAgentConfig").mockReturnValue({ - agentName: "hermes", - configDir: "/sandbox/.hermes", - configPath: "/sandbox/.hermes/config.yaml", - configFile: "config.yaml", - format: "yaml", - stateLockPlanInImage: true, - }); - vi.spyOn(sandboxConfig, "restoreHermesDashboardConfig").mockReturnValue("failed"); - - const result = runRebuildRestorePhase({ - sandboxName: "hermes", - targetAgentType: "hermes", - targetImageIsCustom: false, - backupManifest: { agentType: "hermes", backupPath: "/tmp/rebuild-backup" } as never, - policyPresets: [], - customPolicies: [], - reconcileManagedDcodeObservability: false, - log: vi.fn(), - }); - - expect(result.restoreSucceeded).toBe(false); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining("Could not migrate restored Hermes dashboard state into its profile"), - ); + expect(result).toEqual({ restoreSucceeded: true }); }); - it("reports an unresolved Hermes target as an incomplete restore", () => { + it("reports an unresolved or failed Hermes dashboard migration as incomplete", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority").mockReturnValue({ success: true, restoredDirs: ["dashboard-home"], @@ -138,27 +131,21 @@ describe("rebuild policy restore fidelity", () => { format: "json", stateLockPlanInImage: true, }); - const seedDashboard = vi.spyOn(sandboxConfig, "restoreHermesDashboardConfig"); + const migrate = vi.spyOn(sandboxConfig, "restoreHermesDashboardConfig"); const result = runRebuildRestorePhase({ sandboxName: "hermes", targetAgentType: "hermes", targetImageIsCustom: false, - backupManifest: { agentType: "hermes", backupPath: "/tmp/rebuild-backup" } as never, - policyPresets: [], - customPolicies: [], - reconcileManagedDcodeObservability: false, + backupManifest, log: vi.fn(), }); - expect(seedDashboard).not.toHaveBeenCalled(); - expect(result.restoreSucceeded).toBe(false); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining("Could not migrate restored Hermes dashboard state into its profile"), - ); + expect(migrate).not.toHaveBeenCalled(); + expect(result).toEqual({ restoreSucceeded: false }); }); - it("surfaces a fresh OpenClaw plugin registry precondition failure", () => { + it("surfaces a filesystem restore failure without inventing policy recovery", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const log = vi.fn(); @@ -171,16 +158,15 @@ describe("rebuild policy restore fidelity", () => { error: "could not read fresh OpenClaw plugin install registry", }); - const result = runStandardRebuildRestorePhase({ + const result = runRebuildRestorePhase({ sandboxName: "alpha", - backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never, - policyPresets: [], - customPolicies: [], - reconcileManagedDcodeObservability: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, + backupManifest, log, }); - expect(result.restoreSucceeded).toBe(false); + expect(result).toEqual({ restoreSucceeded: false }); expect(consoleError).toHaveBeenCalledWith( " Restore blocked: could not read fresh OpenClaw plugin install registry", ); @@ -188,497 +174,4 @@ describe("rebuild policy restore fidelity", () => { expect.stringContaining("error=could not read fresh OpenClaw plugin install registry"), ); }); - - it("replays custom web-policy names from exact content instead of same-name built-ins", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - const parsePresetPolicyKeys = vi.spyOn(policies, "parsePresetPolicyKeys"); - const restoreRecreatedSandboxState = vi - .spyOn(snapshotRestore, "restoreRecreatedSandboxStateWithManagedAuthority") - .mockReturnValue({ - success: true, - restoredDirs: [], - restoredFiles: [], - failedDirs: [], - failedFiles: [], - }); - const applyPreset = vi.spyOn(policies, "applyPreset").mockReturnValue(true); - const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - const customPolicies = ["brave", "tavily", "nous-web"].map((name) => ({ - name, - content: `network_policies:\n ${name}-custom:\n name: ${name}-custom\n`, - sourcePath: `/tmp/${name}.yaml`, - })); - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: { - agentType: "openclaw", - backupPath: "/tmp/rebuild-backup", - customPolicies, - } as never, - policyPresets: ["npm", "brave", "tavily", "nous-web"], - customPolicies, - reconcileManagedDcodeObservability: false, - log: vi.fn(), - }); - - expect(restoreRecreatedSandboxState).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ backupPath: "/tmp/rebuild-backup" }), - { - targetAgentType: "openclaw", - }, - { getSandbox: expect.any(Function) }, - ); - expect(applyPreset).toHaveBeenCalledOnce(); - expect(applyPreset).toHaveBeenCalledWith("alpha", "npm"); - customPolicies.forEach((entry) => { - expect(applyPresetContent).toHaveBeenCalledWith("alpha", entry.name, entry.content, { - custom: { sourcePath: entry.sourcePath }, - }); - }); - expect(result.restoredPresets).toEqual(["npm", "brave", "tavily", "nous-web"]); - expect(result.failedPresets).toEqual([]); - expect(result.finalPresets).toEqual(["npm", "brave", "tavily", "nous-web"]); - expect(result.policyPresetReconciliationVerified).toBe(true); - expect(policies.loadPresetForSandbox).not.toHaveBeenCalled(); - expect(parsePresetPolicyKeys).not.toHaveBeenCalled(); - }); - - it("replays captured registry custom policies during stale recovery without a backup", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(sandboxState, "restoreSandboxState").mockReturnValue({ - success: true, - restoredDirs: [], - restoredFiles: [], - failedDirs: [], - failedFiles: [], - }); - const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - const privateContent = - "network_policies:\n custom-egress:\n endpoints:\n - host: api.corp.example\n allowed_ips: [10.20.30.40]\n"; - const customPolicies = [ - { - name: "custom-egress", - content: privateContent, - sourcePath: "/tmp/custom-egress.yaml", - trustedPrivatePins: { - version: 1 as const, - contentDigest: createHash("sha256").update(privateContent).digest("hex"), - }, - }, - ]; - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies, - reconcileManagedDcodeObservability: false, - log: vi.fn(), - }); - - expect(applyPresetContent).toHaveBeenCalledWith( - "alpha", - "custom-egress", - customPolicies[0]!.content, - { - custom: { - sourcePath: "/tmp/custom-egress.yaml", - trustedPrivatePinCapability: expect.objectContaining({ - receipt: customPolicies[0]!.trustedPrivatePins, - }), - }, - }, - ); - expect(result.restoredPresets).toEqual(["custom-egress"]); - expect(result.finalPresets).toEqual(["custom-egress"]); - }); - - it("leaves generated MCP policy replay exclusively to MCP restoration", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - const genuineCustomPolicy = { - name: "custom-egress", - content: "network_policies:\n custom-egress: {}\n", - sourcePath: "/tmp/custom-egress.yaml", - }; - const generatedMcpPolicy = { - name: "mcp-bridge-search", - content: - "network_policies:\n mcp-bridge-search:\n endpoints:\n - host: mcp.example.com\n allowed_ips: [203.0.113.10]\n", - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }; - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies: [genuineCustomPolicy, generatedMcpPolicy], - reconcileManagedDcodeObservability: false, - log: vi.fn(), - }); - - expect(applyPresetContent).toHaveBeenCalledOnce(); - expect(applyPresetContent).toHaveBeenCalledWith( - "alpha", - genuineCustomPolicy.name, - genuineCustomPolicy.content, - { custom: { sourcePath: genuineCustomPolicy.sourcePath } }, - ); - expect(result.restoredPresets).toEqual([genuineCustomPolicy.name]); - expect(result.failedPresets).toEqual([]); - }); - - it("removes an observability preset introduced while rebuilding a restricted sandbox", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(policies, "applyPreset").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("match") - .mockReturnValueOnce("absent"); - const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["npm"], - customPolicies: [], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(removePreset).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(result.finalPresets).toEqual(["npm"]); - expect(result.failedPresetRemovals).toEqual([]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("retains an observed exact built-in when post-removal verification is unavailable", () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(policies, "applyPreset").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("match") - .mockReturnValueOnce(null); - vi.spyOn(policies, "removePreset").mockReturnValue(true); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["npm"], - customPolicies: [], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(result.finalPresets).toEqual(["npm", "observability-otlp-local"]); - expect(result.policyPresetReconciliationVerified).toBe(false); - }); - - it("accounts for known failed additions without treating a narrower live set as unverified", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(policies, "applyPreset") - .mockImplementationOnce((_name, presetName) => { - expect(presetName).toBe("npm"); - return true; - }) - .mockImplementationOnce((_name, presetName) => { - expect(presetName).toBe("bad"); - return false; - }) - .mockImplementationOnce((_name, presetName) => { - expect(presetName).toBe("throw"); - throw new Error("apply failed"); - }); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["npm", "bad", "throw"], - customPolicies: [], - reconcileManagedDcodeObservability: false, - log: vi.fn(), - }); - - expect(result.restoredPresets).toEqual(["npm"]); - expect(result.failedPresets).toEqual(["bad", "throw"]); - expect(result.finalPresets).toEqual(["npm"]); - expect(result.failedPresetRemovals).toEqual([]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("keeps reconciliation unverified when a reported successful addition is missing live", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(policies, "applyPreset").mockReturnValue(true); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["observability-otlp-local"], - customPolicies: [], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(result.restoredPresets).toEqual(["observability-otlp-local"]); - expect(result.failedPresets).toEqual([]); - expect(result.finalPresets).toEqual([]); - expect(result.policyPresetReconciliationVerified).toBe(false); - }); - - it("retains target built-in attribution when exact post-apply verification is unavailable", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(policies, "applyPreset").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue(null); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["observability-otlp-local"], - customPolicies: [], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(result.finalBuiltinPresets).toEqual(["observability-otlp-local"]); - expect(result.policyPresetReconciliationVerified).toBe(false); - }); - - it("does not remove or persist DCode base-policy keys detected as broad presets", () => { - const removePreset = vi.spyOn(policies, "removePreset"); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies: [], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(removePreset).not.toHaveBeenCalled(); - expect(result.finalPresets).toEqual([]); - expect(result.failedPresetRemovals).toEqual([]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("leaves a same-name custom observability policy outside built-in narrowing", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - const removePreset = vi.spyOn(policies, "removePreset"); - const customPolicy = { - name: "observability-otlp-local", - content: "network_policies:\n operator-collector: {}\n", - sourcePath: "/tmp/operator-collector.yaml", - }; - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies: [customPolicy], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(applyPresetContent).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(removePreset).not.toHaveBeenCalled(); - expect(result.finalPresets).toEqual([customPolicy.name]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("leaves a differently named custom policy owning observability egress outside built-in narrowing", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - const exactState = vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("match"); - const removePreset = vi.spyOn(policies, "removePreset"); - const customPolicy = { - name: "corp-otel", - content: - "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", - sourcePath: "/tmp/corp-otel.yaml", - }; - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies: [customPolicy], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(applyPresetContent).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(exactState).toHaveBeenCalledWith("alpha", customPolicy.content); - expect(removePreset).not.toHaveBeenCalled(); - expect(result.finalPresets).toEqual([customPolicy.name]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("removes an exact inner built-in behind a same-name custom with a different key", () => { - vi.spyOn(console, "log").mockImplementation(() => undefined); - const customPolicy = { - name: "observability-otlp-local", - content: "network_policies:\n operator-collector: {}\n", - sourcePath: "/tmp/operator-collector.yaml", - }; - vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("match") - .mockReturnValueOnce("absent"); - const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["observability-otlp-local"], - customPolicies: [customPolicy], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(removePreset).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(result.finalPresets).toEqual([customPolicy.name]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("removes an exact inner built-in when overlapping custom replay fails", () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const customPolicy = { - name: "corp-otel", - content: "network_policies:\n observability-otlp-local: {}\n", - sourcePath: "/tmp/corp-otel.yaml", - }; - vi.spyOn(policies, "applyPresetContent").mockReturnValue(false); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("match") - .mockReturnValueOnce("absent"); - const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies: [customPolicy], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(result.failedPresets).toEqual([customPolicy.name]); - expect(removePreset).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(result.finalPresets).toEqual([]); - expect(result.policyPresetReconciliationVerified).toBe(true); - }); - - it("leaves drift untouched and unverified when successful custom ownership is not exact", () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const customPolicy = { - name: "corp-otel", - content: "network_policies:\n observability-otlp-local: {}\n", - sourcePath: "/tmp/corp-otel.yaml", - }; - vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("drift"); - const removePreset = vi.spyOn(policies, "removePreset"); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: [], - customPolicies: [customPolicy], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(removePreset).not.toHaveBeenCalled(); - expect(result.finalPresets).toEqual([customPolicy.name]); - expect(result.policyPresetReconciliationVerified).toBe(false); - }); - - it("retains separate built-in attribution when same-name custom removal is unverified", () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const customPolicy = { - name: "observability-otlp-local", - content: "network_policies:\n operator-collector: {}\n", - sourcePath: "/tmp/operator-collector.yaml", - }; - vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); - vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("match") - .mockReturnValueOnce(null); - vi.spyOn(policies, "removePreset").mockReturnValue(true); - - const result = runStandardRebuildRestorePhase({ - sandboxName: "alpha", - backupManifest: null, - policyPresets: ["observability-otlp-local"], - customPolicies: [customPolicy], - reconcileManagedDcodeObservability: true, - log: vi.fn(), - }); - - expect(result.finalPresets).toEqual(["observability-otlp-local"]); - expect(result.finalBuiltinPresets).toEqual(["observability-otlp-local"]); - expect(result.policyPresetReconciliationVerified).toBe(false); - expect( - resolveRestoredPolicyRegistryState( - { policyPresetsFinalized: true }, - result.finalBuiltinPresets, - result.failedPresets, - result.policyPresetReconciliationVerified, - ), - ).toEqual({ - policies: ["observability-otlp-local"], - policyPresetsFinalized: undefined, - }); - }); - - it("keeps finalized custom-only policy state empty after exact replay", () => { - expect(resolveRestoredPolicyRegistryState({ policyPresetsFinalized: true }, [], [])).toEqual({ - policies: [], - policyPresetsFinalized: true, - }); - expect( - resolveRestoredPolicyRegistryState({ policyPresetsFinalized: true }, [], ["tavily"]) - .policyPresetsFinalized, - ).toBeUndefined(); - }); - - it("retains the force-skipped backup warning in the successful final summary", () => { - const writeLine = vi.fn(); - - printSuccessfulRebuildSummary( - { - sandboxName: "alpha", - backupManifest: null, - backupWasForceSkipped: true, - staleRecovery: false, - rebuiltAgentName: "OpenClaw", - expectedVersion: "2026.6.10", - }, - writeLine, - ); - - const output = writeLine.mock.calls.flat().join("\n"); - expect(output).toContain("Sandbox 'alpha' rebuilt successfully"); - expect(output).toContain("Backup was skipped via --force after a total backup failure"); - expect(output).toContain("prior workspace state was not preserved"); - }); }); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index 049c5f94f3d..79c01047851 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -1,21 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { CLI_NAME } from "../../cli/branding"; import { G, R, YW } from "../../cli/terminal-style"; -import { - OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - OBSERVABILITY_POLICY_BINDING, -} from "../../onboard/observability-policy-presets"; -import * as policies from "../../policy"; -import { replayTrustedPrivatePolicyPinCapability } from "../../policy/trusted-private-endpoints"; import * as sandboxConfig from "../../sandbox/config"; import { load as loadRegistry } from "../../state/registry/persistence"; -import * as sandboxState from "../../state/sandbox"; -import { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; import type { RebuildBackupManifest } from "./rebuild-backup-phase"; import type { RebuildLog } from "./rebuild-credential-preflight"; -import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import * as snapshotRestore from "./snapshot/restore-authority"; export interface RebuildRestorePhaseInput { @@ -23,178 +13,21 @@ export interface RebuildRestorePhaseInput { targetAgentType: string; targetImageIsCustom: boolean; backupManifest: RebuildBackupManifest; - policyPresets: string[]; - customPolicies: NonNullable; - reconcileManagedDcodeObservability: boolean; + reconcileManagedDcodeObservability?: boolean; log: RebuildLog; } export interface RebuildRestorePhaseResult { restoreSucceeded: boolean; - restoredPresets: string[]; - failedPresets: string[]; - finalPresets: string[]; - finalBuiltinPresets: string[]; - failedPresetRemovals: string[]; - policyPresetReconciliationVerified: boolean; } -function uniquePresetNames(names: readonly string[]): string[] { - return [...new Set(names)]; -} - -function isManagedObservabilityPreset(name: string): boolean { - return OBSERVABILITY_POLICY_BINDING.matchesPreset(name); -} - -function finalRestoredPresetState( - restoredBuiltinPresets: readonly string[], - restoredCustomPresets: readonly string[], - includeManagedObservability: boolean, -): Pick { - const finalBuiltinPresets = uniquePresetNames( - OBSERVABILITY_POLICY_BINDING.setAttribution( - restoredBuiltinPresets, - includeManagedObservability, - ), - ); - return { - finalBuiltinPresets, - finalPresets: uniquePresetNames([...finalBuiltinPresets, ...restoredCustomPresets]), - }; -} - -function reconcileFinalManagedObservability( - sandboxName: string, - targetManagedObservability: boolean, - restoredBuiltinPresets: readonly string[], - restoredCustomPresets: readonly string[], - failedBuiltinPresets: readonly string[], - successfulCustomObservabilityContents: readonly string[], - log: RebuildLog, -): Pick< - RebuildRestorePhaseResult, - | "finalPresets" - | "finalBuiltinPresets" - | "failedPresetRemovals" - | "policyPresetReconciliationVerified" -> { - const customObservabilityStates = successfulCustomObservabilityContents.map((content) => - OBSERVABILITY_POLICY_BINDING.inspectContent(sandboxName, content, policies), - ); - const customObservabilityExpected = successfulCustomObservabilityContents.length > 0; - const customObservabilityVerified = customObservabilityStates.includes("match"); - if (!targetManagedObservability && customObservabilityVerified) { - return { - ...finalRestoredPresetState(restoredBuiltinPresets, restoredCustomPresets, false), - failedPresetRemovals: [], - policyPresetReconciliationVerified: true, - }; - } - - const loadedBinding = OBSERVABILITY_POLICY_BINDING.load(sandboxName, policies); - const builtinContent = loadedBinding.content; - if (!builtinContent) { - log("Could not load managed observability preset content after rebuild restore"); - console.error( - ` ${YW}\u26a0${R} Could not verify managed observability policy content after restore.`, - ); - return { - ...finalRestoredPresetState( - restoredBuiltinPresets, - restoredCustomPresets, - targetManagedObservability, - ), - failedPresetRemovals: [], - policyPresetReconciliationVerified: false, - }; - } - - const liveBefore = loadedBinding.state; - const failedPresetRemovals: string[] = []; - let liveAfter = liveBefore; - if (!targetManagedObservability && liveBefore === "match") { - log(`Removing unexpected live preset: ${OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET}`); - const removal = OBSERVABILITY_POLICY_BINDING.removeExact( - sandboxName, - builtinContent, - policies, - { - knownBefore: liveBefore, - removeOptions: { nonFatal: true }, - }, - ); - if (removal.reportedSuccess !== true) { - failedPresetRemovals.push(OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET); - } - if (removal.errorMessage) { - log( - `Failed to remove unexpected live preset '${OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET}': ${removal.errorMessage}`, - ); - } - liveAfter = removal.after; - } - - const failedManagedAddition = failedBuiltinPresets.some(isManagedObservabilityPreset); - const customReplayVerified = !customObservabilityExpected || customObservabilityVerified; - const managedTargetVerified = targetManagedObservability - ? liveAfter === "match" || (liveAfter === "absent" && failedManagedAddition) - : liveAfter === "absent"; - const policyPresetReconciliationVerified = - failedPresetRemovals.length === 0 && customReplayVerified && managedTargetVerified; - // Until an exact post-removal read proves absence, preserve attribution for - // the exact built-in content observed before mutation. Reconciliation stays - // unverified, but recovery does not forget policy that may still be live. - const includeManagedObservability = - liveAfter === "match" || - (targetManagedObservability && liveAfter !== "absent") || - (!targetManagedObservability && liveBefore === "match" && liveAfter !== "absent"); - const finalPresetState = finalRestoredPresetState( - restoredBuiltinPresets, - restoredCustomPresets, - includeManagedObservability, - ); - if (!policyPresetReconciliationVerified) { - const details = [ - ...(!customReplayVerified ? ["custom observability content not verified live"] : []), - ...(!managedTargetVerified - ? [`managed observability state ${liveAfter ?? "unavailable"}`] - : []), - ...(failedPresetRemovals.length > 0 - ? [`remove failed ${failedPresetRemovals.join(", ")}`] - : []), - ]; - console.error( - ` ${YW}\u26a0${R} Final live policy preset reconciliation is incomplete: ${details.join("; ")}.`, - ); - } - log( - `Final managed observability state: ${liveAfter ?? "unavailable"}; customOwned=${String(customObservabilityVerified)}; builtins=[${finalPresetState.finalBuiltinPresets.join(",")}]; presets=[${finalPresetState.finalPresets.join(",")}]; verified=${String(policyPresetReconciliationVerified)}`, - ); - return { ...finalPresetState, failedPresetRemovals, policyPresetReconciliationVerified }; -} - -/** - * Restore preserved workspace state and gateway-owned built-in policy presets. - * Boundary coverage: rebuild-flow.test.ts exercises full/partial state restore, - * stale recovery, successful presets, and incomplete preset recovery reporting. - */ +/** Restore sandbox files. The replacement already received the captured live OpenShell policy. */ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): RebuildRestorePhaseResult { - const { - sandboxName, - targetAgentType, - targetImageIsCustom, - backupManifest, - policyPresets, - customPolicies, - reconcileManagedDcodeObservability, - log, - } = input; + const { sandboxName, targetAgentType, targetImageIsCustom, backupManifest, log } = input; let restoreSucceeded = true; if (backupManifest) { console.log(""); console.log(" Restoring workspace state..."); - log(`Restoring from: ${backupManifest.backupPath} into sandbox: ${sandboxName}`); const restore = snapshotRestore.restoreRecreatedSandboxStateWithManagedAuthority( sandboxName, backupManifest, @@ -202,9 +35,7 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild targetAgentType, ...(targetImageIsCustom ? { allowCustomImageWholeStateFileRestore: true } : {}), }, - { - getSandbox: (name) => loadRegistry().sandboxes[name] ?? null, - }, + { getSandbox: (name) => loadRegistry().sandboxes[name] ?? null }, ); log( `Restore result: success=${restore.success}, restored=${restore.restoredDirs.join(",")}; files=${restore.restoredFiles.join(",")}, failed=${restore.failedDirs.join(",")}; failedFiles=${restore.failedFiles.join(",")}${restore.error ? `; error=${restore.error}` : ""}`, @@ -216,126 +47,25 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild (directory) => directory === "dashboard-home" || directory === "profiles", ) ) { - const dashboardTarget = sandboxConfig.resolveAgentConfig(sandboxName); - const dashboardSeed = - dashboardTarget.agentName === "hermes" - ? sandboxConfig.restoreHermesDashboardConfig(sandboxName, dashboardTarget) + const target = sandboxConfig.resolveAgentConfig(sandboxName); + const seeded = + target.agentName === "hermes" + ? sandboxConfig.restoreHermesDashboardConfig(sandboxName, target) : "failed"; - log(`Hermes dashboard state after restore: ${dashboardSeed}`); - if (dashboardSeed === "failed") { - restoreSucceeded = false; - console.error( - ` ${YW}⚠${R} Could not migrate restored Hermes dashboard state into its profile.`, - ); - } + log(`Hermes dashboard state after restore: ${seeded}`); + if (seeded === "failed") restoreSucceeded = false; } if (!restore.success) { - if (restore.error) { - console.error(` Restore blocked: ${restore.error}`); - } - console.error(` Partial restore: ${restore.restoredDirs.join(", ") || "none"}`); - console.error(` Failed: ${restore.failedDirs.join(", ")}`); - if (restore.failedFiles.length > 0) { - console.error(` Failed files: ${restore.failedFiles.join(", ")}`); - } + if (restore.error) console.error(` Restore blocked: ${restore.error}`); + console.error(` ${YW}Partial restore:${R} ${restore.restoredDirs.join(", ") || "none"}`); console.error(` Manual restore available from: ${backupManifest.backupPath}`); } else if (restoreSucceeded) { console.log( - ` ${G}\u2713${R} State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, + ` ${G}✓${R} State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, ); } } - - const restoredBuiltinPresets: string[] = []; - const restoredCustomPresets: string[] = []; - const failedPresets: string[] = []; - const failedBuiltinPresets: string[] = []; - const successfulCustomObservabilityContents: string[] = []; - const customPolicyNames = new Set(customPolicies.map((entry) => entry.name)); - const replayableCustomPolicies = customPolicies.filter( - (entry) => entry.sourcePath !== MCP_BRIDGE_POLICY_SOURCE, - ); - const builtinPolicyPresets = policyPresets.filter((name) => !customPolicyNames.has(name)); - const targetManagedObservability = builtinPolicyPresets.some(isManagedObservabilityPreset); - if (builtinPolicyPresets.length > 0 || replayableCustomPolicies.length > 0) { - console.log(""); - console.log(" Restoring policy presets..."); - log(`Policy presets to restore: [${builtinPolicyPresets.join(",")}]`); - for (const presetName of builtinPolicyPresets) { - try { - log(`Applying preset: ${presetName}`); - const applied = policies.applyPreset(sandboxName, presetName); - if (applied) { - restoredBuiltinPresets.push(presetName); - } else { - failedBuiltinPresets.push(presetName); - failedPresets.push(presetName); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(`Failed to apply preset '${presetName}': ${message}`); - failedBuiltinPresets.push(presetName); - failedPresets.push(presetName); - } - } - for (const entry of replayableCustomPolicies) { - try { - log(`Applying custom preset: ${entry.name}`); - const trustedPrivatePinCapability = entry.trustedPrivatePins - ? replayTrustedPrivatePolicyPinCapability(entry.content, entry.trustedPrivatePins) - : undefined; - const applied = policies.applyPresetContent(sandboxName, entry.name, entry.content, { - custom: { - sourcePath: entry.sourcePath, - ...(trustedPrivatePinCapability ? { trustedPrivatePinCapability } : {}), - }, - }); - if (applied) { - restoredCustomPresets.push(entry.name); - if ( - reconcileManagedDcodeObservability && - OBSERVABILITY_POLICY_BINDING.ownsContent(entry.content) - ) { - successfulCustomObservabilityContents.push(entry.content); - } - } else { - failedPresets.push(entry.name); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(`Failed to apply custom preset '${entry.name}': ${message}`); - failedPresets.push(entry.name); - } - } - const restoredPresets = uniquePresetNames([ - ...restoredBuiltinPresets, - ...restoredCustomPresets, - ]); - if (restoredPresets.length > 0) { - console.log(` ${G}\u2713${R} Policy presets restored: ${restoredPresets.join(", ")}`); - } - if (failedPresets.length > 0) { - console.error(` ${YW}\u26a0${R} Failed to restore presets: ${failedPresets.join(", ")}`); - console.error(` Re-apply manually with: ${CLI_NAME} ${sandboxName} policy add`); - } - } - - const restoredPresets = uniquePresetNames([...restoredBuiltinPresets, ...restoredCustomPresets]); - const finalPolicyState = reconcileManagedDcodeObservability - ? reconcileFinalManagedObservability( - sandboxName, - targetManagedObservability, - restoredBuiltinPresets, - restoredCustomPresets, - failedBuiltinPresets, - successfulCustomObservabilityContents, - log, - ) - : { - finalBuiltinPresets: uniquePresetNames(restoredBuiltinPresets), - finalPresets: restoredPresets, - failedPresetRemovals: [], - policyPresetReconciliationVerified: true, - }; - return { restoreSucceeded, restoredPresets, failedPresets, ...finalPolicyState }; + return { + restoreSucceeded, + }; } diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index fd41d7a4778..0f866edb651 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -1,6 +1,10 @@ // 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, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import * as gatewayDrift from "../../adapters/openshell/gateway-drift"; @@ -24,8 +28,10 @@ import { rebuildSandbox } from "./rebuild"; import * as rebuildImagePreflight from "./rebuild-custom-image-preflight"; import { rebuildOnboardDependencies } from "./rebuild-onboard-dependencies"; import * as rebuildRoutePreflight from "./rebuild-preflight-guards"; +import * as rebuildRecreateJournal from "./rebuild-recreate-journal"; import * as rebuildShields from "./rebuild-shields"; import * as rebuildUsageNotice from "./rebuild-usage-notice"; +import * as policyGet from "./policy-get"; function cloneSession(session: Session): Session { return JSON.parse(JSON.stringify(session)); @@ -36,6 +42,7 @@ describe("rebuild resume snapshot repair", () => { let errorSpy: MockInstance; let logSpy: MockInstance; let session: Session; + let backupPath: string; const originalSandboxName = process.env.NEMOCLAW_SANDBOX_NAME; const observed = { handoffOptions: null as Record | null, @@ -49,6 +56,7 @@ describe("rebuild resume snapshot repair", () => { }; beforeEach(() => { + backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-resume-")); spies = []; observed.handoffOptions = null; observed.preRepairMachineState = null; @@ -61,6 +69,11 @@ describe("rebuild resume snapshot repair", () => { errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + spies.push( + vi.spyOn(rebuildRecreateJournal, "recordRebuildRecoveryBackup").mockImplementation( + () => undefined, + ), + ); session = onboardSession.createSession({ sandboxName: "alpha", @@ -149,7 +162,6 @@ describe("rebuild resume snapshot repair", () => { name: "alpha", provider: "ollama-local", model: "nvidia/nemotron", - policies: [], agent: null, nimContainer: null, nemoclawVersion: "0.1.0", @@ -197,9 +209,8 @@ describe("rebuild resume snapshot repair", () => { failedDirs: [], failedFiles: [], manifest: { - backupPath: "/tmp/nemoclaw-rebuild-backup", + backupPath, timestamp: "2026-06-01T00:00:00.000Z", - policyPresets: [], }, } as never), vi @@ -241,6 +252,10 @@ describe("rebuild resume snapshot repair", () => { imageTag: null, } as never), vi.spyOn(rebuildUsageNotice, "ensureRebuildUsageNoticeAccepted").mockResolvedValue(true), + vi.spyOn(policyGet, "getSandboxPolicy").mockReturnValue({ + raw: "version: 1\nnetwork_policies: {}\n", + yaml: "version: 1\nnetwork_policies: {}\n", + }), vi .spyOn(rebuildOnboardDependencies, "onboard") .mockImplementation(async (options: unknown) => { @@ -263,6 +278,7 @@ describe("rebuild resume snapshot repair", () => { for (const spy of spies) spy.mockRestore(); errorSpy.mockRestore(); logSpy.mockRestore(); + fs.rmSync(backupPath, { recursive: true, force: true }); if (originalSandboxName === undefined) { delete process.env.NEMOCLAW_SANDBOX_NAME; } else { diff --git a/src/lib/actions/sandbox/rebuild-shields-finally.test.ts b/src/lib/actions/sandbox/rebuild-shields-finally.test.ts index 732b22ee444..7ee8bdfc421 100644 --- a/src/lib/actions/sandbox/rebuild-shields-finally.test.ts +++ b/src/lib/actions/sandbox/rebuild-shields-finally.test.ts @@ -4,11 +4,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const phaseMocks = vi.hoisted(() => ({ + clearPolicyHandoff: vi.fn(), + cleanupPolicySource: vi.fn(), runBackup: vi.fn(), runDestroy: vi.fn(), runPreflight: vi.fn(), + runRecreate: vi.fn(), runShields: vi.fn(), openRecreateJournal: vi.fn(), + recordRecoveryBackup: vi.fn(), +})); + +vi.mock("../../onboard/temp-files", async (importOriginal) => ({ + ...(await importOriginal()), + cleanupTempDir: phaseMocks.cleanupPolicySource, +})); + +vi.mock("../../state/sandbox", async (importOriginal) => ({ + ...(await importOriginal()), + clearRebuildPolicyHandoff: phaseMocks.clearPolicyHandoff, })); const gatewayAuthority = { @@ -25,9 +39,11 @@ const gatewayAuthority = { vi.mock("./rebuild-recreate-journal", () => ({ fingerprintRebuildRecreateTargetIntent: () => "intent-1", openRebuildRecreateJournal: phaseMocks.openRecreateJournal, + recordRebuildRecoveryBackup: phaseMocks.recordRecoveryBackup, })); -vi.mock("./rebuild-backup-phase", () => ({ +vi.mock("./rebuild-backup-phase", async (importOriginal) => ({ + ...(await importOriginal()), runRebuildBackupPhase: phaseMocks.runBackup, })); @@ -40,6 +56,10 @@ vi.mock("./rebuild-destroy-phase", () => ({ runRebuildDestroyPhase: phaseMocks.runDestroy, })); +vi.mock("./rebuild-recreate-phase", () => ({ + runRebuildRecreatePhase: phaseMocks.runRecreate, +})); + vi.mock("./rebuild-shields-phase", () => ({ runRebuildShieldsPhase: phaseMocks.runShields, })); @@ -47,6 +67,7 @@ vi.mock("./rebuild-shields-phase", () => ({ import { rebuildSandbox } from "./rebuild"; describe("rebuild shields relock guard", () => { + const policySourcePath = "/tmp/nemoclaw-rebuild-policy-test/policy.yaml"; const rebuildWindow = { relocked: false, wasLocked: true }; const cleanupDcodePreflight = vi.fn(); const releaseOnboardLock = vi.fn(); @@ -58,9 +79,10 @@ describe("rebuild shields relock guard", () => { beforeEach(() => { vi.clearAllMocks(); + phaseMocks.clearPolicyHandoff.mockReturnValue(true); rebuildWindow.relocked = false; phaseMocks.runPreflight.mockResolvedValue({ - sandboxEntry: { name: "alpha", customPolicies: [] }, + sandboxEntry: { name: "alpha" }, targetConfig: { durableConfig: { webSearchConfig: null } }, recreateOptions: { observabilityEnabled: false, @@ -71,6 +93,8 @@ describe("rebuild shields relock guard", () => { liveState: { staleRecovery: false, staleRegistrySnapshot: null }, recoveryManifest: null, dcodePreflight: { + applyDockerGpuPatchNetwork: () => vi.fn(), + checkAtDeleteEdge: vi.fn(async () => ({ ok: true })), cleanup: cleanupDcodePreflight, revalidateBeforeDelete: revalidateDcodeBeforeDelete, }, @@ -108,7 +132,15 @@ describe("rebuild shields relock guard", () => { }); it("does not relock shields when sandbox deletion remains ambiguous (#7062)", async () => { - phaseMocks.runBackup.mockReturnValue({ backupManifest: null }); + const backupManifest = { + backupPath: "/tmp/nemoclaw-rebuild-backup", + rebuildPolicyHandoff: { file: "policy.yaml", sha256: "a".repeat(64) }, + }; + phaseMocks.runBackup.mockReturnValue({ + backupManifest, + backupWasForceSkipped: false, + policySourcePath, + }); phaseMocks.runDestroy.mockImplementation( ({ onDeleteStateAmbiguous }: { onDeleteStateAmbiguous?: () => void }) => { onDeleteStateAmbiguous?.(); @@ -121,55 +153,51 @@ describe("rebuild shields relock guard", () => { ); expect(phaseMocks.runDestroy).toHaveBeenCalledOnce(); + expect(phaseMocks.recordRecoveryBackup).toHaveBeenCalledOnce(); + expect(phaseMocks.clearPolicyHandoff).not.toHaveBeenCalled(); + expect(phaseMocks.cleanupPolicySource).not.toHaveBeenCalled(); expect(relockShields).not.toHaveBeenCalled(); expect(rebuildWindow.relocked).toBe(false); }); - it("blocks a pending baseline transition before shields, backup, or destroy phases begin (#7194)", async () => { - const bail = vi.fn(); - phaseMocks.runPreflight.mockResolvedValue({ - sandboxEntry: { - name: "alpha", - customPolicies: [], - baselineExclusionTransition: { - id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", - operation: "restore", - exclusion: { - version: 1, - agent: "openclaw", - key: "agents.openclaw.default", - digest: "a".repeat(64), - }, - startedAt: "2026-07-19T00:00:00.000Z", - targetLiveDigest: "b".repeat(64), - }, - }, - targetConfig: { durableConfig: { webSearchConfig: null } }, - recreateOptions: { - observabilityEnabled: false, - targetGatewayName: "nemoclaw", - targetGatewayPort: 8080, - }, - liveState: { staleRecovery: false, staleRegistrySnapshot: null }, - recoveryManifest: null, - dcodePreflight: { cleanup: cleanupDcodePreflight }, - preparedImage: null, - releaseOnboardLock, - log: vi.fn(), - bail, + it("reports the retained handoff path when cleanup fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + phaseMocks.runBackup.mockReturnValue({ + backupManifest: null, + backupWasForceSkipped: false, + policySourcePath, + }); + phaseMocks.runDestroy.mockResolvedValue(null); + phaseMocks.cleanupPolicySource.mockImplementationOnce(() => { + throw new Error("cleanup failed"); + }); + + await expect( + rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(warn).toHaveBeenCalledWith( + ` Warning: temporary rebuild policy handoff could not be removed. Remove ${policySourcePath} before retrying.`, + ); + }); + + it("removes the live-policy handoff when sandbox recreation fails", async () => { + phaseMocks.runBackup.mockReturnValue({ + backupManifest: null, + backupWasForceSkipped: false, + policySourcePath, }); - vi.spyOn(console, "error").mockImplementation(() => {}); + phaseMocks.runDestroy.mockResolvedValue({ entries: [], removalReceipt: null }); + phaseMocks.runRecreate.mockRejectedValue(new Error("replacement creation failed")); - await rebuildSandbox("alpha", ["--yes"], { throwOnError: true }); + await expect( + rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("replacement creation failed"); - expect(bail).toHaveBeenCalledWith( - "Pending baseline policy restore for 'agents.openclaw.default' blocks rebuild.", - 1, + expect(phaseMocks.runRecreate).toHaveBeenCalledOnce(); + expect(phaseMocks.cleanupPolicySource).toHaveBeenCalledExactlyOnceWith( + policySourcePath, + "nemoclaw-rebuild-policy", ); - expect(phaseMocks.runShields).not.toHaveBeenCalled(); - expect(phaseMocks.runBackup).not.toHaveBeenCalled(); - expect(phaseMocks.runDestroy).not.toHaveBeenCalled(); - expect(cleanupDcodePreflight).toHaveBeenCalledOnce(); - expect(releaseOnboardLock).toHaveBeenCalledOnce(); }); }); diff --git a/src/lib/actions/sandbox/runtime/hermes-lifecycle.ts b/src/lib/actions/sandbox/runtime/hermes-lifecycle.ts index 1c9f576bfe8..0a14e664737 100644 --- a/src/lib/actions/sandbox/runtime/hermes-lifecycle.ts +++ b/src/lib/actions/sandbox/runtime/hermes-lifecycle.ts @@ -9,13 +9,10 @@ import * as processRecovery from "../process-recovery"; export function createHermesCredentialEnvReconciliationRuntime( runOpenshell: MessagingOpenShellRunner, - revalidatePolicyAuthority: (operation: string) => void, + verifyLivePolicyRequirements: (operation: string) => void, ) { return { - reconcileCredentialEnv: ( - plan: SandboxMessagingPlan, - revalidate: (operation: string) => void, - ) => + reconcileCredentialEnv: (plan: SandboxMessagingPlan, revalidate: (operation: string) => void) => MessagingSetupApplier.reconcileCredentialEnvAtOpenShell(plan, { runOpenshell: (args, options) => { revalidate(`mutating Hermes credential environment for sandbox '${plan.sandboxName}'`); @@ -26,11 +23,7 @@ export function createHermesCredentialEnvReconciliationRuntime( }), restartGateway: (sandboxName: string, revalidate: (operation: string) => void) => { revalidate(`restarting Hermes gateway for sandbox '${sandboxName}'`); - const result = processRecovery.executeGatewaySupervisorAction( - sandboxName, - "restart", - 210000, - ); + const result = processRecovery.executeGatewaySupervisorAction(sandboxName, "restart", 210000); revalidate(`confirming Hermes gateway restart for sandbox '${sandboxName}'`); return result; }, @@ -45,7 +38,7 @@ export function createHermesCredentialEnvReconciliationRuntime( revalidate(`confirming Hermes gateway health for sandbox '${sandboxName}'`); return healthy; }, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, }; } diff --git a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts index cedcf0fb240..6d41532e356 100644 --- a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts +++ b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts @@ -22,9 +22,12 @@ const harness = vi.hoisted(() => ({ prepareDestroy: vi.fn((value: unknown) => value), destroy: vi.fn((value: unknown) => ({ status: "removed", receipt: value })), })); -const captureOpenshellMock = vi.fn(() => ({ +const captureOpenshellMock = vi.fn((args: string[]) => ({ status: 0, - output: "alpha Ready\nbeta Ready\nId: beta-runtime-id\n", + output: + args[0] === "policy" + ? "version: 1\nnetwork_policies: {}\n" + : "alpha Ready\nbeta Ready\nId: beta-runtime-id\n", })); const getSandboxMock = vi.fn((name?: string) => harness.entries.get(name ?? "") ?? null); const registerSandboxMock = vi.fn( @@ -177,9 +180,9 @@ vi.mock("../../policy", () => ({ applyPreset: vi.fn(() => true), applyPresetContent: vi.fn(() => true), getAppliedPresets: vi.fn(() => []), - getCustomPolicies: vi.fn(() => []), getPresetContentGatewayState: vi.fn(() => "absent"), loadPresetForSandbox: vi.fn(() => null), + parseCurrentPolicy: (raw: unknown) => String(raw), removePreset: vi.fn(() => true), resolveAgentBaselinePolicy: resolveTestAgentBaselinePolicy, })); diff --git a/src/lib/actions/sandbox/snapshot-baseline-exclusion-output.test.ts b/src/lib/actions/sandbox/snapshot-baseline-exclusion-output.test.ts deleted file mode 100644 index c3017fcb8bd..00000000000 --- a/src/lib/actions/sandbox/snapshot-baseline-exclusion-output.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - backupSandboxState: vi.fn(), - captureOpenshell: vi.fn(() => ({ status: 0, output: "alpha Ready\n" })), - findBackup: vi.fn(), - getBaselineExclusions: vi.fn(), -})); - -vi.mock("../../adapters/openshell/runtime", () => ({ - captureOpenshell: mocks.captureOpenshell, - getOpenshellBinary: vi.fn(() => "openshell"), - runOpenshell: vi.fn(), -})); - -vi.mock("../../runtime-recovery", () => ({ - parseLiveSandboxNames: vi.fn(() => new Set(["alpha"])), -})); - -vi.mock("../../shields", () => ({ - isShieldsDown: vi.fn(() => true), -})); - -vi.mock("../../shields/timer-bound-lock", () => ({ - withTimerBoundShieldsMutationLock: vi.fn( - (_sandboxName: string, _command: string, operation: () => unknown) => operation(), - ), -})); - -vi.mock("../../state/registry", () => ({ - getBaselineExclusions: mocks.getBaselineExclusions, - getSandbox: vi.fn(() => ({ name: "alpha", agent: "hermes" })), -})); - -vi.mock("../../state/sandbox", () => ({ - backupSandboxState: mocks.backupSandboxState, - findBackup: mocks.findBackup, -})); - -vi.mock("./sandbox-gateway-routing", () => ({ - probeGatewayRunning: vi.fn(() => true), - selectSandboxGatewayIfRegistered: vi.fn(() => true), - usesGatewayMetadataProbe: vi.fn(() => false), -})); - -describe("snapshot baseline exclusion output", () => { - beforeEach(() => { - vi.clearAllMocks(); - const manifest = { - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - }; - mocks.backupSandboxState.mockReturnValue({ - success: true, - backedUpDirs: ["workspace"], - backedUpFiles: ["openclaw.json"], - failedDirs: [], - failedFiles: [], - manifest, - }); - mocks.findBackup.mockReturnValue({ match: { ...manifest, snapshotVersion: 7 } }); - mocks.getBaselineExclusions.mockReturnValue([{ key: "nous_research", digest: "a".repeat(64) }]); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("reports active exclusions and support impact after a successful snapshot (#7178)", async () => { - const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "create" }); - - const output = consoleLog.mock.calls.flat().join("\n"); - expect(output).toContain("Active baseline exclusions: nous_research"); - expect(output).toContain( - "Support impact: Excluded egress leaves dependent agent features unsupported for this sandbox.", - ); - }); -}); diff --git a/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.test.ts b/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.test.ts deleted file mode 100644 index 22ad173ca7b..00000000000 --- a/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { formatSnapshotBaselineExclusionSummary } from "./snapshot-baseline-exclusion-summary"; - -describe("formatSnapshotBaselineExclusionSummary (#7178)", () => { - it("discloses active exclusions and their support impact", () => { - expect( - formatSnapshotBaselineExclusionSummary([ - { version: 1, agent: "hermes", key: "nous_research", digest: "digest-a" }, - { version: 1, agent: "hermes", key: "managed_inference", digest: "digest-b" }, - ]), - ).toEqual([ - "Active baseline exclusions: nous_research, managed_inference", - expect.stringMatching(/^Support impact: .*unsupported/), - ]); - }); - - it("omits the summary when no exclusions are active", () => { - expect(formatSnapshotBaselineExclusionSummary([])).toEqual([]); - }); -}); diff --git a/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.ts b/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.ts deleted file mode 100644 index 73e2775c476..00000000000 --- a/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.ts +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { - type BaselineExclusionRequest, - BASELINE_EXCLUSION_SUPPORT_IMPACT, -} from "../../policy/baseline-exclusion"; - -export function formatSnapshotBaselineExclusionSummary( - exclusions: readonly BaselineExclusionRequest[], -): string[] { - if (exclusions.length === 0) return []; - return [ - `Active baseline exclusions: ${exclusions.map((entry) => entry.key).join(", ")}`, - `Support impact: ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`, - ]; -} diff --git a/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts b/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts index 56a21d730f8..6cb6b20e633 100644 --- a/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts +++ b/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts @@ -74,7 +74,10 @@ const provider = createInMemoryRuntimeProviderBundle({ }); vi.mock("../../adapters/openshell/runtime", () => ({ - captureOpenshell: vi.fn(() => ({ status: 0, output: "alpha Ready\n" })), + captureOpenshell: vi.fn((args: string[]) => ({ + status: 0, + output: args[0] === "policy" ? "version: 1\nnetwork_policies: {}\n" : "alpha Ready\n", + })), getOpenshellBinary: vi.fn(() => "openshell"), runOpenshell: vi.fn(() => ({ status: 0, output: "" })), })); @@ -85,6 +88,7 @@ vi.mock("../../policy", () => ({ getAppliedPresets: vi.fn(() => []), getPresetContentGatewayState: vi.fn(() => "absent"), loadPresetForSandbox: vi.fn(() => null), + parseCurrentPolicy: (raw: unknown) => String(raw), removePreset: vi.fn(() => true), })); @@ -108,8 +112,6 @@ vi.mock("../../state/mcp-lifecycle-lock", () => ({ })); vi.mock("../../state/registry", () => ({ - getBaselineExclusions: vi.fn(() => []), - getCustomPolicies: vi.fn(() => []), getSandbox: harness.getSandbox, listSandboxes: vi.fn(() => ({ sandboxes: [harness.getSandbox()].filter(Boolean), diff --git a/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts b/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts index ea8cfffe562..b3d2d2876e7 100644 --- a/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts +++ b/src/lib/actions/sandbox/snapshot-failed-create-cleanup.test.ts @@ -37,7 +37,6 @@ vi.mock("../../shields/timer-bound-lock", () => ({ })); vi.mock("../../state/registry", () => ({ - getBaselineExclusions: vi.fn(() => []), getSandbox: vi.fn(() => ({ name: "alpha", agent: "openclaw" })), })); diff --git a/src/lib/actions/sandbox/snapshot-restore-baseline-exclusions.test.ts b/src/lib/actions/sandbox/snapshot-restore-baseline-exclusions.test.ts deleted file mode 100644 index b3f99707211..00000000000 --- a/src/lib/actions/sandbox/snapshot-restore-baseline-exclusions.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as f from "./snapshot-restore-test-fixture"; - -beforeEach(f.resetSnapshotRestoreMocks); -afterEach(f.cleanupSnapshotRestoreMocks); - -describe("runSandboxSnapshot restore: baseline exclusions", () => { - it("uses the OpenClaw baseline in the shared fixture when the agent is absent", () => { - const openClawBaseline = { - agent: "openclaw", - policyPath: "/repo/nemoclaw-blueprint/policies/openclaw-sandbox.yaml", - content: "version: 1\nnetwork_policies: {}\n", - }; - - expect(f.resolveAgentBaselinePolicyMock(undefined)).toEqual(openClawBaseline); - expect(f.resolveAgentBaselinePolicyMock(null)).toEqual(openClawBaseline); - expect(f.resolveAgentBaselinePolicyMock("openclaw")).toEqual(openClawBaseline); - }); - - it("creates a clone with the source exclusions applied to its live policy (#7178)", async () => { - const exclusion = { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: "0.18.0", - }; - const cleanup = vi.fn(() => true); - let registeredClone: f.SandboxRecord | null = null; - f.registerSandboxMock.mockImplementation( - (entry) => (registeredClone = entry as f.SandboxRecord), - ); - f.getSandboxMock.mockImplementation((name) => - name === "alpha" - ? { - name: "alpha", - agent: "hermes", - imageTag: "nemoclaw-alpha:test", - openshellDriver: "docker", - provider: "nvidia-nim", - model: "nvidia/model-a", - baselineExclusions: [exclusion], - } - : registeredClone, - ); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - f.prepareInitialSandboxCreatePolicyMock.mockReturnValue({ - policyPath: "/tmp/snapshot-clone-policy.yaml", - appliedPresets: [], - cleanup, - }); - - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); - - expect(f.resolveAgentBaselinePolicyMock).toHaveBeenCalledWith("hermes"); - expect(f.prepareInitialSandboxCreatePolicyMock).toHaveBeenCalledWith( - "/repo/agents/hermes/policy-additions.yaml", - [], - { agentName: "hermes", sandboxName: "beta", baselineExclusions: [exclusion] }, - ); - const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? []; - expect(createArgs[createArgs.indexOf("--policy") + 1]).toBe("/tmp/snapshot-clone-policy.yaml"); - expect(f.registerSandboxMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "beta", baselineExclusions: [exclusion] }), - undefined, - { pending: true }, - ); - expect(cleanup).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts index 7ee33289684..84e71348041 100644 --- a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts @@ -58,18 +58,6 @@ describe("runSandboxSnapshot restore: clone port identity", () => { gatewayPort: 18080, lifecycleGeneration: "00000000-0000-4000-8000-000000000001", lifecycleLiveIdentityFingerprint: "a".repeat(64), - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw-18080", - gatewayPort: 18080, - sandboxName: "alpha", - lifecycleGeneration: "00000000-0000-4000-8000-000000000001", - sandboxIdentityFingerprint: "a".repeat(64), - policyHash: "policy-alpha", - policyVersion: 1, - }, } : registeredClone, ); diff --git a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts index ea8b2cdb347..9220cdc426b 100644 --- a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts @@ -13,9 +13,13 @@ import * as f from "./snapshot-restore-test-fixture"; const tempHomes: string[] = []; beforeEach(() => { f.resetSnapshotRestoreMocks(); + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snapshot-restore-home-")); + tempHomes.push(tempHome); + vi.stubEnv("HOME", tempHome); }); afterEach(() => { f.cleanupSnapshotRestoreMocks(); + vi.unstubAllEnvs(); for (const tempHome of tempHomes.splice(0)) { fs.rmSync(tempHome, { recursive: true, force: true }); } @@ -129,7 +133,6 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { f.getLatestBackupMock.mockReturnValue({ timestamp: "2026-06-15T00:00:00.000Z", backupPath: "/tmp/backup-alpha", - policyPresets: ["github"], }); f.restoreSandboxStateMock.mockReturnValue({ success: true, @@ -145,7 +148,7 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { expect(f.lifecycleMock.events).toContain("lock:restore sandbox snapshot"); expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); expect(f.shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); - expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "github", { nonFatal: true }); + expect(f.applyPresetMock).not.toHaveBeenCalled(); }); it("hardens an active timer window before force-deleting a restore destination", async () => { @@ -244,56 +247,56 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { }, expected: "could not prove ownership", }, - ])("refuses force deletion before every side effect for $label", async ({ - destination, - expected, - }) => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - f.getSandboxMock.mockImplementation((name) => - name === "alpha" - ? { - name: "alpha", - agent: "openclaw", - imageTag: "nemoclaw-alpha:test", - openshellDriver: "docker", - provider: "nvidia-nim", - model: "nvidia/model-a", - } - : name === "beta" - ? destination - : null, - ); - f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await expect( - runSandboxSnapshot("alpha", { - kind: "restore", - to: "beta", - force: true, - yes: true, - }), - ).rejects.toMatchObject({ exitCode: 1 }); + ])( + "refuses force deletion before every side effect for $label", + async ({ destination, expected }) => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "openclaw", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + } + : name === "beta" + ? destination + : null, + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); - expect(consoleError.mock.calls.flat().join("\n")).toContain(expected); - expect(f.stopNimContainerMock).not.toHaveBeenCalled(); - expect(f.stopNimContainerByNameMock).not.toHaveBeenCalled(); - expect(f.lifecycleMock.events).not.toContain("delete"); - expect(f.lifecycleMock.events).not.toContain("cleanup-shields"); - expect(f.runOpenshellMock).not.toHaveBeenCalledWith( - expect.arrayContaining(["provider", "delete"]), - expect.anything(), - ); - expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); - expect(f.registerSandboxMock).not.toHaveBeenCalled(); - }); + await expect( + runSandboxSnapshot("alpha", { + kind: "restore", + to: "beta", + force: true, + yes: true, + }), + ).rejects.toMatchObject({ exitCode: 1 }); + + expect(consoleError.mock.calls.flat().join("\n")).toContain(expected); + expect(f.stopNimContainerMock).not.toHaveBeenCalled(); + expect(f.stopNimContainerByNameMock).not.toHaveBeenCalled(); + expect(f.lifecycleMock.events).not.toContain("delete"); + expect(f.lifecycleMock.events).not.toContain("cleanup-shields"); + expect(f.runOpenshellMock).not.toHaveBeenCalledWith( + expect.arrayContaining(["provider", "delete"]), + expect.anything(), + ); + expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(f.registerSandboxMock).not.toHaveBeenCalled(); + }, + ); it("rechecks cleanup authority inside the destination lock before every side effect", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -834,8 +837,8 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { ], ]); f.getSandboxMock.mockImplementation((name) => entries.get(name ?? "") ?? null); - f.parseLiveSandboxNamesMock.mockImplementation((output: string) => - new Set(output.includes("beta Ready") ? ["alpha", "beta"] : ["alpha"]), + f.parseLiveSandboxNamesMock.mockImplementation( + (output: string) => new Set(output.includes("beta Ready") ? ["alpha", "beta"] : ["alpha"]), ); f.removeSandboxRegistryEntryOutcomeMock.mockImplementation((name) => { entries.delete(name); @@ -875,240 +878,6 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { }, ); - it("blocks a cross-sandbox clone before deleting the target when source policy repair is pending (#7178)", async () => { - const common = { - agent: "openclaw", - openshellDriver: "docker", - provider: "nvidia-nim", - model: "nvidia/model-a", - }; - f.getSandboxMock.mockImplementation((name) => { - return name === "alpha" - ? { - ...common, - name: "alpha", - imageTag: "nemoclaw-alpha:test", - baselineExclusionTransition: { - id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", - operation: "restore", - exclusion: { - version: 1 as const, - agent: "openclaw", - key: "agents.openclaw.default", - digest: "a".repeat(64), - }, - startedAt: "2026-07-19T00:00:00.000Z", - targetLiveDigest: "b".repeat(64), - }, - } - : name === "beta" - ? { ...common, name: "beta", imageTag: "nemoclaw-beta:test" } - : null; - }); - f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await expect( - runSandboxSnapshot("alpha", { - kind: "restore", - to: "beta", - force: true, - yes: true, - }), - ).rejects.toThrow( - "Cannot clone baseline policy while 'restore agents.openclaw.default' needs repair", - ); - - expect(f.lifecycleMock.events).not.toContain("delete"); - expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); - expect(f.registerSandboxMock).not.toHaveBeenCalled(); - }); - - it("blocks a forced clone before deleting a destination whose policy repair is pending (#7178)", async () => { - const pendingTransition = { - id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", - operation: "restore" as const, - exclusion: { - version: 1 as const, - agent: "openclaw", - key: "agents.openclaw.default", - digest: "a".repeat(64), - }, - startedAt: "2026-07-19T00:00:00.000Z", - targetLiveDigest: "b".repeat(64), - }; - f.getSandboxMock.mockImplementation((name) => - name - ? { - name, - agent: "openclaw", - imageTag: `nemoclaw-${name}:test`, - openshellDriver: "docker", - provider: "nvidia-nim", - model: "nvidia/model-a", - ...(name === "beta" ? { baselineExclusionTransition: pendingTransition } : {}), - } - : null, - ); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - const { runSandboxSnapshot } = await import("./snapshot"); - - await expect( - runSandboxSnapshot("alpha", { - kind: "restore", - to: "beta", - force: true, - yes: true, - }), - ).rejects.toMatchObject({ exitCode: 1 }); - - expect(f.lifecycleMock.events).not.toContain("delete"); - expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); - expect(f.registerSandboxMock).not.toHaveBeenCalled(); - }); -}); - -describe("runSandboxSnapshot restore: gateway pairing on a freshly created destination", () => { - const removedCustomPolicy = { - name: "legacy-custom", - content: "network_policies:\n legacy-custom: {}\n", - sourcePath: "/policies/legacy-custom.yaml", - }; - const appliedCustomPolicy = { - name: "new-custom", - content: "network_policies:\n new-custom: {}\n", - sourcePath: "/policies/new-custom.yaml", - }; - - it.each([ - { - label: "built-in preset application", - snapshot: { ...f.latestBackupFixture, policyPresets: ["github"] }, - configureFailure: () => f.applyPresetMock.mockReturnValue(false), - expectedWarning: "github (apply failed)", - assertMutation: () => - expect(f.applyPresetMock).toHaveBeenCalledWith("beta", "github", { nonFatal: true }), - }, - { - label: "built-in OTLP removal", - snapshot: { ...f.latestBackupFixture, policyPresets: [] }, - configureFailure: () => { - f.getPresetContentGatewayStateMock.mockReturnValue("match"); - f.removePresetMock.mockReturnValue(false); - }, - expectedWarning: - "observability-otlp-local (remove failed; exact content still live after remove)", - assertMutation: () => - expect(f.removePresetMock).toHaveBeenCalledWith("beta", "observability-otlp-local", { - nonFatal: true, - }), - }, - { - label: "custom policy removal", - snapshot: { ...f.latestBackupFixture, policyPresets: [], customPolicies: [] }, - configureFailure: () => { - f.getCustomPoliciesMock.mockReturnValue([removedCustomPolicy]); - f.removePresetMock.mockReturnValue(false); - }, - expectedWarning: "legacy-custom (remove failed)", - assertMutation: () => - expect(f.removePresetMock).toHaveBeenCalledWith("beta", removedCustomPolicy.name, { - nonFatal: true, - }), - }, - { - label: "custom policy application", - snapshot: { - ...f.latestBackupFixture, - policyPresets: [], - customPolicies: [appliedCustomPolicy], - }, - configureFailure: () => f.applyPresetContentMock.mockReturnValue(false), - expectedWarning: "new-custom (apply failed)", - assertMutation: () => - expect(f.applyPresetContentMock).toHaveBeenCalledWith( - "beta", - appliedCustomPolicy.name, - appliedCustomPolicy.content, - { custom: { sourcePath: appliedCustomPolicy.sourcePath }, nonFatal: true }, - ), - }, - ])("warns before gateway pairing and continues after $label failure (#8210)", async ({ - snapshot, - configureFailure, - expectedWarning, - assertMutation, - }) => { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snapshot-pairing-")); - tempHomes.push(tempHome); - vi.stubEnv("HOME", tempHome); - vi.spyOn(console, "log").mockImplementation(() => {}); - const events: string[] = []; - const consoleWarn = vi.spyOn(console, "warn").mockImplementation((...args) => { - events.push(`warn:${args.join(" ")}`); - }); - f.establishRestoredSandboxGatewayPairingMock.mockImplementation(() => { - events.push("pairing"); - }); - let registeredClone: f.SandboxRecord | null = null; - f.registerSandboxMock.mockImplementation((entry) => { - registeredClone = entry as f.SandboxRecord; - }); - const alphaEntry = { - name: "alpha", - agent: "openclaw", - imageTag: "nemoclaw-alpha:test", - openshellDriver: "docker", - provider: "nvidia-nim", - model: "nvidia/model-a", - } as f.SandboxRecord; - f.getSandboxMock.mockImplementation((name) => - name === "alpha" ? alphaEntry : registeredClone, - ); - f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - f.getLatestBackupMock.mockReturnValue(snapshot); - configureFailure(); - f.restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["user.md"], - failedDirs: [], - failedFiles: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await expect( - runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }), - ).resolves.toBeUndefined(); - - expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); - assertMutation(); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain(expectedWarning); - const warningIndex = events.findIndex((event) => event.includes(expectedWarning)); - expect(warningIndex).toBeGreaterThanOrEqual(0); - expect(warningIndex).toBeLessThan(events.indexOf("pairing")); - expect(f.establishRestoredSandboxGatewayPairingMock).toHaveBeenCalledWith("beta"); - }); - it("fails with repair guidance when restored gateway pairing cannot be verified (#7431)", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); f.getSandboxMock.mockImplementation((name) => @@ -1155,45 +924,45 @@ describe("runSandboxSnapshot restore: gateway pairing on a freshly created desti }); }); - it.each([ - "hermes", - "langchain-deepagents-code", - ])("does not run OpenClaw pairing for a cross-sandbox %s restore (#7431)", async (agent) => { - vi.spyOn(console, "log").mockImplementation(() => {}); - f.getSandboxMock.mockImplementation((name) => - name === "alpha" - ? { - name: "alpha", - agent, - imageTag: "nemoclaw-alpha:test", - openshellDriver: "docker", - provider: "nvidia-nim", - model: "nvidia/model-a", - } - : null, - ); - f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - f.restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: [], - failedDirs: [], - failedFiles: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); + it.each(["hermes", "langchain-deepagents-code"])( + "does not run OpenClaw pairing for a cross-sandbox %s restore (#7431)", + async (agent) => { + vi.spyOn(console, "log").mockImplementation(() => {}); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent, + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + } + : null, + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + f.restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }); + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }); - expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); - expect(f.establishRestoredSandboxGatewayPairingMock).not.toHaveBeenCalled(); - }); + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); + expect(f.establishRestoredSandboxGatewayPairingMock).not.toHaveBeenCalled(); + }, + ); it("leaves the working gateway credentials untouched on a self-restore", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts b/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts deleted file mode 100644 index 7d3f56b2be4..00000000000 --- a/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as f from "./snapshot-restore-test-fixture"; - -beforeEach(f.resetSnapshotRestoreMocks); -afterEach(f.cleanupSnapshotRestoreMocks); -describe("runSandboxSnapshot restore: observability policy replay", () => { - it.each([ - { enabled: true, expectedValue: "1" }, - { enabled: false, expectedValue: "0" }, - ])("starts a snapshot clone with the authoritative source observability state when enabled=$enabled", async ({ - enabled, - expectedValue, - }) => { - let registeredClone: f.SandboxRecord | null = null; - f.registerSandboxMock.mockImplementation( - (entry) => (registeredClone = entry as f.SandboxRecord), - ); - vi.stubEnv("NEMOCLAW_OBSERVABILITY", "1"); - f.getSandboxMock.mockImplementation((name) => - name === "alpha" - ? { - name: "alpha", - agent: "langchain-deepagents-code", - imageTag: "nemoclaw-alpha:test", - openshellDriver: "docker", - observabilityEnabled: enabled, - provider: "nvidia-nim", - model: "nvidia/model-a", - } - : registeredClone, - ); - f.captureOpenshellMock.mockImplementation((args) => - f.openshellResponses(args, { - "sandbox exec": { status: 0, output: f.dcodeProbeOutput("idle") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); - const createCall = f.streamSandboxCreateMock.mock.calls[0] ?? []; - const createArgs = createCall[1] as readonly string[]; - const createEnv = createCall[2] as NodeJS.ProcessEnv | undefined; - expect(createCall[0]).toBe("openshell"); - expect(createArgs).toContain(`NEMOCLAW_OBSERVABILITY=${expectedValue}`); - expect(createEnv?.NEMOCLAW_OBSERVABILITY).toBeUndefined(); - expect(f.registerSandboxMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "beta", - observabilityEnabled: enabled, - }), - undefined, - { pending: true }, - ); - expect(f.applyPresetMock).toHaveBeenCalledTimes(enabled ? 1 : 0); - }); - - it.each([ - { label: "recorded", policyPresets: ["npm"] }, - { label: "legacy", policyPresets: undefined }, - ])("adds built-in OTLP egress for a $label snapshot", async ({ policyPresets }) => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - } as never); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture, policyPresets }); - f.getAppliedPresetsMock.mockReturnValue(["npm"]); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(f.removePresetMock).not.toHaveBeenCalled(); - }); - - it("removes historical built-in OTLP egress when observability was disabled after the snapshot", async () => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - }); - f.getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); - f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); - - it("removes an exact unrecorded built-in OTLP policy when observability is disabled", async () => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: [], - } as never); - f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture, policyPresets: [] }); - f.getAppliedPresetsMock.mockReturnValue([]); - f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledWith( - "alpha", - f.builtinObservabilityPolicy, - ); - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(f.updateSandboxMock).not.toHaveBeenCalled(); - }); - - it.each([ - { - label: "returns false", - configureRemoval: () => f.removePresetMock.mockReturnValue(false), - }, - { - label: "throws", - configureRemoval: () => - f.removePresetMock.mockImplementation(() => { - throw new Error("remove exploded"); - }), - }, - { - label: "claims success without removing", - configureRemoval: () => f.removePresetMock.mockReturnValue(true), - }, - ])("retains built-in OTLP attribution when removal $label", async ({ configureRemoval }) => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: [], - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - f.getAppliedPresetsMock.mockReturnValue([]); - f.getPresetContentGatewayStateMock.mockReturnValue("match"); - configureRemoval(); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); - expect(f.updateSandboxMock).toHaveBeenCalledWith("alpha", { - policies: ["observability-otlp-local"], - }); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain( - "exact content still live after remove", - ); - }); -}); diff --git a/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts b/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts deleted file mode 100644 index a74ed9945bc..00000000000 --- a/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as f from "./snapshot-restore-test-fixture"; - -beforeEach(f.resetSnapshotRestoreMocks); -afterEach(f.cleanupSnapshotRestoreMocks); -describe("runSandboxSnapshot restore: observability policy reconciliation", () => { - it("does not promote a forged snapshot digest into trusted-private pin authority", async () => { - const content = - "network_policies:\n private-api:\n endpoints:\n - host: api.corp.example\n allowed_ips:\n - 10.20.30.40\n"; - const customPolicy = { - name: "private-api", - content, - sourcePath: "/policies/private-api.yaml", - trustedPrivatePins: { - version: 1 as const, - contentDigest: "a".repeat(64), - }, - }; - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - policyTier: "balanced", - } as never); - f.getLatestBackupMock.mockReturnValue({ - ...f.latestBackupFixture, - policyPresets: [customPolicy.name], - customPolicies: [customPolicy], - }); - f.getCustomPoliciesMock.mockReturnValue([]); - f.applyPresetContentMock.mockReturnValue(false); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true }, - ); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain("private-api (apply failed)"); - }); - - it("does not resurrect an earlier removed preset while restoring unverified OTLP attribution", async () => { - let registryEntry = { - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["github", "observability-otlp-local"], - }; - f.getSandboxMock.mockImplementation(() => registryEntry as never); - f.updateSandboxMock.mockImplementation((_sandboxName, update) => { - registryEntry = { ...registryEntry, ...(update as Partial) }; - }); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - f.getAppliedPresetsMock.mockReturnValue(["github", "observability-otlp-local"]); - f.getPresetContentGatewayStateMock.mockReturnValue("match"); - f.removePresetMock - .mockImplementationOnce((_sandboxName, presetName) => { - expect(presetName).toBe("github"); - registryEntry = { - ...registryEntry, - policies: registryEntry.policies.filter((name) => name !== "github"), - }; - return true; - }) - .mockReturnValue(true); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.removePresetMock.mock.calls.map((call) => call[1])).toEqual([ - "github", - "observability-otlp-local", - ]); - expect(f.updateSandboxMock).toHaveBeenLastCalledWith("alpha", { - policies: ["observability-otlp-local"], - }); - expect(registryEntry.policies).toEqual(["observability-otlp-local"]); - }); - - it.each([ - { - label: "records an exact live enabled policy", - observabilityEnabled: true, - liveState: "match" as const, - policies: ["npm"], - expectedPolicies: ["npm", "observability-otlp-local"], - }, - { - label: "prunes an exact absent disabled policy", - observabilityEnabled: false, - liveState: "absent" as const, - policies: ["npm", "observability-otlp-local"], - expectedPolicies: ["npm"], - }, - ])("repairs stale OTLP registry state: $label", async ({ - observabilityEnabled, - liveState, - policies: recordedPolicies, - expectedPolicies, - }) => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled, - policyTier: "balanced", - policies: recordedPolicies, - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm"], - }); - f.getAppliedPresetsMock.mockReturnValue(recordedPolicies); - f.getPresetContentGatewayStateMock.mockReturnValue(liveState); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: expectedPolicies }); - expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); - - it("does not let a same-name, different-key custom replay suppress stale built-in OTLP cleanup", async () => { - const customPolicy = { - name: "observability-otlp-local", - content: "network_policies:\n operator-collector: {}\n", - sourcePath: "/policies/operator-collector.yaml", - }; - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [customPolicy.name], - customPolicies: [customPolicy], - }); - f.getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); - f.getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); - f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true }, - ); - expect(f.removePresetMock).toHaveBeenCalledTimes(1); - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); - expect(f.updateSandboxMock).not.toHaveBeenCalled(); - }); - - it("lets successfully replayed corp-otel content own its exact live OTLP key", async () => { - const customPolicy = { - name: "corp-otel", - content: - "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", - sourcePath: "/policies/corp-otel.yaml", - }; - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["npm", "observability-otlp-local"], - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - customPolicies: [customPolicy], - }); - f.getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); - f.getAppliedPresetsMock.mockReturnValue(["npm", "corp-otel", "observability-otlp-local"]); - f.getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => - content === customPolicy.content ? "match" : "drift", - ); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath }, nonFatal: true }, - ); - expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); - expect(f.updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: ["npm"] }); - expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(1); - expect(f.getPresetContentGatewayStateMock.mock.calls[0]?.[1]).toBe(customPolicy.content); - expect(f.getPresetContentGatewayStateMock.mock.calls[0]?.[2]).toBe("observability-otlp-local"); - }); - - it("does not let a failed corp-otel replay suppress stale built-in OTLP cleanup", async () => { - const customPolicy = { - name: "corp-otel", - content: - "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", - sourcePath: "/policies/corp-otel.yaml", - }; - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["npm", "observability-otlp-local"], - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - customPolicies: [customPolicy], - }); - f.getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); - f.applyPresetContentMock.mockReturnValue(false); - f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(consoleWarn.mock.calls.flat().join("\n")).toContain("corp-otel (apply failed)"); - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local", { - nonFatal: true, - }); - expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); - expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledWith( - "alpha", - f.builtinObservabilityPolicy, - ); - }); - - it("aborts preset reconciliation when custom OTLP ownership is unreadable", async () => { - const currentCustomPolicy = { - name: "corp-otel", - content: "network_policies:\n observability-otlp-local: {}\n", - sourcePath: "/policies/old-collector.yaml", - }; - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - } as never); - f.getLatestBackupMock.mockReturnValue({ - ...f.latestBackupFixture, - policyPresets: [], - customPolicies: [], - }); - f.getCustomPoliciesMock.mockReturnValue([currentCustomPolicy]); - f.removePresetMock.mockReturnValue(false); - f.getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => - content === currentCustomPolicy.content ? null : "absent", - ); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name, { - nonFatal: true, - }); - expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain( - "leaving live policy presets unchanged", - ); - }); - it.each([ - "drift", - null, - ] as const)("does not remove built-in OTLP when its exact live content state is %s", async (gatewayState) => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["observability-otlp-local"], - }); - f.getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); - f.getPresetContentGatewayStateMock.mockReturnValue(gatewayState); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.removePresetMock).not.toHaveBeenCalled(); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain( - "leaving its live policy content unchanged", - ); - }); - - it("normalizes a legacy restricted tier before deciding built-in OTLP egress", async () => { - f.getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: " Restricted ", - } as never); - f.getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); -}); diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts index cacf6e50622..58e7642fcd2 100644 --- a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -26,28 +26,6 @@ export type SandboxRecord = { pendingRouteReservation?: true; reservationSessionId?: string; agent?: string | null; - baselineExclusionTransition?: { - id: string; - operation: "exclude" | "restore"; - exclusion: { - version: 1; - agent: string; - key: string; - digest: string; - acknowledgedAt?: string; - appliedAgentVersion?: string | null; - }; - startedAt: string; - targetLiveDigest: string | null; - }; - baselineExclusions?: Array<{ - version: 1; - agent: string; - key: string; - digest: string; - acknowledgedAt?: string; - appliedAgentVersion?: string | null; - }>; fromDockerfile?: string | null; gatewayName?: string | null; gatewayPort?: number | null; @@ -63,8 +41,6 @@ export type SandboxRecord = { preferredInferenceApi?: string | null; lifecycleGeneration?: string; lifecycleLiveIdentityFingerprint?: string; - policyAuthority?: SandboxEntry["policyAuthority"]; - policyCreationReceipt?: SandboxEntry["policyCreationReceipt"]; hostLocalInferenceReceipt?: string | null; hostLocalInferenceProvenance?: SandboxHostLocalInferenceProvenance; dashboardPort?: number | null; @@ -95,20 +71,23 @@ export function openshellResponses( const sandboxName = String(args.at(-1) ?? "sandbox"); const result = responses[command] ?? - (command === "sandbox get" - ? { - status: 0, - output: `Name: ${sandboxName}\nId: ${sandboxName}-live-id\nPhase: Ready\n`, - } - : { - status: 0, - output: "", - }); + (command === "policy get" + ? { status: 0, output: "version: 1\nnetwork_policies: {}\n" } + : command === "sandbox get" + ? { + status: 0, + output: `Name: ${sandboxName}\nId: ${sandboxName}-live-id\nPhase: Ready\n`, + } + : { + status: 0, + output: "", + }); return captureOpenshellStreams(args, result); } export function defaultOpenshellResponses(args: string[]): OpenshellCaptureResult { return openshellResponses(args, { + "policy get": { status: 0, output: "version: 1\nnetwork_policies: {}\n" }, "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, "sandbox list": { status: 0, @@ -170,9 +149,6 @@ export const dockerInspectMock = vi.fn(() => ({ status: 0, stdout: "true\n" })); export const establishRestoredSandboxGatewayPairingMock = vi.fn(); export const findBackupMock = vi.fn(); export const getAppliedPresetsMock = vi.fn(() => [] as string[]); -export const getCustomPoliciesMock = vi.fn( - () => [] as Array<{ name: string; content: string; sourcePath?: string }>, -); export const getLatestBackupMock = vi.fn(() => null as Record | null); export const applyPresetMock = vi.fn((_sandbox: string, _preset: string) => true); export const applyPresetContentMock = vi.fn( @@ -272,6 +248,7 @@ vi.mock("../../policy", () => ({ getAppliedPresets: getAppliedPresetsMock, getPresetContentGatewayState: getPresetContentGatewayStateMock, loadPresetForSandbox: loadPresetForSandboxMock, + parseCurrentPolicy: (raw: unknown) => String(raw), removePreset: removePresetMock, resolveAgentBaselinePolicy: resolveAgentBaselinePolicyMock, })); @@ -326,9 +303,7 @@ vi.mock("../../state/gateway", () => ({ })); vi.mock("../../state/registry", () => ({ - getBaselineExclusions: vi.fn(() => []), getConfiguredMessagingChannelsFromEntry: vi.fn(() => []), - getCustomPolicies: getCustomPoliciesMock, getDisabledMessagingChannelsFromEntry: vi.fn(() => []), getSandbox: getSandboxMock, isRouteOnlySandboxReservation: (entry: SandboxRecord) => @@ -393,7 +368,6 @@ export function resetSnapshotRestoreMocks(): void { establishRestoredSandboxGatewayPairingMock.mockReset(); findBackupMock.mockReturnValue({ match: null }); getAppliedPresetsMock.mockReturnValue([]); - getCustomPoliciesMock.mockReturnValue([]); getLatestBackupMock.mockReturnValue(null); applyPresetMock.mockReturnValue(true); applyPresetContentMock.mockReturnValue(true); diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index 4807e013afc..b5e6cf70096 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -106,19 +106,17 @@ describe("runSandboxSnapshot", () => { }); it("rejects a schema-5 snapshot restore source or destination before effects (#9203)", async () => { - f.assertHermesPortableCommandUnavailableMock.mockImplementation( - (sandboxName: string) => { - switch (sandboxName) { - case "beta": - throw new Error("schema-5 destination rejected"); - } - }, - ); + f.assertHermesPortableCommandUnavailableMock.mockImplementation((sandboxName: string) => { + switch (sandboxName) { + case "beta": + throw new Error("schema-5 destination rejected"); + } + }); const { runSandboxSnapshot } = await import("./snapshot"); - await expect( - runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }), - ).rejects.toThrow("schema-5 destination rejected"); + await expect(runSandboxSnapshot("alpha", { kind: "restore", to: "beta" })).rejects.toThrow( + "schema-5 destination rejected", + ); expect(f.assertHermesPortableCommandUnavailableMock).toHaveBeenCalledWith( "alpha", @@ -616,11 +614,6 @@ describe("runSandboxSnapshot", () => { expect(output).toContain("alpha snapshot restore"); }); - - - - - it("reserves an explicit llama.cpp clone with the original owner and exact gateway authority", async () => { const hostLocalInferenceReceipt = serializedLlamaCppHostLocalInferenceReceipt("docker"); const hostLocalInferenceProvenance = createSandboxHostLocalInferenceProvenance( @@ -715,17 +708,6 @@ describe("runSandboxSnapshot", () => { expect(confirm).toHaveBeenCalledTimes(2); }); - - - - - - - - - - - it("refuses snapshot creation before backup when the sandbox is not live", async () => { f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["beta"])); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -760,57 +742,4 @@ describe("runSandboxSnapshot", () => { }); expect(consoleError.mock.calls.flat().join("\n")).toContain("tar exploded"); }); - - it("reconciles snapshot policies after restore and warns without failing on repair misses", async () => { - const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - f.getLatestBackupMock.mockReturnValue({ - backupPath: "/tmp/alpha/v2", - timestamp: "2026-06-02T00:00:00.000Z", - policyPresets: ["npm", "github"], - customPolicies: [ - { - name: "team-egress", - content: "network_policies:\n team-egress: {}\n", - sourcePath: "/policies/team.yaml", - }, - ], - }); - f.restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["openclaw.json"], - failedDirs: [], - failedFiles: [], - }); - f.getAppliedPresetsMock.mockReturnValue(["npm", "team-egress", "old-preset"]); - f.getCustomPoliciesMock.mockReturnValue([ - { - name: "team-egress", - content: "network_policies:\n team-egress: {}\n", - sourcePath: "/policies/team.yaml", - }, - { name: "old-custom", content: "network_policies:\n old: {}\n", sourcePath: "/old.yaml" }, - ]); - f.removePresetMock.mockImplementation((_sandbox, preset) => preset !== "old-custom"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/alpha/v2"); - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "old-preset", { nonFatal: true }); - expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "github", { nonFatal: true }); - expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "old-custom", { nonFatal: true }); - expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", "team-egress"); - expect(f.applyPresetContentMock).not.toHaveBeenCalled(); - const output = consoleLog.mock.calls.flat().join("\n"); - expect(output).toContain("✓ Restored 1 directories, 1 files"); - expect(output).toContain( - "Reconciling policy presets on 'alpha': add github; remove old-preset", - ); - expect(output).toContain("Reconciling custom policies on 'alpha': remove old-custom"); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain( - "Warning: could not reconcile custom policy(ies): old-custom (remove failed)", - ); - }); }); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 241403f054e..291da89176a 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -44,10 +44,11 @@ import { resolveHermesDashboardOnboardState } from "../../onboard/hermes-dashboa import { isDcodeAgent, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - OBSERVABILITY_POLICY_BINDING, } from "../../onboard/observability-policy-presets"; import { normalizePolicyTierName } from "../../onboard/policy-tier-suppression"; +import { cleanupTempDir, secureTempFile } from "../../onboard/temp-files"; import * as policies from "../../policy"; +import { buildPolicyGetArgs } from "../../policy/commands"; import { ROOT, run, shellQuote, validateName } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; import { streamSandboxCreate } from "../../sandbox/create-stream"; @@ -104,7 +105,6 @@ import { retirePreparedHostLocalInferenceAuthority, type RuntimeProviderBundle, } from "./snapshot/dependencies"; -import { formatSnapshotBaselineExclusionSummary } from "./snapshot-baseline-exclusion-summary"; import { printHermesGatewayRestoreHint } from "./snapshot-hermes-gateway-hint"; const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; @@ -376,35 +376,24 @@ function resolveCloneDashboardEnvArgs( async function prepareSnapshotClonePolicy( srcEntry: SandboxEntry, - targetSandbox: string, + _targetSandbox: string, ): Promise<{ policyPath: string; cleanup?: () => boolean; }> { - if (srcEntry.baselineExclusionTransition) { - const transition = srcEntry.baselineExclusionTransition; - throw new Error( - `Cannot clone baseline policy while '${transition.operation} ${transition.exclusion.key}' needs repair. Re-run that policy command on '${srcEntry.name}' first.`, - ); - } - const agentName = srcEntry.agent || "openclaw"; - const baseline = policies.resolveAgentBaselinePolicy(agentName); - if (!baseline) { - throw new Error(`Cannot resolve the '${agentName}' baseline policy for snapshot restore.`); - } - const baselineExclusions = srcEntry.baselineExclusions ?? []; - if (baselineExclusions.length === 0) return { policyPath: baseline.policyPath }; - - const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(srcEntry)); - const activeMessagingChannels = registry - .getConfiguredMessagingChannelsFromEntry(srcEntry) - .filter((channel) => !disabledChannels.has(channel)); - const { prepareInitialSandboxCreatePolicy } = await import("../../onboard/initial-policy"); - return prepareInitialSandboxCreatePolicy(baseline.policyPath, activeMessagingChannels, { - agentName, - sandboxName: targetSandbox, - baselineExclusions, - }); + const gatewayName = resolveSandboxGatewayName(srcEntry); + const raw = captureOpenshell(buildPolicyGetArgs(srcEntry.name, gatewayName)).output; + const policy = policies.parseCurrentPolicy(raw); + if (!policy) throw new Error(`Cannot read the live OpenShell policy for '${srcEntry.name}'.`); + const policyPath = secureTempFile("nemoclaw-clone-policy", ".yaml"); + fs.writeFileSync(policyPath, policy, { mode: 0o600 }); + return { + policyPath, + cleanup: () => { + cleanupTempDir(policyPath, "nemoclaw-clone-policy"); + return true; + }, + }; } // Used by `snapshot restore --to ` when dst does not exist yet: reuses @@ -591,18 +580,13 @@ async function autoCreateSandboxFromSource( releaseCloneHostLocalReservation(); failUnregisteredSnapshotClone(dstName, sourceGatewayName); } - const { - policyAuthority: _sourcePolicyAuthority, - policyCreationReceipt: _sourcePolicyCreationReceipt, - ...cloneSourceEntry - } = srcEntry as SandboxEntry; + const cloneSourceEntry = srcEntry as SandboxEntry; try { registry.registerSandbox( { ...cloneSourceEntry, name: dstName, createdAt: new Date().toISOString(), - policies: [], observabilityEnabled: sourceObservabilityEnabled, // dst has its own lifecycle; don't inherit src's local NIM container // reference, or destroying dst would stop src's NIM. @@ -1002,11 +986,6 @@ function runSnapshotCreate( const itemSummary = `${result.backedUpDirs.length} directories, ${result.backedUpFiles.length} files`; console.log(` ${G}✓${R} Snapshot ${v}${nameSuffix} created (${itemSummary})`); console.log(` ${manifest.backupPath}`); - for (const line of formatSnapshotBaselineExclusionSummary( - registry.getBaselineExclusions(sandboxName), - )) { - console.log(` ${line}`); - } return; } if (result.error) { @@ -1052,244 +1031,6 @@ function repairRestoredOpenClawConfigPerms( } } -function reconcileSnapshotPolicyPresets( - targetSandbox: string, - resolvedSnapshot: ReturnType, -): void { - if (!resolvedSnapshot) return; - const snapshotPolicyPresets = Array.isArray(resolvedSnapshot.policyPresets) - ? resolvedSnapshot.policyPresets - : null; - const hasSnapshotPresetMetadata = snapshotPolicyPresets !== null; - const snapshotCustomPolicies = Array.isArray(resolvedSnapshot.customPolicies) - ? resolvedSnapshot.customPolicies - : []; - const snapshotCustomPolicyNames = new Set( - snapshotCustomPolicies.map((entry) => entry.name.trim().toLowerCase()), - ); - const snapshotPresets = - snapshotPolicyPresets?.filter( - (preset) => !snapshotCustomPolicyNames.has(preset.trim().toLowerCase()), - ) ?? []; - const targetEntry = registry.getSandbox(targetSandbox); - // Custom reconciliation runs before this function. Only the registry state - // that remains after that reconciliation can participate in ownership. - const currentCustomPolicies = registry.getCustomPolicies(targetSandbox); - const currentCustomPolicyNames = new Set( - currentCustomPolicies.map((preset) => preset.name.trim().toLowerCase()), - ); - const customPolicyNames = new Set([...snapshotCustomPolicyNames, ...currentCustomPolicyNames]); - let customOwnsObservability: boolean; - try { - customOwnsObservability = OBSERVABILITY_POLICY_BINDING.hasLiveCustomOwner( - targetSandbox, - currentCustomPolicies.map((entry) => entry.content), - policies, - ); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.warn( - ` Warning: could not verify custom ownership of '${OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET}' (${detail}); leaving live policy presets unchanged.`, - ); - return; - } - const withoutBuiltinObservability = snapshotPresets.filter( - (preset) => !OBSERVABILITY_POLICY_BINDING.matchesPreset(preset), - ); - const shouldEnableBuiltinObservability = - !customOwnsObservability && - isDcodeAgent(targetEntry?.agent) && - targetEntry?.observabilityEnabled === true && - normalizePolicyTierName(targetEntry.policyTier) !== "restricted"; - // getAppliedPresets includes custom-policy names for display/CLI parity. - // Built-in preset reconciliation must not remove those; custom policy content - // is reconciled separately below from registry.getCustomPolicies(). - const currentPresets = hasSnapshotPresetMetadata - ? [...new Set(policies.getAppliedPresets(targetSandbox))].filter((preset: string) => { - const normalized = preset.trim().toLowerCase(); - return ( - !OBSERVABILITY_POLICY_BINDING.matchesPreset(normalized) && - !customPolicyNames.has(normalized) - ); - }) - : []; - const recordedBuiltinObservability = (targetEntry?.policies ?? []).some((preset) => - OBSERVABILITY_POLICY_BINDING.matchesPreset(preset), - ); - const setRecordedBuiltinObservability = (enabled: boolean, force = false): void => { - const currentEntry = registry.getSandbox(targetSandbox); - if (!currentEntry) return; - const currentPolicies = currentEntry.policies ?? []; - const currentlyRecorded = currentPolicies.some((preset) => - OBSERVABILITY_POLICY_BINDING.matchesPreset(preset), - ); - if (!force && enabled === currentlyRecorded) return; - registry.updateSandbox(targetSandbox, { - policies: OBSERVABILITY_POLICY_BINDING.setAttribution(currentPolicies, enabled), - }); - }; - if (customOwnsObservability) { - setRecordedBuiltinObservability(false); - } - // Legacy snapshots predate generic preset metadata. Leave those unrelated - // presets untouched, while still reconciling the managed observability - // binding below from the target registry's authoritative enablement state. - const toRemove = hasSnapshotPresetMetadata - ? currentPresets.filter((preset: string) => !withoutBuiltinObservability.includes(preset)) - : []; - const toAdd = hasSnapshotPresetMetadata - ? withoutBuiltinObservability.filter((preset: string) => !currentPresets.includes(preset)) - : []; - - // A same-name custom policy does not own the built-in OTLP entry unless its - // exact, overlapping content is both registered after custom reconciliation - // and live in the gateway. Reconcile the built-in from exact content state, - // never from a name/key-only match that could delete drifted operator policy. - let builtinObservabilityContent: string | null = null; - let builtinObservabilityState: "match" | "absent" | "drift" | null = null; - if (!customOwnsObservability) { - const loadedBinding = OBSERVABILITY_POLICY_BINDING.load(targetSandbox, policies); - builtinObservabilityContent = loadedBinding.content; - builtinObservabilityState = loadedBinding.state; - const builtinState = builtinObservabilityState; - if (builtinState === "absent" && shouldEnableBuiltinObservability) { - toAdd.push(OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET); - } else if (builtinState === "absent" && recordedBuiltinObservability) { - setRecordedBuiltinObservability(false); - } else if (builtinState === "match" && !shouldEnableBuiltinObservability) { - toRemove.push(OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET); - } else if (builtinState === "match" && !recordedBuiltinObservability) { - setRecordedBuiltinObservability(true); - } else if (builtinState === "drift" || builtinState === null) { - const reason = builtinState === "drift" ? "has drifted" : "could not be inspected"; - console.warn( - ` Warning: built-in preset '${OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET}' ${reason}; leaving its live policy content unchanged.`, - ); - } - } - if (toRemove.length === 0 && toAdd.length === 0) return; - - const summary: string[] = []; - if (toAdd.length > 0) summary.push(`add ${toAdd.join(", ")}`); - if (toRemove.length > 0) summary.push(`remove ${toRemove.join(", ")}`); - console.log(` Reconciling policy presets on '${targetSandbox}': ${summary.join("; ")}`); - - const failed: string[] = []; - for (const preset of toRemove) { - if (OBSERVABILITY_POLICY_BINDING.matchesPreset(preset) && builtinObservabilityContent) { - const removal = OBSERVABILITY_POLICY_BINDING.removeExact( - targetSandbox, - builtinObservabilityContent, - policies, - { knownBefore: builtinObservabilityState, removeOptions: { nonFatal: true } }, - ); - builtinObservabilityState = removal.after; - if (removal.verifiedAbsent) { - setRecordedBuiltinObservability(false); - } else { - // removePreset updates the registry on a reported success. Restore - // attribution whenever exact absence was not proven so recovery does - // not forget built-in policy that may still be live. - setRecordedBuiltinObservability(true, true); - } - if (removal.failureDetail) failed.push(`${preset} (${removal.failureDetail})`); - continue; - } - try { - // Post-restore policy reconciliation is best-effort by design: a failed - // gateway policy mutation must be reported as a warning, not terminate - // the restore before gateway pairing. Pass nonFatal so setPolicyFile - // returns false on failure instead of exiting the process (#8210). - if (!policies.removePreset(targetSandbox, preset, { nonFatal: true })) - failed.push(`${preset} (remove failed)`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - failed.push(`${preset} (remove: ${message})`); - } - } - for (const preset of toAdd) { - try { - if (!policies.applyPreset(targetSandbox, preset, { nonFatal: true })) - failed.push(`${preset} (apply failed)`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - failed.push(`${preset} (apply: ${message})`); - } - } - if (failed.length > 0) { - console.warn(` Warning: could not reconcile preset(s): ${failed.join("; ")}`); - } -} - -function reconcileSnapshotCustomPolicies( - targetSandbox: string, - resolvedSnapshot: ReturnType, -): void { - if (!resolvedSnapshot || !Array.isArray(resolvedSnapshot.customPolicies)) return; - const snapshotCustom = resolvedSnapshot.customPolicies; - const currentCustom = registry.getCustomPolicies(targetSandbox); - const snapshotByName = new Map(snapshotCustom.map((entry) => [entry.name, entry])); - const currentByName = new Map(currentCustom.map((entry) => [entry.name, entry])); - const toRemove = currentCustom.filter((c) => !snapshotByName.has(c.name)); - const toAdd = snapshotCustom.filter((sp) => { - const current = currentByName.get(sp.name); - return ( - !current || - current.content !== sp.content || - current.sourcePath !== sp.sourcePath || - current.trustedPrivatePins?.contentDigest !== sp.trustedPrivatePins?.contentDigest - ); - }); - if (toRemove.length === 0 && toAdd.length === 0) return; - - const summary: string[] = []; - if (toAdd.length > 0) summary.push(`add ${toAdd.map((c) => c.name).join(", ")}`); - if (toRemove.length > 0) summary.push(`remove ${toRemove.map((c) => c.name).join(", ")}`); - console.log(` Reconciling custom policies on '${targetSandbox}': ${summary.join("; ")}`); - - const failed: string[] = []; - for (const entry of toRemove) { - try { - // Best-effort like the built-in preset reconciliation above (#8210). - if (!policies.removePreset(targetSandbox, entry.name, { nonFatal: true })) { - failed.push(`${entry.name} (remove failed)`); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - failed.push(`${entry.name} (remove: ${message})`); - } - } - for (const entry of toAdd) { - try { - const currentAuthority = currentByName.get(entry.name); - const trustedPrivatePinCapability = - currentAuthority?.content === entry.content && currentAuthority.trustedPrivatePins - ? policies.replayTrustedPrivatePolicyPinCapability( - currentAuthority.content, - currentAuthority.trustedPrivatePins, - ) - : undefined; - if ( - !policies.applyPresetContent(targetSandbox, entry.name, entry.content, { - custom: { - sourcePath: entry.sourcePath, - ...(trustedPrivatePinCapability ? { trustedPrivatePinCapability } : {}), - }, - nonFatal: true, - }) - ) { - failed.push(`${entry.name} (apply failed)`); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - failed.push(`${entry.name} (apply: ${message})`); - } - } - if (failed.length > 0) { - console.warn(` Warning: could not reconcile custom policy(ies): ${failed.join("; ")}`); - } -} - function readCurrentManagedSnapshotProfileAuthority(entry: SandboxEntry | null) { return entry ? readManagedSnapshotProfileAuthority({ @@ -1348,16 +1089,6 @@ async function runSnapshotRestoreUnlocked( const hasPendingCreatedClone = targetEntry?.pendingRouteReservation === true && !registry.isRouteOnlySandboxReservation(targetEntry); - if (targetEntry?.baselineExclusionTransition) { - const transition = targetEntry.baselineExclusionTransition; - console.error( - ` Cannot replace destination '${targetSandbox}' while baseline policy '${transition.operation} ${transition.exclusion.key}' needs repair.`, - ); - console.error( - ` Re-run that policy command on '${targetSandbox}' before restoring into it with --force.`, - ); - snapshotExit(1); - } // #3756 P1 preflight: resolve the snapshot selector AND the source pod // image before any destructive action. A bad selector, missing snapshot, @@ -1849,15 +1580,6 @@ async function runSnapshotRestoreUnlocked( // #5027/#4538: openclaw.json restores via the generic copy strategy, which // lands it at 0640. Repair the mutable config contract when needed. repairRestoredOpenClawConfigPerms(targetSandbox, result); - // Reconcile custom policy presets (applied via --from-file/--from-dir). - // Skipped for legacy snapshots that predate the `customPolicies` field. - reconcileSnapshotCustomPolicies(targetSandbox, resolvedSnapshot); - // Reconcile built-in presets after custom content so same-name custom - // policies are never transiently substituted with a built-in. The current - // target observability bit and tier override historical built-in OTLP state. - // Legacy snapshots skip unrelated generic presets but still reconcile the - // managed observability binding from current target state. - reconcileSnapshotPolicyPresets(targetSandbox, resolvedSnapshot); }); if (isCrossSandboxRestore && crossSandboxRestoreAgent === "openclaw") { try { diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index 82945efa053..e08d7640255 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -59,6 +59,8 @@ describe("showSandboxStatus flow", () => { agent: "hermes", agentDisplayName: "Hermes", portableLifecyclePhase: phase, + policies: ["npm", "telegram"], + policiesAvailable: true, }); expect(harness.collectSandboxStatusSnapshotSpy).not.toHaveBeenCalled(); expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); @@ -253,6 +255,7 @@ describe("showSandboxStatus flow", () => { expect(output).toContain("Serving process (openclaw gateway):"); expect(output).toContain("not checked"); expect(output).toContain("Host GPU: yes"); + expect(output).toContain("Policies: npm, telegram"); expect(output).toContain("last CUDA proof failed: cuInit"); expect(output).toContain("CUDA initialization failed"); expect(output).toContain("SSH sessions: 2"); @@ -270,6 +273,16 @@ describe("showSandboxStatus flow", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("reports unavailable live policy instead of an empty policy set", async () => { + const harness = createStatusFlowHarness({ gatewayPresets: null }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("Policies: unavailable"); + expect(output).not.toContain("Policies: none"); + }); + it("reports zero SSH sessions as 'none' without connection-negative language (#7805)", async () => { const harness = createStatusFlowHarness(); harness.getActiveSandboxSessionsSpy.mockReturnValue({ detected: true, sessions: [] }); @@ -329,64 +342,6 @@ describe("showSandboxStatus flow", () => { expect(output).not.toMatch(/^\s*(?:Connected|SSH sessions):/m); }); - it("reports active baseline exclusions and their support impact (#7178)", async () => { - const harness = createStatusFlowHarness({ - sandboxEntry: { - baselineExclusions: [ - { version: 1, agent: "openclaw", key: "nous_research", digest: "digest" }, - ], - }, - }); - - await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); - - const output = harness.logSpy.mock.calls.flat().join("\n"); - expect(output).toContain("Baseline exclusions: nous_research"); - expect(output).toContain("Support impact:"); - expect(output).toContain("unsupported"); - expect(output).toContain("policy restore "); - }); - - it("warns when a recorded exclusion is still present in the live policy (#7178)", async () => { - const harness = createStatusFlowHarness({ - baselineExclusionStatus: "live-policy-mismatch", - sandboxEntry: { - baselineExclusions: [{ version: 1, agent: "openclaw", key: "pypi", digest: "digest" }], - }, - }); - - await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); - - const output = harness.logSpy.mock.calls.flat().join("\n"); - expect(output).toContain("pypi: excluded key is present in live policy"); - }); - - it("reports interrupted baseline policy repair and the exact reconciliation command (#7178)", async () => { - const harness = createStatusFlowHarness({ - sandboxEntry: { - baselineExclusionTransition: { - id: "tx-1", - operation: "restore", - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "digest", - }, - targetLiveDigest: "current-digest", - startedAt: "2026-07-19T00:00:00.000Z", - }, - }, - }); - - await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); - - const output = harness.logSpy.mock.calls.flat().join("\n"); - expect(output).toContain("Baseline policy repair required: interrupted restore"); - expect(output).toContain("rebuild blocked"); - expect(output).toContain("nemoclaw alpha policy restore nous_research"); - }); - it("omits serving-process status when the gateway is unavailable (#7003)", async () => { const harness = createStatusFlowHarness({ lookupState: "missing", diff --git a/src/lib/actions/sandbox/status-snapshot-inference-health.test.ts b/src/lib/actions/sandbox/status-snapshot-inference-health.test.ts index 6bf89c45330..953a831ba14 100644 --- a/src/lib/actions/sandbox/status-snapshot-inference-health.test.ts +++ b/src/lib/actions/sandbox/status-snapshot-inference-health.test.ts @@ -23,7 +23,6 @@ function snapshotDeps( const sandbox: SandboxEntry = { name: "alpha", agent: "openclaw", - policies: [], provider: "nvidia", model: "nvidia/nemotron", ...sandboxOverride, diff --git a/src/lib/actions/sandbox/status-snapshot-recovery.test.ts b/src/lib/actions/sandbox/status-snapshot-recovery.test.ts index c6eb73c1d4a..77dc97a3e45 100644 --- a/src/lib/actions/sandbox/status-snapshot-recovery.test.ts +++ b/src/lib/actions/sandbox/status-snapshot-recovery.test.ts @@ -11,7 +11,6 @@ import { collectSandboxStatusSnapshot, getSandboxStatusReport } from "./status-s const sandbox: SandboxEntry = { name: "alpha", agent: "openclaw", - policies: [], provider: "nvidia", model: "nvidia/nemotron", openshellDriver: "docker", diff --git a/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts b/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts index 2099d3cdc31..5da2127f1f9 100644 --- a/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts +++ b/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts @@ -199,7 +199,6 @@ describe("collectSandboxStatusSnapshot inference invocation route (#9302)", () = const sandbox = { name: "alpha", agent: "openclaw", - policies: [], gatewayName: "nemoclaw", ...entry, } as SandboxEntry; diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index f9c11c9733b..11fd7fe07b9 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -29,8 +29,7 @@ import { normalizeDcodeAutoApprovalMode, } from "../../onboard/dcode-auto-approval"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; -import { getBaselineExclusionRuntimeStatus } from "../../policy"; -import type { BaselineExclusionRuntimeStatus } from "../../policy/baseline-exclusion"; +import { getGatewayPresets } from "../../policy"; import { redact } from "../../security/redact"; import * as registry from "../../state/registry"; import { @@ -177,15 +176,8 @@ export interface SandboxStatusReport { openshellDriver: string; openshellVersion: string; policies: string[]; - /** Baseline network policy keys the operator has excluded, replayed on rebuild. */ - baselineExclusions: string[]; - /** Observed enforcement state for each recorded baseline exclusion. */ - baselineExclusionStates: Array<{ key: string; status: BaselineExclusionRuntimeStatus }>; - /** Interrupted cross-system policy mutation that must be reconciled before rebuild. */ - baselineExclusionTransition: { - operation: registry.BaselineExclusionTransitionOperation; - key: string; - } | null; + /** False when the live OpenShell policy could not be read or parsed. */ + policiesAvailable: boolean; failureLayer: SandboxStatusFailureLayer | null; terminalRuntimeHealth: TerminalRuntimeOomProbeResult | null; /** @@ -303,7 +295,7 @@ interface CollectSandboxStatusSnapshotDeps { recoverSandboxProcesses?: RecoverSandboxProcesses; reconcile?: ReconcileSandboxGatewayState; getSandboxStatusPreflightImpl?: typeof getSandboxStatusPreflight; - getBaselineExclusionRuntimeStatus?: typeof getBaselineExclusionRuntimeStatus; + getGatewayPresets?: typeof getGatewayPresets; } function sanitizedStatusDetail(error: unknown): string { @@ -728,25 +720,7 @@ async function buildSandboxStatusReport( ); const sandboxGpuEnabled = sb ? (sb.sandboxGpuEnabled ?? sb.gpuEnabled === true) : false; const hostMounts = normalizeSandboxStatusHostMounts(sb?.hostMounts); - const policies = - sb && Array.isArray(sb.policies) - ? sb.policies.filter((policy): policy is string => typeof policy === "string") - : []; - const baselineExclusions = sb?.baselineExclusions?.map((exclusion) => exclusion.key) ?? []; - const baselineExclusionStates = - sb?.baselineExclusions?.map((exclusion) => ({ - key: exclusion.key, - status: (deps.getBaselineExclusionRuntimeStatus ?? getBaselineExclusionRuntimeStatus)( - sandboxName, - exclusion, - ), - })) ?? []; - const baselineExclusionTransition = sb?.baselineExclusionTransition - ? { - operation: sb.baselineExclusionTransition.operation, - key: sb.baselineExclusionTransition.exclusion.key, - } - : null; + const livePolicies = sb ? (deps.getGatewayPresets ?? getGatewayPresets)(sandboxName) : []; const agent = resolveSandboxStatusAgent(sb?.agent || "openclaw"); return { schemaVersion: 1, @@ -779,10 +753,8 @@ async function buildSandboxStatusReport( hostMounts, openshellDriver: (sb && sb.openshellDriver) || "unknown", openshellVersion: (sb && sb.openshellVersion) || "unknown", - policies, - baselineExclusions, - baselineExclusionStates, - baselineExclusionTransition, + policies: livePolicies ?? [], + policiesAvailable: livePolicies !== null, failureLayer: effectivePreflight.failureLayer, terminalRuntimeHealth, dockerPaused: !!dockerRuntime?.paused, diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index 397ded57d92..5fadb011664 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -11,11 +11,7 @@ import type { ProviderHealthStatus } from "../../inference/health"; import * as nim from "../../inference/nim"; import { getEffectiveReasoningEffort } from "../../inference/selection"; import { buildSshForwardHintLines } from "../../onboard/ssh-forward-hint"; -import { getBaselineExclusionRuntimeStatus } from "../../policy"; -import { - BASELINE_EXCLUSION_SUPPORT_IMPACT, - type BaselineExclusionRuntimeStatus, -} from "../../policy/baseline-exclusion"; +import { getGatewayPresets } from "../../policy"; import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import type { SandboxEntry, SandboxGpuProofResult } from "../../state/registry"; @@ -36,18 +32,17 @@ import { type ServingProcessHealth, } from "./status-snapshot"; -export interface SandboxStatusTextContext - extends Pick< - SandboxStatusSnapshot, - | "sb" - | "lookup" - | "currentModel" - | "currentProvider" - | "routeDrift" - | "inferenceHealth" - | "terminalRuntimeHealth" - | "servingProcessHealth" - > { +export interface SandboxStatusTextContext extends Pick< + SandboxStatusSnapshot, + | "sb" + | "lookup" + | "currentModel" + | "currentProvider" + | "routeDrift" + | "inferenceHealth" + | "terminalRuntimeHealth" + | "servingProcessHealth" +> { sandboxName: string; statusAgent: SandboxStatusAgentInfo; } @@ -56,42 +51,6 @@ export interface SandboxStatusTextOutcome { exitCode: number | null; } -function describeBaselineExclusionStatus(status: BaselineExclusionRuntimeStatus): string { - switch (status) { - case "excluded": - return "live policy verified"; - case "agent-changed": - return "approval belongs to another agent"; - case "baseline-unreadable": - return "agent baseline unreadable"; - case "content-changed": - return "baseline content changed"; - case "no-longer-in-baseline": - return "key no longer in baseline"; - case "live-policy-unreadable": - return "live policy unreadable"; - case "live-policy-mismatch": - return "excluded key is present in live policy"; - } -} - -function printBaselineExclusions(sandboxName: string, sandbox: SandboxEntry): void { - if (!sandbox.baselineExclusions?.length) return; - console.log( - ` Baseline exclusions: ${sandbox.baselineExclusions.map((entry) => entry.key).join(", ")}`, - ); - console.log(` Support impact: ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`); - console.log( - ` Review or restore with \`${CLI_NAME} ${sandboxName} policy list\` or \`${CLI_NAME} ${sandboxName} policy restore \`.`, - ); - for (const exclusion of sandbox.baselineExclusions) { - const status = getBaselineExclusionRuntimeStatus(sandboxName, exclusion); - if (status !== "excluded") { - console.log(` ${YW}${exclusion.key}: ${describeBaselineExclusionStatus(status)}${R}`); - } - } -} - /** Returns true when status can validate an agent version against the running sandbox. */ function shouldProbeSandboxRuntimeVersion( lookup: SandboxGatewayState, @@ -372,17 +331,10 @@ export function printSandboxDetails(context: SandboxStatusTextContext): SandboxS console.log( ` OpenShell: ${sb.openshellVersion || "unknown"} (${sb.openshellDriver || "unknown"})`, ); - console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); - printBaselineExclusions(sandboxName, sb); - if (sb.baselineExclusionTransition) { - const transition = sb.baselineExclusionTransition; - console.log( - ` Baseline policy repair required: interrupted ${transition.operation} for ${transition.exclusion.key} (rebuild blocked)`, - ); - console.log( - ` Re-run \`${CLI_NAME} ${sandboxName} policy ${transition.operation} ${transition.exclusion.key}\` to reconcile live and durable state.`, - ); - } + const livePolicies = getGatewayPresets(sandboxName); + console.log( + ` Policies: ${livePolicies === null ? "unavailable" : livePolicies.join(", ") || "none"}`, + ); const agentExitCode = printAgentHarness(context); printActiveSessions(sandboxName); printShieldsPosture(sandboxName); diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 0d64b62f974..d1f29c47488 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -5,6 +5,7 @@ import { printOpenShellStateRpcIssue } from "../../adapters/openshell/gateway-dr import { CLI_NAME } from "../../cli/branding"; import { deferSandboxLifecycleExit, isSandboxLifecycleDeferredExit } from "../../core/process-exit"; import { inspectManagedLlamaCppStatus } from "../../inference/llama-cpp/managed-status"; +import { getGatewayPresets } from "../../policy"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; import * as registry from "../../state/registry"; import { getSandboxDockerRuntime } from "./docker-health"; @@ -74,10 +75,12 @@ function getPublishedSandbox(sandboxName: string): registry.SandboxEntry | null function hermesPortableStatusReport( sandboxName: string, authority: HermesPortableAgentLifecycleAuthority, + readPolicies: typeof getGatewayPresets, ): SandboxStatusReport { const { entry, phase } = authority; const model = entry?.model ?? "unknown"; const provider = entry?.provider ?? "unknown"; + const livePolicies = readPolicies(sandboxName); return { schemaVersion: 1, name: sandboxName, @@ -106,16 +109,8 @@ function hermesPortableStatusReport( hostMounts: normalizeSandboxStatusHostMounts(entry?.hostMounts), openshellDriver: entry?.openshellDriver ?? "unknown", openshellVersion: entry?.openshellVersion ?? "unknown", - policies: - entry?.policies?.filter((policy): policy is string => typeof policy === "string") ?? [], - baselineExclusions: entry?.baselineExclusions?.map((exclusion) => exclusion.key) ?? [], - baselineExclusionStates: [], - baselineExclusionTransition: entry?.baselineExclusionTransition - ? { - operation: entry.baselineExclusionTransition.operation, - key: entry.baselineExclusionTransition.exclusion.key, - } - : null, + policies: livePolicies ?? [], + policiesAvailable: livePolicies !== null, failureLayer: null, terminalRuntimeHealth: null, servingProcessHealth: null, @@ -129,7 +124,13 @@ export async function getSandboxStatusReport( ): Promise { return withMcpLifecycleLock(sandboxName, async () => { const hermesPortable = inspectHermesPortableStatus(sandboxName); - if (hermesPortable) return hermesPortableStatusReport(sandboxName, hermesPortable); + if (hermesPortable) { + return hermesPortableStatusReport( + sandboxName, + hermesPortable, + deps.getGatewayPresets ?? getGatewayPresets, + ); + } return getLegacySandboxStatusReport(sandboxName, deps); }); } diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts index 5269090ce94..a0302aa2b62 100644 --- a/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts @@ -221,11 +221,11 @@ describe("OpenShell VM DNS monkeypatch", () => { const initPath = path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"); writeRootfsFiles(rootfs, "nameserver 8.8.8.8\n"); const originalInit = fs.readFileSync(initPath, "utf-8"); - const revalidatePolicyAuthority = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); expect(() => @@ -235,15 +235,15 @@ describe("OpenShell VM DNS monkeypatch", () => { { capture: () => ({ status: 0, output: "Id: abc\n" }), platform: "darwin", - revalidatePolicyAuthority, + verifyLivePolicyRequirements, stateDir, }, ), - ).toThrow("policy authority changed"); + ).toThrow("policy requirements changed"); expect(fs.readFileSync(resolverPath, "utf-8")).toBe("nameserver 192.168.127.1\n"); expect(fs.readFileSync(initPath, "utf-8")).toBe(originalInit); - expect(revalidatePolicyAuthority).toHaveBeenCalledTimes(2); + expect(verifyLivePolicyRequirements).toHaveBeenCalledTimes(2); }); it("is idempotent when resolver and init script are already patched", () => { diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts index f2196c48f7b..1df26ed7cf7 100644 --- a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts @@ -280,7 +280,7 @@ export function applyOpenShellVmDnsMonkeypatch( env?: NodeJS.ProcessEnv; homeDir?: string; platform?: NodeJS.Platform; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; stateDir?: string; } = {}, ): VmDnsMonkeypatchResult { @@ -312,12 +312,12 @@ export function applyOpenShellVmDnsMonkeypatch( let changed = false; let rootfsContext: string | undefined; - let policyAuthorityError: unknown; - const revalidatePolicyAuthority = (operation: string): void => { + let policyObservationError: unknown; + const verifyLivePolicyRequirements = (operation: string): void => { try { - deps.revalidatePolicyAuthority?.(operation); + deps.verifyLivePolicyRequirements?.(operation); } catch (error) { - policyAuthorityError = error; + policyObservationError = error; throw error; } }; @@ -368,13 +368,13 @@ export function applyOpenShellVmDnsMonkeypatch( const currentResolver = readTextFileIfPresent(resolvConf.path) ?? ""; const desiredResolver = normalizeResolver(currentResolver); if (currentResolver !== desiredResolver) { - revalidatePolicyAuthority(`write VM resolver for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(`write VM resolver for sandbox '${sandboxName}'`); fs.writeFileSync(resolvConf.path, desiredResolver); changed = true; } if (initPatch.changed && initPatch.content !== undefined) { - revalidatePolicyAuthority(`write VM init script for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(`write VM init script for sandbox '${sandboxName}'`); fs.writeFileSync(initScript.path, initPatch.content); changed = true; } @@ -388,7 +388,7 @@ export function applyOpenShellVmDnsMonkeypatch( ); } - revalidatePolicyAuthority(`report successful VM DNS repair for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(`report successful VM DNS repair for sandbox '${sandboxName}'`); return { attempted: true, @@ -398,7 +398,7 @@ export function applyOpenShellVmDnsMonkeypatch( status: changed ? "applied" : "already-present", }; } catch (error) { - if (error === policyAuthorityError) throw error; + if (error === policyObservationError) throw error; return { attempted: true, changed, diff --git a/src/lib/actions/upgrade-sandboxes-recovery.test.ts b/src/lib/actions/upgrade-sandboxes-recovery.test.ts index 6fd1b46b7ae..be3a1f3b808 100644 --- a/src/lib/actions/upgrade-sandboxes-recovery.test.ts +++ b/src/lib/actions/upgrade-sandboxes-recovery.test.ts @@ -35,8 +35,6 @@ function makeManifest(sandboxName: string, agentType: ManifestAgentType = "openc dir: MANIFEST_DIR_BY_AGENT[agentType], backupPath: `/tmp/rebuild-backups/${sandboxName}/${timestamp}`, blueprintDigest: null, - policyPresets: [], - customPolicies: [], snapshotVersion: 1, }; } diff --git a/src/lib/adapters/openshell/policy-authority.test.ts b/src/lib/adapters/openshell/policy-authority.test.ts deleted file mode 100644 index c11d5859119..00000000000 --- a/src/lib/adapters/openshell/policy-authority.test.ts +++ /dev/null @@ -1,589 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createHash } from "node:crypto"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as openshellRuntimeModule from "./runtime"; -import { - assertExternalPolicyRequirements, - assertOpenShellGatewayPortBinding, - assertRecordedPolicyAuthority, - captureSandboxBasePolicy, - inspectActiveGlobalPolicy, - inspectOpenShellSandboxPolicyReadiness, - inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, - isExternalPolicyAuthorityRefusalError, - policyAuthorityInternals, - type SandboxPolicyAuthorityInspection, -} from "./policy-authority"; - -function captureResult( - stdout: string, - overrides: Partial<{ - stderr: string; - status: number | null; - error: Error; - }> = {}, -) { - return { - status: overrides.status === undefined ? 0 : overrides.status, - output: stdout, - stdout, - stderr: overrides.stderr ?? "", - ...(overrides.error ? { error: overrides.error } : {}), - }; -} - -function captureError(code: string, message: string): Error { - return Object.assign(new Error(message), { code }); -} - -function sandboxMetadata(overrides: Record = {}): Record { - return { - scope: "sandbox", - sandbox: "alpha", - status: "effective", - policy_source: "sandbox", - hash: "policy-alpha", - active_version: 7, - policy: { version: 1, network_policies: { baseline: { endpoints: ["base.test"] } } }, - ...overrides, - }; -} - -function sandboxReadiness(overrides: Record = {}): string { - return JSON.stringify([ - { - id: "sandbox-alpha", - name: "alpha", - labels: {}, - resource_version: 9, - created_at: "2026-08-25T00:00:00Z", - phase: "Ready", - current_policy_version: 7, - ...overrides, - }, - ]); -} - -function errorFrom(action: () => unknown): Error { - try { - action(); - } catch (error) { - expect(error).toBeInstanceOf(Error); - return error as Error; - } - throw new Error("expected the action to throw"); -} - -describe("OpenShell policy authority inspection", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - - it("leaves a sandbox-scoped effective policy owner unknown (#9833)", () => { - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue(captureResult(JSON.stringify(sandboxMetadata()))); - - expect(inspectSandboxPolicyAuthority({ sandboxName: "alpha" })).toEqual({ - authority: "owner-unknown", - effectivePolicy: { - version: 1, - network_policies: { baseline: { endpoints: ["base.test"] } }, - }, - policyIdentity: { hash: "policy-alpha", activeVersion: 7 }, - }); - expect(captureOpenshell).toHaveBeenCalledWith( - ["policy", "get", "--full", "--output", "json", "alpha"], - expect.objectContaining({ - ignoreError: true, - includeStreams: true, - maxBuffer: policyAuthorityInternals.captureMaxBytes, - replaceEnv: true, - timeout: policyAuthorityInternals.captureTimeoutMs, - }), - ); - }); - - it("requires the exact Ready sandbox row to report the effective policy version (#9833)", () => { - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue(captureResult(sandboxReadiness())); - const sandboxIdentityFingerprint = createHash("sha256").update("sandbox-alpha").digest("hex"); - - expect( - inspectOpenShellSandboxPolicyReadiness({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - sandboxIdentityFingerprint, - policyVersion: 7, - }), - ).toEqual({ state: "ready" }); - expect(captureOpenshell.mock.calls[0]?.[0]).toEqual([ - "sandbox", - "list", - "-g", - "nemoclaw-18080", - "--output", - "json", - "--limit", - "1000", - ]); - }); - - it.each([ - ["sandbox phase", { phase: "Provisioning" }, "sandbox-not-ready"], - ["policy version", { current_policy_version: 6 }, "policy-version-pending"], - ] as const)("classifies a pending %s as transient (#9833)", (_name, change, reason) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult(sandboxReadiness(change)), - ); - - expect( - inspectOpenShellSandboxPolicyReadiness({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - sandboxIdentityFingerprint: createHash("sha256").update("sandbox-alpha").digest("hex"), - policyVersion: 7, - }), - ).toEqual({ state: "transient", reason }); - }); - - it("fails closed when the Ready row belongs to a replacement sandbox (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult(sandboxReadiness({ id: "sandbox-replacement" })), - ); - - expect(() => - inspectOpenShellSandboxPolicyReadiness({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - sandboxIdentityFingerprint: createHash("sha256").update("sandbox-alpha").digest("hex"), - policyVersion: 7, - }), - ).toThrow(/live sandbox identity changed/u); - }); - - it("recognizes a global policy source as externally managed on the recorded gateway (#9833)", () => { - const policy = { version: 1, network_policies: { required: { endpoints: ["api.test"] } } }; - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue( - captureResult(JSON.stringify(sandboxMetadata({ policy_source: "global", policy }))), - ); - - expect( - inspectSandboxPolicyAuthority({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - }), - ).toEqual({ - authority: "externally-managed", - effectivePolicy: policy, - policyIdentity: { hash: "policy-alpha", activeVersion: 7 }, - }); - expect(captureOpenshell.mock.calls[0]?.[0]).toEqual([ - "policy", - "get", - "-g", - "nemoclaw-18080", - "--full", - "--output", - "json", - "alpha", - ]); - }); - - it("reads an active global policy through the bounded selected-gateway boundary (#9833)", () => { - const policy = { version: 1, network_policies: { required: { endpoints: ["api.test"] } } }; - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(captureResult("global revision 7")) - .mockReturnValueOnce( - captureResult( - JSON.stringify({ - scope: "global", - status: "loaded", - policy_source: "global", - hash: "global-policy", - active_version: 7, - policy, - }), - ), - ); - - expect(inspectActiveGlobalPolicy({ gatewayName: "nemoclaw-18080" })).toEqual({ - state: "active", - inspection: { - authority: "externally-managed", - effectivePolicy: policy, - policyIdentity: { hash: "global-policy", activeVersion: 7 }, - }, - }); - expect(captureOpenshell.mock.calls.map(([args]) => args)).toEqual([ - ["policy", "list", "-g", "nemoclaw-18080", "--global", "--limit", "1"], - ["policy", "get", "-g", "nemoclaw-18080", "--global", "--full", "--output", "json"], - ]); - }); - - it("recognizes the OpenShell 0.0.106 fresh-gateway history response as absent (#9833)", () => { - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue(captureResult("", { stderr: "No global policy history found\n" })); - - expect(inspectActiveGlobalPolicy({ gatewayName: "nemoclaw-18080" })).toEqual({ - state: "absent", - }); - expect(captureOpenshell).toHaveBeenCalledTimes(1); - }); - - it.each([ - ["empty output", ""], - ["an unexpected diagnostic", "captured-stderr-secret"], - ])("fails closed on %s from a successful global history read (#9833)", (_name, stderr) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult("", { stderr }), - ); - - const error = errorFrom(() => inspectActiveGlobalPolicy({ gatewayName: "nemoclaw-18080" })); - expect(error.message).toContain("invalid global policy history"); - expect(error.message).not.toContain("captured-stderr-secret"); - }); - - it("keeps a canonical absence response non-authoritative after a failed command (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult("", { status: 7, stderr: "No global policy history found\n" }), - ); - - expect(() => inspectActiveGlobalPolicy({ gatewayName: "nemoclaw-18080" })).toThrow( - /did not complete successfully/u, - ); - }); - - it("preserves fresh-gateway config and workspace without accepting endpoint overrides (#9833)", () => { - vi.stubEnv("XDG_CONFIG_HOME", "/tmp/nemoclaw-openshell-config"); - vi.stubEnv("OPENSHELL_WORKSPACE", "selected-workspace"); - vi.stubEnv("OPENSHELL_GATEWAY", "ambient-sibling"); - vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://ambient.invalid"); - vi.stubEnv("OPENSHELL_GATEWAY_INSECURE", "true"); - vi.stubEnv("OPENAI_API_KEY", "must-not-cross-policy-boundary"); - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue(captureResult("", { stderr: "No global policy history found\n" })); - - expect(inspectActiveGlobalPolicy({ gatewayName: "nemoclaw-18080" })).toEqual({ - state: "absent", - }); - const options = captureOpenshell.mock.calls[0]?.[1]; - expect(options?.env).toMatchObject({ - OPENSHELL_GATEWAY: "nemoclaw-18080", - OPENSHELL_WORKSPACE: "selected-workspace", - XDG_CONFIG_HOME: "/tmp/nemoclaw-openshell-config", - }); - expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); - expect(options?.env).not.toHaveProperty("OPENSHELL_GATEWAY_INSECURE"); - expect(options?.env).not.toHaveProperty("OPENAI_API_KEY"); - }); - - it("binds sandbox and gateway authority reads to the selected config (#9833)", () => { - vi.stubEnv("XDG_CONFIG_HOME", "/tmp/nemoclaw-openshell-config"); - vi.stubEnv("OPENSHELL_WORKSPACE", "selected-workspace"); - vi.stubEnv("OPENSHELL_GATEWAY", "ambient-sibling"); - vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://ambient.invalid"); - vi.stubEnv("OPENAI_API_KEY", "must-not-cross-policy-boundary"); - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(captureResult(JSON.stringify(sandboxMetadata()))) - .mockReturnValueOnce(captureResult("version: 1\n")) - .mockReturnValueOnce(captureResult("Name: alpha\nId: sandbox-alpha\nPhase: Ready\n")) - .mockReturnValueOnce(captureResult("Gateway endpoint: http://127.0.0.1:18080\n")); - - inspectSandboxPolicyAuthority({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - }); - expect(captureSandboxBasePolicy("alpha", "nemoclaw-18080")).toBe("version: 1\n"); - inspectOpenShellSandboxIdentityFingerprint({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - }); - assertOpenShellGatewayPortBinding({ gatewayName: "nemoclaw-18080", gatewayPort: 18080 }); - - expect(captureOpenshell).toHaveBeenCalledTimes(4); - const environments = captureOpenshell.mock.calls.map(([, options]) => options?.env); - expect(environments).toEqual([ - expect.objectContaining({ - OPENSHELL_GATEWAY: "nemoclaw-18080", - OPENSHELL_WORKSPACE: "selected-workspace", - XDG_CONFIG_HOME: "/tmp/nemoclaw-openshell-config", - }), - expect.objectContaining({ - OPENSHELL_GATEWAY: "nemoclaw-18080", - OPENSHELL_WORKSPACE: "selected-workspace", - XDG_CONFIG_HOME: "/tmp/nemoclaw-openshell-config", - }), - expect.objectContaining({ - OPENSHELL_GATEWAY: "nemoclaw-18080", - OPENSHELL_WORKSPACE: "selected-workspace", - XDG_CONFIG_HOME: "/tmp/nemoclaw-openshell-config", - }), - expect.objectContaining({ - OPENSHELL_GATEWAY: "nemoclaw-18080", - OPENSHELL_WORKSPACE: "selected-workspace", - XDG_CONFIG_HOME: "/tmp/nemoclaw-openshell-config", - }), - ]); - expect( - environments.map((environment) => - Object.hasOwn(environment ?? {}, "OPENSHELL_GATEWAY_ENDPOINT"), - ), - ).toEqual([false, false, false, false]); - expect( - environments.map((environment) => Object.hasOwn(environment ?? {}, "OPENAI_API_KEY")), - ).toEqual([false, false, false, false]); - }); - - it("rejects invalid sandbox and gateway identities before querying policy (#9833)", () => { - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue(captureResult(JSON.stringify(sandboxMetadata()))); - - expect(() => inspectSandboxPolicyAuthority({ sandboxName: "--global" })).toThrow( - /Invalid sandbox name/, - ); - expect(() => - inspectSandboxPolicyAuthority({ - sandboxName: "alpha", - gatewayName: "invalid gateway", - }), - ).toThrow(/Invalid gateway name/); - expect(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha", gatewayName: "" })).toThrow( - /gateway name is required/, - ); - expect(captureOpenshell).not.toHaveBeenCalled(); - }); - - it.each([ - ["another scope", sandboxMetadata({ scope: "global" })], - ["another sandbox", sandboxMetadata({ sandbox: "beta" })], - ["an unknown source", sandboxMetadata({ policy_source: "unknown" })], - ])("rejects sandbox metadata with %s (#9833)", (_caseName, metadata) => { - const secret = "captured-policy-secret"; - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult(JSON.stringify({ ...metadata, diagnostic: secret })), - ); - - const error = errorFrom(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha" })); - expect(error.message).toContain("inspection failed"); - expect(error.message).not.toContain(secret); - }); - - it.each(["", " \n\t"])("fails closed when sandbox policy output is empty (%j) (#9833)", (raw) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult(raw), - ); - - expect(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha" })).toThrow( - /empty sandbox policy authority metadata/u, - ); - }); - - it.each([ - ["a nonzero exit", { status: 7 }], - ["a timeout", { status: null, error: captureError("ETIMEDOUT", "captured-timeout-secret") }], - ["malformed JSON", {}], - ])("fails closed without exposing output after %s (#9833)", (_caseName, overrides) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult('{"secret":"captured-stdout-secret"', { - ...overrides, - stderr: "captured-stderr-secret", - }), - ); - - const error = errorFrom(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha" })); - expect(error.message).not.toContain("captured-stdout-secret"); - expect(error.message).not.toContain("captured-stderr-secret"); - }); - - it("replaces a thrown capture diagnostic instead of exposing command output (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockImplementation(() => { - throw new Error("captured-policy-secret"); - }); - - const error = errorFrom(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha" })); - expect(error.message).toContain("could not run"); - expect(error.message).not.toContain("captured-policy-secret"); - }); - - it("rejects a captured policy response that exceeds the byte limit (#9833)", () => { - const secret = "captured-oversized-secret"; - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockReturnValue( - captureResult(secret, { - status: null, - error: captureError("ENOBUFS", secret), - }), - ); - - const error = errorFrom(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha" })); - expect(error.message).toContain("capture limit"); - expect(error.message).not.toContain(secret); - }); - - it("reads one sandbox identity through the canonical bounded adapter (#9833)", () => { - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValue(captureResult("Name: alpha\nId: sandbox-alpha\nPhase: Ready\n")); - - expect( - inspectOpenShellSandboxIdentityFingerprint({ - sandboxName: "alpha", - gatewayName: "nemoclaw-18080", - }), - ).toBe(createHash("sha256").update("sandbox-alpha").digest("hex")); - expect(captureOpenshell).toHaveBeenCalledWith( - ["sandbox", "get", "-g", "nemoclaw-18080", "alpha"], - expect.objectContaining({ - ignoreError: true, - includeStreams: true, - maxBuffer: policyAuthorityInternals.captureMaxBytes, - replaceEnv: true, - timeout: policyAuthorityInternals.captureTimeoutMs, - }), - ); - }); - - it("rejects an ambiguous or failed sandbox identity without exposing output (#9833)", () => { - const captureOpenshell = vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell"); - captureOpenshell.mockReturnValueOnce( - captureResult("Name: alpha\nId: first-secret\nId: second-secret\nPhase: Ready\n"), - ); - expect(() => - inspectOpenShellSandboxIdentityFingerprint({ - sandboxName: "alpha", - gatewayName: "nemoclaw", - }), - ).toThrow("OpenShell did not return one exact durable sandbox ID"); - - captureOpenshell.mockReturnValueOnce( - captureResult("captured-stdout-secret", { - status: 7, - stderr: "captured-stderr-secret", - }), - ); - const error = errorFrom(() => - inspectOpenShellSandboxIdentityFingerprint({ - sandboxName: "alpha", - gatewayName: "nemoclaw", - }), - ); - expect(error.message).not.toContain("captured-stdout-secret"); - expect(error.message).not.toContain("captured-stderr-secret"); - }); -}); - -describe("recorded policy authority", () => { - it("accepts unchanged authority and refuses missing or changed authority (#9833)", () => { - expect(() => - assertRecordedPolicyAuthority("externally-managed", "externally-managed", "rebuild"), - ).not.toThrow(); - expect(() => - assertRecordedPolicyAuthority(undefined, "externally-managed", "restore the snapshot"), - ).toThrow(/recorded policy authority is unavailable or invalid/); - expect(() => - assertRecordedPolicyAuthority( - "nemoclaw-managed", - "externally-managed", - "restore the snapshot", - ), - ).toThrow(/changed from nemoclaw-managed to externally-managed/); - expect(() => - assertRecordedPolicyAuthority("externally-managed", "unknown", "restore the snapshot"), - ).toThrow(/observed OpenShell policy authority is unavailable or invalid/); - }); - - it("classifies an observed external authority without parsing diagnostics (#9833)", () => { - const externalError = errorFrom(() => - assertRecordedPolicyAuthority( - "nemoclaw-managed", - "externally-managed", - "restore the snapshot", - ), - ); - const managedError = errorFrom(() => - assertRecordedPolicyAuthority( - "externally-managed", - "nemoclaw-managed", - "restore the snapshot", - ), - ); - - expect(isExternalPolicyAuthorityRefusalError(externalError)).toBe(true); - expect(isExternalPolicyAuthorityRefusalError(managedError)).toBe(false); - }); -}); - -describe("externally managed policy requirements", () => { - it("compares exact requirements and redacts missing or drifted contents (#9833)", () => { - const requiredPolicy = { - version: 1, - filesystem_policy: { read_only: ["/required-secret"] }, - process: { run_as_user: 1000 }, - network_policies: { - exact: { endpoints: [{ host: "api.test", port: 443 }], mode: "allow" }, - missing: { endpoints: [{ host: "missing-secret.test", port: 443 }] }, - drifted: { endpoints: [{ host: "required-secret.test", port: 443 }] }, - }, - }; - const inspection: SandboxPolicyAuthorityInspection = { - authority: "externally-managed", - policyIdentity: { hash: "policy-alpha", activeVersion: 7 }, - effectivePolicy: { - version: 9, - filesystem_policy: { read_only: ["/observed-secret"] }, - network_policies: { - exact: { mode: "allow", endpoints: [{ port: 443, host: "api.test" }] }, - drifted: { endpoints: [{ host: "observed-secret.test", port: 443 }] }, - }, - }, - }; - - const error = errorFrom(() => - assertExternalPolicyRequirements({ - inspection, - requiredPolicy, - operation: "enable messaging", - sandboxName: "alpha", - }), - ); - expect(error.message).toContain('missing sections "process"'); - expect(error.message).toContain('drifted sections "filesystem_policy"'); - expect(error.message).toContain('missing entries "missing"'); - expect(error.message).toContain('drifted entries "drifted"'); - expect(error.message).not.toMatch( - /required-secret|observed-secret|missing-secret\.test|observed-secret\.test/u, - ); - }); - - it("leaves NemoClaw-managed requirements to the mutation path (#9833)", () => { - expect(() => - assertExternalPolicyRequirements({ - inspection: { - authority: "nemoclaw-managed", - effectivePolicy: {}, - policyIdentity: { hash: "policy-alpha", activeVersion: 7 }, - }, - requiredPolicy: { network_policies: { required: { endpoints: ["api.test"] } } }, - operation: "apply a preset", - }), - ).not.toThrow(); - }); -}); diff --git a/src/lib/adapters/openshell/policy-state.test.ts b/src/lib/adapters/openshell/policy-state.test.ts new file mode 100644 index 00000000000..c2776ad5f67 --- /dev/null +++ b/src/lib/adapters/openshell/policy-state.test.ts @@ -0,0 +1,154 @@ +// 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 * as openshellRuntime from "./runtime"; +import { + assertObservedPolicyRequirements, + assertOpenShellGatewayPortBinding, + captureSandboxBasePolicy, + inspectActiveGlobalPolicy, + inspectSandboxPolicy, + isPolicyObservationError, + policyStateInternals, +} from "./policy-state"; + +function capture(stdout: string, overrides: Record = {}) { + return { status: 0, output: stdout, stdout, stderr: "", ...overrides }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("OpenShell policy observation", () => { + it("reads current sandbox metadata without assigning ownership", () => { + vi.spyOn(openshellRuntime, "captureResolvedOpenshell").mockReturnValue( + capture( + JSON.stringify({ + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "sandbox", + hash: "sha256:policy", + active_version: 4, + policy: { version: 1, network_policies: { npm: { endpoints: [] } } }, + }), + ) as never, + ); + expect(inspectSandboxPolicy({ sandboxName: "alpha", gatewayName: "nemoclaw" })).toEqual({ + policySource: "sandbox", + effectivePolicy: { version: 1, network_policies: { npm: { endpoints: [] } } }, + policyIdentity: { hash: "sha256:policy", activeVersion: 4 }, + }); + }); + + it("uses bounded capture and classifies timeouts", () => { + const spy = vi + .spyOn(openshellRuntime, "captureResolvedOpenshell") + .mockReturnValue( + capture("", { error: Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }) }) as never, + ); + let observed: unknown; + try { + inspectSandboxPolicy({ sandboxName: "alpha", gatewayName: "nemoclaw" }); + } catch (error) { + observed = error; + } + expect(isPolicyObservationError(observed)).toBe(true); + expect(spy).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + maxBuffer: policyStateInternals.captureMaxBytes, + timeout: policyStateInternals.captureTimeoutMs, + }), + ); + }); + + it("extracts round-trippable YAML from the live base policy display", () => { + vi.spyOn(openshellRuntime, "captureResolvedOpenshell").mockReturnValue( + capture( + [ + "Version: 13", + "Hash: sha256:current", + "Updated: 2026-08-28T00:00:00Z", + "---", + "version: 1", + "network_policies: {}", + "", + ].join("\n"), + ) as never, + ); + expect(captureSandboxBasePolicy("alpha", "nemoclaw")).toBe("version: 1\nnetwork_policies: {}"); + }); + + it("rejects a metadata-only base policy display", () => { + vi.spyOn(openshellRuntime, "captureResolvedOpenshell").mockReturnValue( + capture("Version: 13\nHash: sha256:current\n") as never, + ); + expect(() => captureSandboxBasePolicy("alpha", "nemoclaw")).toThrow( + /policy inspection failed/u, + ); + }); + + it("reads active global policy through the selected gateway", () => { + const spy = vi + .spyOn(openshellRuntime, "captureResolvedOpenshell") + .mockReturnValueOnce(capture("VERSION STATUS\n1 loaded\n") as never) + .mockReturnValueOnce( + capture( + JSON.stringify({ + scope: "global", + status: "loaded", + policy_source: "global", + hash: "sha256:global", + active_version: 2, + policy: { version: 1, network_policies: {} }, + }), + ) as never, + ); + + expect(inspectActiveGlobalPolicy({ gatewayName: "nemoclaw" })).toEqual({ + state: "active", + inspection: { + policySource: "global", + effectivePolicy: { version: 1, network_policies: {} }, + policyIdentity: { hash: "sha256:global", activeVersion: 2 }, + }, + }); + expect(spy.mock.calls.map(([args]) => args)).toEqual([ + ["policy", "list", "-g", "nemoclaw", "--global", "--limit", "1"], + ["policy", "get", "-g", "nemoclaw", "--global", "--full", "--output", "json"], + ]); + }); + + it("validates required entries while allowing unrelated host changes", () => { + expect(() => + assertObservedPolicyRequirements({ + operation: "continue onboarding", + inspection: { + policySource: "sandbox", + policyIdentity: { hash: "sha256:policy", activeVersion: 4 }, + effectivePolicy: { + version: 1, + network_policies: { required: { endpoints: [] }, host_added: { endpoints: [] } }, + }, + }, + requiredPolicy: { network_policies: { required: { endpoints: [] } } }, + }), + ).not.toThrow(); + }); + + it("checks only the recorded gateway endpoint binding", () => { + vi.spyOn(openshellRuntime, "captureResolvedOpenshell").mockReturnValue( + capture( + "Gateway Info\nGateway: nemoclaw\nGateway endpoint: https://127.0.0.1:8080\n", + ) as never, + ); + expect(() => + assertOpenShellGatewayPortBinding({ gatewayName: "nemoclaw", gatewayPort: 8080 }), + ).not.toThrow(); + }); +}); diff --git a/src/lib/adapters/openshell/policy-authority.ts b/src/lib/adapters/openshell/policy-state.ts similarity index 63% rename from src/lib/adapters/openshell/policy-authority.ts rename to src/lib/adapters/openshell/policy-state.ts index 99302e2974e..479a6077253 100644 --- a/src/lib/adapters/openshell/policy-authority.ts +++ b/src/lib/adapters/openshell/policy-state.ts @@ -14,15 +14,13 @@ import { buildPolicyGetFullJsonArgs, } from "../../policy/commands"; import { - assertExternalPolicyRequirementContainment, - assertMatchingPolicyAuthority, assertPolicyRequirementContainment, classifyOpenShellGlobalPolicyHistory, - parseActiveGlobalPolicyAuthorityMetadata, + parseActiveGlobalPolicyMetadata, + parseOpenShellPolicy, type ActiveGlobalPolicyInspection, - type OpenShellPolicyAuthority, - parseSandboxPolicyAuthorityMetadata, - type SandboxPolicyAuthorityInspection as CanonicalSandboxPolicyAuthorityInspection, + type OpenShellPolicyInspection, + parseSandboxPolicyMetadata, } from "../../policy/merge"; import * as openshellRuntime from "./runtime"; import { @@ -30,13 +28,12 @@ import { fingerprintOpenShellSandboxLiveIdentity, parseStrictOpenShellSandboxListJson, } from "./sandbox-identity"; -const POLICY_AUTHORITY_CAPTURE_MAX_BYTES = 1024 * 1024; -const POLICY_AUTHORITY_CAPTURE_TIMEOUT_MS = 30_000; +const POLICY_STATE_CAPTURE_MAX_BYTES = 1024 * 1024; +const POLICY_STATE_CAPTURE_TIMEOUT_MS = 30_000; type JsonObject = Record; -export type SandboxPolicyAuthority = OpenShellPolicyAuthority; -export type SandboxPolicyAuthorityInspection = CanonicalSandboxPolicyAuthorityInspection; +export type SandboxPolicyInspection = OpenShellPolicyInspection; export type { ActiveGlobalPolicyInspection } from "../../policy/merge"; export type OpenShellSandboxPolicyReadiness = @@ -46,38 +43,27 @@ export type OpenShellSandboxPolicyReadiness = readonly reason: "sandbox-not-ready" | "policy-version-pending"; }; -const POLICY_AUTHORITY_REFUSAL_CODE = "NEMOCLAW_POLICY_AUTHORITY_REFUSAL"; +const POLICY_OBSERVATION_ERROR_CODE = "NEMOCLAW_POLICY_OBSERVATION_ERROR"; -/** A final refusal at the OpenShell policy authority boundary. */ -export class PolicyAuthorityRefusalError extends Error { - readonly code = POLICY_AUTHORITY_REFUSAL_CODE; - readonly observedAuthority?: SandboxPolicyAuthority; +/** A final failure while observing or validating live OpenShell policy. */ +export class PolicyObservationError extends Error { + readonly code = POLICY_OBSERVATION_ERROR_CODE; - constructor(message: string, observedAuthority?: SandboxPolicyAuthority, options?: ErrorOptions) { + constructor(message: string, options?: ErrorOptions) { super(message, options); - this.name = "PolicyAuthorityRefusalError"; - this.observedAuthority = observedAuthority; + this.name = "PolicyObservationError"; } } -/** Recognize policy-authority refusals across CommonJS and ESM module boundaries. */ -export function isPolicyAuthorityRefusalError(error: unknown): boolean { +/** Recognize live-policy observation failures across module boundaries. */ +export function isPolicyObservationError(error: unknown): boolean { return ( - error instanceof PolicyAuthorityRefusalError || - (isObject(error) && error.code === POLICY_AUTHORITY_REFUSAL_CODE) + error instanceof PolicyObservationError || + (isObject(error) && error.code === POLICY_OBSERVATION_ERROR_CODE) ); } -/** Recognize a refusal caused by an externally managed observed policy. */ -export function isExternalPolicyAuthorityRefusalError(error: unknown): boolean { - return ( - isPolicyAuthorityRefusalError(error) && - isObject(error) && - error.observedAuthority === "externally-managed" - ); -} - -interface SandboxPolicyAuthorityInspectionOptions { +interface SandboxPolicyInspectionOptions { readonly sandboxName: string; readonly gatewayName?: string; } @@ -86,19 +72,19 @@ interface ActiveGlobalPolicyInspectionOptions { readonly gatewayName?: string; } -function validatePolicyAuthorityName(name: string, label: string): string { +function validatePolicyName(name: string, label: string): string { if (!name || typeof name !== "string") { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `${label} is required. Allowed format: ${NAME_ALLOWED_FORMAT}.`, ); } if (name.length > NAME_MAX_LENGTH) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `${label} too long (max ${NAME_MAX_LENGTH} chars): ${diagnosticPreview(name)}. Allowed format: ${NAME_ALLOWED_FORMAT}.`, ); } if (isValidName(name)) return name; - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Invalid ${label}: ${diagnosticPreview(name)}. Allowed format: ${NAME_ALLOWED_FORMAT}.`, ); } @@ -108,8 +94,8 @@ function isObject(value: unknown): value is JsonObject { } function failInspection(subject: "sandbox" | "global" | "gateway", reason: string): never { - throw new PolicyAuthorityRefusalError( - `OpenShell ${subject} policy authority inspection failed: ${reason}. Policy-dependent operations must stop.`, + throw new PolicyObservationError( + `OpenShell ${subject} policy inspection failed: ${reason}. Policy-dependent operations must stop.`, ); } @@ -133,16 +119,16 @@ function captureBoundedOpenShell( env, ignoreError: true, includeStreams: true, - maxBuffer: POLICY_AUTHORITY_CAPTURE_MAX_BYTES, + maxBuffer: POLICY_STATE_CAPTURE_MAX_BYTES, replaceEnv: true, - timeout: POLICY_AUTHORITY_CAPTURE_TIMEOUT_MS, + timeout: POLICY_STATE_CAPTURE_TIMEOUT_MS, }); } catch { failInspection(subject, "the policy query could not run"); } } -function captureAuthorityRead( +function capturePolicyCommand( args: string[], subject: "sandbox" | "global" | "gateway", runtimeSelection?: { readonly gatewayName?: string }, @@ -177,26 +163,24 @@ function capturePolicyRead( subject: "sandbox" | "global", runtimeSelection?: { readonly gatewayName?: string }, ): string { - return captureAuthorityRead(args, subject, runtimeSelection).stdout; + return capturePolicyCommand(args, subject, runtimeSelection).stdout; } /** Inspect the effective policy source for one live sandbox. */ -export function inspectSandboxPolicyAuthority({ +export function inspectSandboxPolicy({ sandboxName, gatewayName, -}: SandboxPolicyAuthorityInspectionOptions): SandboxPolicyAuthorityInspection { - const validatedSandboxName = validatePolicyAuthorityName(sandboxName, "sandbox name"); +}: SandboxPolicyInspectionOptions): SandboxPolicyInspection { + const validatedSandboxName = validatePolicyName(sandboxName, "sandbox name"); const validatedGatewayName = - gatewayName === undefined - ? undefined - : validatePolicyAuthorityName(gatewayName, "gateway name"); + gatewayName === undefined ? undefined : validatePolicyName(gatewayName, "gateway name"); const raw = capturePolicyRead( buildPolicyGetFullJsonArgs(validatedSandboxName, validatedGatewayName), "sandbox", { gatewayName: validatedGatewayName }, ); try { - return parseSandboxPolicyAuthorityMetadata(raw, validatedSandboxName); + return parseSandboxPolicyMetadata(raw, validatedSandboxName); } catch (error) { failInspection( "sandbox", @@ -216,15 +200,15 @@ export function inspectOpenShellSandboxPolicyReadiness(options: { readonly sandboxIdentityFingerprint: string; readonly policyVersion: number; }): OpenShellSandboxPolicyReadiness { - const sandboxName = validatePolicyAuthorityName(options.sandboxName, "sandbox name"); - const gatewayName = validatePolicyAuthorityName(options.gatewayName, "gateway name"); + const sandboxName = validatePolicyName(options.sandboxName, "sandbox name"); + const gatewayName = validatePolicyName(options.gatewayName, "gateway name"); if (!/^[0-9a-f]{64}$/u.test(options.sandboxIdentityFingerprint)) { failInspection("sandbox", "the expected sandbox identity is invalid"); } if (!Number.isSafeInteger(options.policyVersion) || options.policyVersion < 0) { failInspection("sandbox", "the expected policy version is invalid"); } - const result = captureAuthorityRead( + const result = capturePolicyCommand( ["sandbox", "list", "-g", gatewayName, "--output", "json", "--limit", "1000"], "sandbox", { gatewayName }, @@ -253,10 +237,8 @@ export function inspectActiveGlobalPolicy({ gatewayName, }: ActiveGlobalPolicyInspectionOptions = {}): ActiveGlobalPolicyInspection { const validatedGatewayName = - gatewayName === undefined - ? undefined - : validatePolicyAuthorityName(gatewayName, "gateway name"); - const history = captureAuthorityRead(buildGlobalPolicyListArgs(validatedGatewayName), "global", { + gatewayName === undefined ? undefined : validatePolicyName(gatewayName, "gateway name"); + const history = capturePolicyCommand(buildGlobalPolicyListArgs(validatedGatewayName), "global", { gatewayName: validatedGatewayName, }); const historyState = classifyOpenShellGlobalPolicyHistory(history.stdout, history.stderr); @@ -264,13 +246,13 @@ export function inspectActiveGlobalPolicy({ if (historyState === "invalid") { failInspection("global", "OpenShell returned invalid global policy history"); } - const raw = captureAuthorityRead( + const raw = capturePolicyCommand( buildGlobalPolicyGetFullJsonArgs(validatedGatewayName), "global", { gatewayName: validatedGatewayName }, ).stdout; try { - return parseActiveGlobalPolicyAuthorityMetadata(raw); + return parseActiveGlobalPolicyMetadata(raw); } catch (error) { failInspection( "global", @@ -281,15 +263,20 @@ export function inspectActiveGlobalPolicy({ /** Read one sandbox base policy through the same bounded OpenShell adapter. */ export function captureSandboxBasePolicy(sandboxName: string, gatewayName: string): string { - const validatedGatewayName = validatePolicyAuthorityName(gatewayName, "gateway name"); - return capturePolicyRead( - buildPolicyGetArgs( - validatePolicyAuthorityName(sandboxName, "sandbox name"), - validatedGatewayName, - ), + const validatedGatewayName = validatePolicyName(gatewayName, "gateway name"); + const raw = capturePolicyRead( + buildPolicyGetArgs(validatePolicyName(sandboxName, "sandbox name"), validatedGatewayName), "sandbox", { gatewayName: validatedGatewayName }, ); + try { + return parseOpenShellPolicy(raw).yamlBody; + } catch (error) { + failInspection( + "sandbox", + error instanceof Error ? error.message : "OpenShell returned invalid base policy output", + ); + } } /** Read and fingerprint one sandbox ID without exposing the ID in diagnostics. */ @@ -297,8 +284,8 @@ export function inspectOpenShellSandboxIdentityFingerprint(options: { readonly sandboxName: string; readonly gatewayName: string; }): string { - const gatewayName = validatePolicyAuthorityName(options.gatewayName, "gateway name"); - const sandboxName = validatePolicyAuthorityName(options.sandboxName, "sandbox name"); + const gatewayName = validatePolicyName(options.gatewayName, "gateway name"); + const sandboxName = validatePolicyName(options.sandboxName, "sandbox name"); let result: ReturnType; try { result = captureBoundedOpenShell( @@ -324,12 +311,12 @@ export function inspectOpenShellSandboxIdentityFingerprint(options: { return fingerprint; } -/** Require the named live OpenShell gateway to expose the receipt-bound local port. */ +/** Require the named live OpenShell gateway to expose the expected local port. */ export function assertOpenShellGatewayPortBinding(options: { readonly gatewayName: string; readonly gatewayPort: number; }): void { - const gatewayName = validatePolicyAuthorityName(options.gatewayName, "gateway name"); + const gatewayName = validatePolicyName(options.gatewayName, "gateway name"); if ( !Number.isSafeInteger(options.gatewayPort) || options.gatewayPort < 1 || @@ -337,7 +324,7 @@ export function assertOpenShellGatewayPortBinding(options: { ) { failInspection("gateway", "the expected gateway port is invalid"); } - const result = captureAuthorityRead(["gateway", "info", "-g", gatewayName], "gateway", { + const result = capturePolicyCommand(["gateway", "info", "-g", gatewayName], "gateway", { gatewayName, }); if ( @@ -354,58 +341,14 @@ function operationLabel(operation: string): string { : "continue the policy-dependent operation"; } -/** Refuse a lifecycle operation when its durable and observed authority disagree. */ -export function assertRecordedPolicyAuthority( - recorded: unknown, - observed: unknown, - operation: string, -): void { - const label = operationLabel(operation); - try { - assertMatchingPolicyAuthority(recorded, observed); - } catch (error) { - const detail = error instanceof Error ? error.message : "policy authority is invalid"; - const observedAuthority = - observed === "nemoclaw-managed" || observed === "externally-managed" ? observed : undefined; - throw new PolicyAuthorityRefusalError(`Refusing to ${label}: ${detail}.`, observedAuthority); - } -} - -/** - * Verify that an externally supplied policy contains each required entry and - * section without claiming ownership. Unrelated external entries are allowed. - */ -export function assertExternalPolicyRequirements({ - inspection, - requiredPolicy, - operation, - sandboxName, -}: { - readonly inspection: SandboxPolicyAuthorityInspection; - readonly requiredPolicy: JsonObject; - readonly operation: string; - readonly sandboxName?: string; -}): void { - const label = operationLabel(operation); - const target = sandboxName ? ` for sandbox ${JSON.stringify(sandboxName)}` : ""; - try { - assertExternalPolicyRequirementContainment(inspection, requiredPolicy); - } catch (error) { - const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; - throw new PolicyAuthorityRefusalError( - `Refusing to ${label}${target}: ${detail}. Ask the external policy authority to supply the exact required entries.`, - ); - } -} - -/** Verify required entries without assigning ownership to a sandbox-scoped policy. */ +/** Verify that the current OpenShell policy contains required entries and sections. */ export function assertObservedPolicyRequirements({ inspection, requiredPolicy, operation, sandboxName, }: { - readonly inspection: SandboxPolicyAuthorityInspection; + readonly inspection: SandboxPolicyInspection; readonly requiredPolicy: JsonObject; readonly operation: string; readonly sandboxName?: string; @@ -416,13 +359,13 @@ export function assertObservedPolicyRequirements({ assertPolicyRequirementContainment(inspection, requiredPolicy); } catch (error) { const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Refusing to ${label}${target}: ${detail}. The verified policy must supply the exact required entries.`, ); } } -export const policyAuthorityInternals = { - captureMaxBytes: POLICY_AUTHORITY_CAPTURE_MAX_BYTES, - captureTimeoutMs: POLICY_AUTHORITY_CAPTURE_TIMEOUT_MS, +export const policyStateInternals = { + captureMaxBytes: POLICY_STATE_CAPTURE_MAX_BYTES, + captureTimeoutMs: POLICY_STATE_CAPTURE_TIMEOUT_MS, }; diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index e646b151cf4..c57a99994b8 100644 --- a/src/lib/agent/candidate-authority.ts +++ b/src/lib/agent/candidate-authority.ts @@ -13,8 +13,8 @@ export const CANDIDATE_QUALIFICATION_RECEIPT_DIGESTS: Readonly< Record > = Object.freeze({ pi: Object.freeze([ - "207930aaca3b1f233b32ddc0c5a3abe3db3123f34bb5b59a4233130befc16df5", - "1e49356ca9a910ea52fc7a0a70164aff8b056a5530e786c8ea0e54f79858e20e", + "fe7e3fd3da3731043fc43bb9f33511fd4bd13b7eab6c73c6199d33d6f73795a2", + "9a29cf2f2604700030e6f92861d90a11be1eae59f6c865bcf9d62a962b02b0b6", ]), }); diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 965ef56ca71..edc1502c17c 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -307,7 +307,7 @@ describe("agent setup session boundaries", () => { function createAgentSetupContext( runCaptureOpenshell: OnboardContext["runCaptureOpenshell"] = vi.fn(() => ""), timing: Pick = {}, - policyRequirements: Pick = {}, + policyRequirements: Pick = {}, ) { return { context: { @@ -432,7 +432,7 @@ describe("agent setup session boundaries", () => { expect(context.recordStepFailed).not.toHaveBeenCalled(); }); - it("refuses completion when policy authority changes during the gateway wait (#9833)", async () => { + it("refuses completion when policy requirements changes during the gateway wait (#9833)", async () => { let nowMs = 0; const sleepSeconds = vi.fn((seconds: number) => { nowMs += seconds * 1000; @@ -442,18 +442,18 @@ describe("agent setup session boundaries", () => { .mockReturnValueOnce("NEMOCLAW_AGENT_BINARY_CHECK:ok") .mockReturnValueOnce(""); const refuseCompletion = () => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }; const policyChecks = new Map([ ["record completed agent setup for sandbox 'sandbox-x'", refuseCompletion], ]); - const revalidatePolicyRequirements = vi.fn((operation: string) => + const verifyLivePolicyRequirements = vi.fn((operation: string) => policyChecks.get(operation)?.(), ); const { context } = createAgentSetupContext( runCaptureOpenshell, { now: () => nowMs, sleepSeconds }, - { revalidatePolicyRequirements }, + { verifyLivePolicyRequirements }, ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -469,7 +469,7 @@ describe("agent setup session boundaries", () => { null, context, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(sleepSeconds).toHaveBeenCalledWith(0.25); expect(context.recordStepComplete).not.toHaveBeenCalled(); diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 9c9fe58ee76..3aace8c839f 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -62,7 +62,7 @@ export interface OnboardContext { recordStepComplete: (stepName: string, updates: LooseObject) => Promise; recordStepFailed: (stepName: string, message: string | null) => Promise; skippedStepMessage: (stepName: string, sandboxName: string) => void; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; now?: () => number; sleepSeconds?: (seconds: number) => void; } @@ -248,9 +248,9 @@ async function failAgentSetup( message: string, recordStepFailed: OnboardContext["recordStepFailed"], details: string[] = [], - revalidatePolicyRequirements?: OnboardContext["revalidatePolicyRequirements"], + verifyLivePolicyRequirements?: OnboardContext["verifyLivePolicyRequirements"], ): Promise { - revalidatePolicyRequirements?.(`record failed agent setup for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`record failed agent setup for sandbox '${sandboxName}'`); await recordStepFailed( "agent_setup", details.length > 0 ? `${message}\n${details.join("\n")}` : message, @@ -319,7 +319,7 @@ export async function handleAgentSetup( recordStepComplete, recordStepFailed, skippedStepMessage, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, } = ctx; const runSmokeCapture = @@ -328,7 +328,7 @@ export async function handleAgentSetup( : runCaptureOpenshell; const syncNemoClawConfig = (): void => { - revalidatePolicyRequirements?.(`synchronize agent configuration in sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`synchronize agent configuration in sandbox '${sandboxName}'`); runSandboxConfigSync(sandboxName, { getSelectionConfig: () => { const cfg = getProviderSelectionConfig(provider, model); @@ -356,7 +356,7 @@ export async function handleAgentSetup( if (smokeResult.ok) { await enforceTerminalAgentVersion(sandboxName, agent, runCaptureOpenshell, { beforeFailure: () => { - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `start failed agent setup recording for sandbox '${sandboxName}'`, ); return startRecordedStep("agent_setup", { sandboxName, provider, model }); @@ -368,10 +368,10 @@ export async function handleAgentSetup( message, recordStepFailed, [], - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), }); - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `record resumed agent setup for sandbox '${sandboxName}'`, ); skippedStepMessage("agent_setup", sandboxName); @@ -402,7 +402,7 @@ export async function handleAgentSetup( // to the Dockerfile's zero-byte placeholder. Mirrors the OpenClaw // path in src/lib/onboard.ts. Fixes #3999 for non-OpenClaw agents. syncNemoClawConfig(); - revalidatePolicyRequirements?.( + verifyLivePolicyRequirements?.( `record resumed agent setup for sandbox '${sandboxName}'`, ); skippedStepMessage("agent_setup", sandboxName); @@ -412,7 +412,7 @@ export async function handleAgentSetup( } } - revalidatePolicyRequirements?.(`start agent setup for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`start agent setup for sandbox '${sandboxName}'`); await startRecordedStep("agent_setup", { sandboxName, provider, model }); step(7, 8, `Setting up ${agent.displayName} inside sandbox`); @@ -424,7 +424,7 @@ export async function handleAgentSetup( describeAgentBinaryFailure(sandboxName, agent, binaryAvailability), recordStepFailed, [], - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); } @@ -439,7 +439,7 @@ export async function handleAgentSetup( `${agent.displayName} terminal smoke command failed: ${smokeResult.command}`, recordStepFailed, smokeResult.output ? [String(redact(smokeResult.output)).slice(0, 500)] : [], - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); } await enforceTerminalAgentVersion(sandboxName, agent, runCaptureOpenshell, { @@ -450,10 +450,10 @@ export async function handleAgentSetup( message, recordStepFailed, [], - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), }); - revalidatePolicyRequirements?.(`record completed agent setup for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`record completed agent setup for sandbox '${sandboxName}'`); console.log(` \u2713 ${agent.displayName} terminal runtime is ready`); await recordStepComplete("agent_setup", { sandboxName, provider, model }); return; @@ -485,7 +485,7 @@ export async function handleAgentSetup( }, }); if (healthy) { - revalidatePolicyRequirements?.(`record completed agent setup for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`record completed agent setup for sandbox '${sandboxName}'`); console.log(` \u2713 ${agent.displayName} gateway is healthy`); } else { const diagnostics = @@ -498,11 +498,11 @@ export async function handleAgentSetup( `${agent.displayName} gateway did not respond within ${timeoutSecs}s`, recordStepFailed, diagnostics, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); } } else { - revalidatePolicyRequirements?.(`record completed agent setup for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`record completed agent setup for sandbox '${sandboxName}'`); console.log(` \u2713 ${agent.displayName} configured inside sandbox`); } diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 207ff08a66d..b6c7e7e54f7 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -392,7 +392,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { group: "Policy Presets", order: 21, usage: "nemoclaw policy exclude ", - description: "Exclude a baseline policy entry (persisted, replayed on rebuild)", + description: "Remove a baseline entry from the current OpenShell policy", flags: "(--force, --yes, -y, --dry-run)", }, ], diff --git a/src/lib/inference/llama-cpp/managed-installer.test.ts b/src/lib/inference/llama-cpp/managed-installer.test.ts index be83e19a67d..3ccdf9df1ca 100644 --- a/src/lib/inference/llama-cpp/managed-installer.test.ts +++ b/src/lib/inference/llama-cpp/managed-installer.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryRuntimeProviderBundle } from "../../../../test/helpers/runtime-provider-bundle"; -import { PolicyAuthorityRefusalError } from "../../adapters/openshell/policy-authority"; +import { PolicyObservationError } from "../../adapters/openshell/policy-state"; import type { ContainerEngine } from "../../adapters/container-engine"; import type { PodmanContainerEngine } from "../../adapters/podman"; import type { RuntimeProviderWorkloadProfile } from "../../onboard/runtime-provider/contract"; @@ -447,7 +447,7 @@ describe("managed llama.cpp Docker authority", () => { }); describe("managed llama.cpp installer", () => { - it("stops after acquisition when policy authority refuses activation (#9833)", async () => { + it("stops after acquisition when policy requirements refuses activation (#9833)", async () => { const selected = selection(); const homeDir = temporaryHome(); const paths = managedLlamaCppStatePaths(homeDir); @@ -455,12 +455,12 @@ describe("managed llama.cpp installer", () => { harness.images.add(selected.recipe.spec.runtime.image); harness.images.add(selected.recipe.spec.readiness.probeImage); const lifecycle = dormantManagedLifecycle(); - const revalidatePolicyRequirements = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new PolicyAuthorityRefusalError( - "External policy authority must supply the managed llama.cpp entry.", + throw new PolicyObservationError( + "Live policy requirements changed before the managed llama.cpp entry.", ); }); @@ -472,15 +472,15 @@ describe("managed llama.cpp installer", () => { verifyGguf: vi.fn(async () => verifiedArtifact(selected, homeDir)), checkPort: vi.fn(async () => ({ ok: true })), log: vi.fn(), - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), - ).rejects.toBeInstanceOf(PolicyAuthorityRefusalError); + ).rejects.toBeInstanceOf(PolicyObservationError); - expect(revalidatePolicyRequirements).toHaveBeenNthCalledWith( + expect(verifyLivePolicyRequirements).toHaveBeenNthCalledWith( 1, "reserve the managed llama.cpp runtime", ); - expect(revalidatePolicyRequirements).toHaveBeenNthCalledWith( + expect(verifyLivePolicyRequirements).toHaveBeenNthCalledWith( 2, "activate the managed llama.cpp runtime", ); @@ -510,12 +510,12 @@ describe("managed llama.cpp installer", () => { harness.images.add(selected.recipe.spec.runtime.image); harness.images.add(selected.recipe.spec.readiness.probeImage); const lifecycle = dormantManagedLifecycle(); - const revalidatePolicyRequirements = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new PolicyAuthorityRefusalError( - "External policy authority must supply the managed llama.cpp entry.", + throw new PolicyObservationError( + "Live policy requirements changed before the managed llama.cpp entry.", ); }); @@ -525,15 +525,15 @@ describe("managed llama.cpp installer", () => { runtimeProvider: managedRuntimeProvider(harness.engine, () => lifecycle), verifyGguf: vi.fn(async () => verifiedArtifact(selected, homeDir)), checkPort: vi.fn(async () => ({ ok: true })), - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), - ).rejects.toBeInstanceOf(PolicyAuthorityRefusalError); + ).rejects.toBeInstanceOf(PolicyObservationError); - expect(revalidatePolicyRequirements).toHaveBeenNthCalledWith( + expect(verifyLivePolicyRequirements).toHaveBeenNthCalledWith( 1, "inspect the managed llama.cpp runtime", ); - expect(revalidatePolicyRequirements).toHaveBeenNthCalledWith( + expect(verifyLivePolicyRequirements).toHaveBeenNthCalledWith( 2, "recover the managed llama.cpp runtime", ); diff --git a/src/lib/inference/llama-cpp/managed-installer.ts b/src/lib/inference/llama-cpp/managed-installer.ts index a4f426d6f7c..a20845948e3 100644 --- a/src/lib/inference/llama-cpp/managed-installer.ts +++ b/src/lib/inference/llama-cpp/managed-installer.ts @@ -8,9 +8,9 @@ import path from "node:path"; import type { ContainerEngine } from "../../adapters/container-engine"; import { - isPolicyAuthorityRefusalError, - PolicyAuthorityRefusalError, -} from "../../adapters/openshell/policy-authority"; + isPolicyObservationError, + PolicyObservationError, +} from "../../adapters/openshell/policy-state"; import { checkPortAvailable } from "../../onboard/preflight"; import type { RuntimeProviderBundle } from "../../onboard/runtime-provider/contract"; import { createHostLocalCreateJournalStore } from "../../onboard/runtime-provider/host-local-create-journal"; @@ -63,7 +63,7 @@ export interface ManagedLlamaCppInstallOptions { readonly acquireGguf?: typeof acquireVerifiedLlamaCppGguf; readonly verifyGguf?: typeof verifyLlamaCppGgufCacheEntry; readonly checkPort?: typeof checkPortAvailable; - readonly revalidatePolicyRequirements?: (operation: string) => void; + readonly verifyLivePolicyRequirements?: (operation: string) => void; readonly log?: (message: string) => void; } @@ -83,7 +83,7 @@ export interface ManagedLlamaCppResumeOptions { readonly env?: NodeJS.ProcessEnv; readonly verifyGguf?: typeof verifyLlamaCppGgufCacheEntry; readonly checkPort?: typeof checkPortAvailable; - readonly revalidatePolicyRequirements?: (operation: string) => void; + readonly verifyLivePolicyRequirements?: (operation: string) => void; } export interface ManagedLlamaCppExactInspectionOptions { @@ -640,7 +640,7 @@ export async function installManagedLlamaCpp( { env }, ); engine = operation.engine; - options.revalidatePolicyRequirements?.("reserve the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("reserve the managed llama.cpp runtime"); const reservation = claimManagedLlamaCppOwner(paths, { schemaVersion: 1, sandboxName: options.sandboxName, @@ -725,7 +725,7 @@ export async function installManagedLlamaCpp( if (artifact === null) { throw new Error("Managed llama.cpp could not verify its exact GGUF artifact."); } - options.revalidatePolicyRequirements?.("activate the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("activate the managed llama.cpp runtime"); loadOrCreateManagedLlamaCppApiKey(paths); const lifecycle = lifecycleFor({ selection, @@ -746,7 +746,7 @@ export async function installManagedLlamaCpp( let receipt = loadManagedLlamaCppReceipt(paths); if (receipt !== null) { - options.revalidatePolicyRequirements?.("resume the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("resume the managed llama.cpp runtime"); receipt = lifecycle.resume(receipt); } else { const port = await checkPort(hostPort); @@ -755,11 +755,11 @@ export async function installManagedLlamaCpp( `Managed llama.cpp port ${String(hostPort)} is unavailable: ${port.reason}`, ); } - options.revalidatePolicyRequirements?.("start the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("start the managed llama.cpp runtime"); const transactionId = randomBytes(32).toString("hex"); receipt = lifecycle.start(createManagedLlamaCppReceiptWriter(paths, transactionId)); } - options.revalidatePolicyRequirements?.( + options.verifyLivePolicyRequirements?.( "report successful managed llama.cpp runtime activation", ); const apiKey = loadOrCreateManagedLlamaCppApiKey(paths); @@ -773,9 +773,9 @@ export async function installManagedLlamaCpp( ownerCreated, paths, }); - if (isPolicyAuthorityRefusalError(error)) { + if (isPolicyObservationError(error)) { if (rollbackError === null) throw error; - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `${reason} Fresh ownership rollback also failed: ${rollbackError}`, ); } @@ -812,7 +812,7 @@ export async function resumeManagedLlamaCppRuntime( "llama-cpp", { env }, ); - options.revalidatePolicyRequirements?.("inspect the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("inspect the managed llama.cpp runtime"); const engine = operation.engine; const verify = options.verifyGguf ?? verifyLlamaCppGgufCacheEntry; const checkPort = options.checkPort ?? checkPortAvailable; @@ -849,7 +849,7 @@ export async function resumeManagedLlamaCppRuntime( artifact, operation, }); - options.revalidatePolicyRequirements?.("recover the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("recover the managed llama.cpp runtime"); if (pending.length === 1) { const recovery = lifecycle.recoverUnfinished( createManagedLlamaCppReceiptWriter(paths, pending[0]!.transactionId), @@ -864,15 +864,15 @@ export async function resumeManagedLlamaCppRuntime( if (!port.ok) { throw new Error(`Managed llama.cpp port ${String(hostPort)} is unavailable: ${port.reason}`); } - options.revalidatePolicyRequirements?.("start the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("start the managed llama.cpp runtime"); loadOrCreateManagedLlamaCppApiKey(paths); const transactionId = randomBytes(32).toString("hex"); lifecycle.start(createManagedLlamaCppReceiptWriter(paths, transactionId)); } else { - options.revalidatePolicyRequirements?.("resume the managed llama.cpp runtime"); + options.verifyLivePolicyRequirements?.("resume the managed llama.cpp runtime"); lifecycle.resume(receipt); } - options.revalidatePolicyRequirements?.("report successful managed llama.cpp runtime recovery"); + options.verifyLivePolicyRequirements?.("report successful managed llama.cpp runtime recovery"); const apiKey = loadManagedLlamaCppApiKey(paths); if (apiKey === null) throw new Error("Managed llama.cpp API-key authority is missing."); env[LLAMA_CPP_CREDENTIAL_ENV] = apiKey; diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 231c592b481..9d9c01a8340 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -89,7 +89,6 @@ describe("inventory commands", () => { model: "configured-alpha", provider: "configured-provider", gpuEnabled: true, - policies: ["pypi"], agent: "openclaw", }, ], @@ -126,7 +125,7 @@ describe("inventory commands", () => { sandboxGpuDevice: null, openshellDriver: null, openshellVersion: null, - policies: ["pypi"], + policies: [], agent: "openclaw", isDefault: true, activeSessionCount: 1, @@ -636,7 +635,6 @@ describe("inventory commands", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: true, - policies: ["pypi"], }, ], defaultSandbox: "alpha", @@ -644,6 +642,7 @@ describe("inventory commands", () => { recoveredFromGateway: 1, }), getLiveInference: () => null, + getPolicyPresets: () => ["pypi"], loadLastSession: () => null, log: (message = "") => lines.push(message), }); @@ -666,7 +665,6 @@ describe("inventory commands", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agent: "hermes", }, ], @@ -692,14 +690,12 @@ describe("inventory commands", () => { model: "configured-alpha", provider: "configured-provider", gpuEnabled: true, - policies: [], }, { name: "beta", model: "configured-beta", provider: "beta-provider", gpuEnabled: false, - policies: [], }, ], defaultSandbox: "alpha", @@ -737,7 +733,6 @@ describe("inventory commands", () => { model: "configured-alpha", provider: "configured-provider", gpuEnabled: true, - policies: [], }, ], defaultSandbox: "alpha", @@ -763,7 +758,6 @@ describe("inventory commands", () => { model: "configured-alpha", provider: "configured-provider", gpuEnabled: true, - policies: [], }, ], defaultSandbox: "alpha", @@ -789,7 +783,6 @@ describe("inventory commands", () => { model: "configured-alpha", provider: "configured-provider", gpuEnabled: true, - policies: [], }, ], defaultSandbox: "alpha", @@ -818,7 +811,6 @@ describe("inventory commands", () => { model: "configured-alpha", provider: "configured-provider", gpuEnabled: true, - policies: [], }, ], defaultSandbox: "alpha", diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 0ae830b2f1d..7890323bf1b 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -27,7 +27,6 @@ export interface SandboxEntry { sandboxGpuDevice?: string | null; openshellDriver?: string | null; openshellVersion?: string | null; - policies?: string[] | null; messaging?: SandboxMessagingState | null; agent?: string | null; dashboardPort?: number | null; @@ -88,6 +87,8 @@ export interface ListSandboxesCommandDeps { loadLastSession: () => OnboardingSessionSummary | null; /** Detect active SSH sessions for a sandbox. Returns session count or null if unavailable. */ getActiveSessionCount?: (sandboxName: string) => number | null; + /** Derive applied preset names from the current OpenShell policy. */ + getPolicyPresets?: (sandboxName: string) => string[]; log?: (message?: string) => void; } @@ -153,6 +154,8 @@ export interface ShowStatusCommandDeps { * that case. #2604. */ getActiveSessionCount?: (sandboxName: string) => number | null; + /** Derive applied preset names from the current OpenShell policy. */ + getPolicyPresets?: (sandboxName: string) => string[]; /** * Report whether the named NemoClaw gateway is reachable. When omitted, * `showStatusCommand` keeps its legacy 0-exit behaviour; when provided and @@ -174,9 +177,7 @@ export interface ShowStatusCommandDeps { findMessagingOverlaps?: () => MessagingOverlap[]; readGatewayLog?: (sandboxName: string) => string | null; /** Receipt-only schema-5 phase lookup held by the global portable host fence. */ - getHermesPortablePhase?: ( - sandboxName: string, - ) => "pending" | "configuring" | "active" | null; + getHermesPortablePhase?: (sandboxName: string) => "pending" | "configuring" | "active" | null; /** Count receipt-root authority so an unregistered phase cannot permit ambient probes. */ getHermesPortableHostAuthorityCount?: () => number; log?: (message?: string) => void; @@ -297,6 +298,7 @@ function buildSandboxInventoryRow( sandbox: SandboxEntry, defaultSandbox: string | null, getActiveSessionCount?: (sandboxName: string) => number | null, + getPolicyPresets?: (sandboxName: string) => string[], ): SandboxInventoryRow { const activeSessionCount = getActiveSessionCount ? getActiveSessionCount(sandbox.name) : null; const sandboxGpuEnabled = @@ -316,7 +318,7 @@ function buildSandboxInventoryRow( sandboxGpuDevice: safeStatusString(sandbox.sandboxGpuDevice || null), openshellDriver: safeStatusString(sandbox.openshellDriver || null), openshellVersion: safeStatusString(sandbox.openshellVersion || null), - policies: Array.isArray(sandbox.policies) ? sandbox.policies : [], + policies: getPolicyPresets?.(sandbox.name) ?? [], agent: resolveDisplayAgent(sandbox), ...(sandbox.dashboardPort != null ? { dashboardPort: sandbox.dashboardPort } : {}), isDefault: sandbox.name === defaultSandbox, @@ -356,7 +358,12 @@ export async function getSandboxInventory( sandboxes: recovery.sandboxes .filter(isPublishedSandboxRegistration) .map((sandbox) => - buildSandboxInventoryRow(sandbox, resolvedDefault, deps.getActiveSessionCount), + buildSandboxInventoryRow( + sandbox, + resolvedDefault, + deps.getActiveSessionCount, + deps.getPolicyPresets, + ), ), }; } @@ -473,6 +480,7 @@ function buildStatusSandboxRow( defaultSandbox: string | null, liveInference: GatewayInference | null, portablePhase: "pending" | "configuring" | "active" | null, + getPolicyPresets?: (sandboxName: string) => string[], ): StatusSandboxRow { const isDefault = sandbox.name === defaultSandbox; const liveModel = isDefault ? liveInference?.model : null; @@ -497,11 +505,9 @@ function buildStatusSandboxRow( sandboxGpuDevice: safeStatusString(sandbox.sandboxGpuDevice || null), openshellDriver: safeStatusString(sandbox.openshellDriver || null), openshellVersion: safeStatusString(sandbox.openshellVersion || null), - policies: Array.isArray(sandbox.policies) - ? sandbox.policies - .filter((policy): policy is string => typeof policy === "string") - .map((policy) => safeStatusString(policy) || policy) - : [], + policies: (getPolicyPresets?.(sandbox.name) ?? []).map( + (policy) => safeStatusString(policy) || policy, + ), agent: redactFull(resolveDisplayAgent(sandbox)), ...(portablePhase ? { phase: portablePhase } : {}), ...(dashboardPort != null ? { dashboardPort } : {}), @@ -578,18 +584,16 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { sandboxList.sandboxes, deps.loadLastSession?.(), ); - const liveInference = - sandboxes.length > 0 && !hasHermesPortable ? deps.getLiveInference() : null; + const liveInference = sandboxes.length > 0 && !hasHermesPortable ? deps.getLiveInference() : null; const gatewayHealth = deps.getGatewayHealth && sandboxes.length > 0 && !hasHermesPortable ? deps.getGatewayHealth() : null; - const services = - !hasHermesPortable - ? (deps - .getServiceStatuses?.({ sandboxName: resolvedDefault || undefined }) - .map(normalizeServiceStatus) ?? []) - : []; + const services = !hasHermesPortable + ? (deps + .getServiceStatuses?.({ sandboxName: resolvedDefault || undefined }) + .map(normalizeServiceStatus) ?? []) + : []; return { schemaVersion: 1, @@ -611,6 +615,7 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { resolvedDefault, liveInference, portablePhases.get(sandbox.name) ?? null, + deps.getPolicyPresets, ), ), services, diff --git a/src/lib/list-command-deps.ts b/src/lib/list-command-deps.ts index 86f6ac8d147..e5aaa9d9558 100644 --- a/src/lib/list-command-deps.ts +++ b/src/lib/list-command-deps.ts @@ -10,6 +10,7 @@ import { resolveOpenshell } from "./adapters/openshell/resolve"; import { captureOpenshell } from "./adapters/openshell/runtime"; import { recoverRegistryEntries } from "./registry-recovery-action"; import * as registry from "./state/registry"; +import * as policy from "./policy"; interface RecoveredRegistry { sandboxes: SandboxEntry[]; @@ -18,6 +19,8 @@ interface RecoveredRegistry { recoveredFromGateway?: number; } +const INVENTORY_POLICY_PROBE_TIMEOUT_MS = 2_000; + interface RegistryFallback { sandboxes: SandboxEntry[]; defaultSandbox?: string | null; @@ -87,6 +90,13 @@ export function buildListCommandDeps(): ListSandboxesCommandDeps { } }, loadLastSession: () => onboardSession.loadSession(), + getPolicyPresets: (sandboxName) => { + try { + return policy.getAppliedPresets(sandboxName, INVENTORY_POLICY_PROBE_TIMEOUT_MS); + } catch { + return []; + } + }, getActiveSessionCount: sessionDeps ? (name) => { try { diff --git a/src/lib/messaging/README.md b/src/lib/messaging/README.md index 3675050e0cb..ffb71155001 100644 --- a/src/lib/messaging/README.md +++ b/src/lib/messaging/README.md @@ -48,8 +48,8 @@ The workflow has these stages. 5. `src/lib/onboard/dockerfile-patch.ts` passes the encoded plan into image builds. 6. `applier/build/messaging-build-applier.mts` applies build-time package installs, render entries, post-agent-install files, and the reduced runtime plan artifact. 7. `MessagingHostStateApplier` persists compact plan state under the sandbox registry entry. -8. Rebuild hydrates persisted plans from current manifests so compacted render, host-forward, package, runtime, and hook fields stay current. - Non-empty persisted `networkPolicy` entries are preserved and regenerated only when they are absent or empty. +8. Rebuild hydrates persisted plans from current manifests so compacted policy, render, host-forward, package, runtime, and hook fields stay current. + `networkPolicy` is never persisted; it is transient command input regenerated from current manifests. ## Class Diagram @@ -479,4 +479,4 @@ Add the channel through the manifest-first path. - Agent render and hook build-file targets must stay inside `/sandbox/.openclaw`, `/sandbox/.hermes`, or `/sandbox/.deepagents`. - Disabled channels must be filtered before side effects run. - Rebuild should hydrate compacted or missing derived fields from current manifests instead of trusting stale persisted render, package, host-forward, runtime, or hook data. -- Rebuild preserves non-empty persisted `networkPolicy` entries and regenerates policy only when entries are absent or empty. +- Persisted plans never contain `networkPolicy`; command-time policy references are regenerated from current manifests. diff --git a/src/lib/messaging/channels/channel-health.ts b/src/lib/messaging/channels/channel-health.ts index e9c13f6f85d..03996204da4 100644 --- a/src/lib/messaging/channels/channel-health.ts +++ b/src/lib/messaging/channels/channel-health.ts @@ -90,7 +90,7 @@ export interface ChannelHealthProbeFacts { readonly agent: string; readonly probedAt: string; readonly channelEnabledInRegistry: boolean; - readonly presetInRegistry: boolean; + readonly presetApplied: boolean; readonly presetOnGateway: boolean | null; } @@ -103,7 +103,7 @@ export function channelHealthProbeInputs( agent: facts.agent, probedAt: facts.probedAt, channelEnabledInRegistry: facts.channelEnabledInRegistry, - presetInRegistry: facts.presetInRegistry, + presetApplied: facts.presetApplied, presetOnGateway: facts.presetOnGateway, }; } diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 008052c1652..d99f65251b8 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -1,9 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; + +import YAML from "yaml"; + import { describe, expect, it } from "vitest"; import type { ChannelManifest, ChannelPolicyPresetReference } from "../manifest"; +import { resolveMessagingChannelPolicyPresetPath } from "./policy"; import { getMessagingChannelForCredentialEnvKey, getMessagingConfigEnvAliases, @@ -18,6 +23,7 @@ import { listMessagingConfigEnvKeys, listMessagingCredentialEnvAssignments, listMessagingPackageInstallSpecs, + listMessagingPolicyPresetMetadata, listMessagingProviderNamesForChannel, listOpenClawManagedChannelNames, listOpenClawPluginExtensionIds, @@ -439,3 +445,77 @@ function manifestWithPreset(id: string, preset: ChannelPolicyPresetReference): C hooks: [], }; } + +describe("messaging policy credential bindings", () => { + const AGENTS = ["openclaw", "hermes"] as const; + + type PresetPolicyFile = { + readonly label: string; + readonly requiredAtCreate: boolean; + readonly path: string; + }; + + function policyFiles(): PresetPolicyFile[] { + return listMessagingPolicyPresetMetadata() + .flatMap((preset) => + AGENTS.map((agent) => ({ + label: `${preset.channelId}/${agent}`, + requiredAtCreate: preset.requiredAtCreate, + path: resolveMessagingChannelPolicyPresetPath(preset.presetName, agent) ?? "", + })), + ) + .filter((entry: PresetPolicyFile) => entry.path.length > 0); + } + + function presetsBindingACredentialWithoutCreateTimeApply(): string[] { + return policyFiles() + .filter((entry: PresetPolicyFile) => !entry.requiredAtCreate) + .filter((entry: PresetPolicyFile) => + readFileSync(entry.path, "utf8").includes("credential_binding:"), + ) + .map((entry: PresetPolicyFile) => entry.label); + } + + type PolicyEndpoint = { readonly host?: string; readonly port?: number; readonly path?: string }; + + function endpointsSharingAHostPortWithoutDistinctPaths(): string[] { + return policyFiles() + .flatMap((entry: PresetPolicyFile) => { + const parsed = YAML.parse(readFileSync(entry.path, "utf8")) as { + network_policies?: Record; + }; + return Object.entries(parsed.network_policies ?? {}).flatMap(([policyKey, policy]) => { + const declared = (policy.endpoints ?? []).map((endpoint: PolicyEndpoint) => ({ + hostPort: `${endpoint.host}:${endpoint.port}`, + selector: endpoint.path ?? "", + })); + const selectorsFor = (hostPort: string) => + declared.filter((endpoint) => endpoint.hostPort === hostPort).map((e) => e.selector); + return [...new Set(declared.map((endpoint) => endpoint.hostPort))] + .filter( + (hostPort) => new Set(selectorsFor(hostPort)).size !== selectorsFor(hostPort).length, + ) + .map((hostPort) => `${entry.label} ${policyKey} ${hostPort}`); + }); + }) + .sort(); + } + + it("gives every repeated host and port a distinct path selector", () => { + // A channel with two credentials on one host declares that host twice: + // - OpenShell picks the endpoint by path specificity. + // - Two entries scoring the same are rejected as ambiguous, which fails + // sandbox creation outright rather than degrading. + expect(endpointsSharingAHostPortWithoutDistinctPaths()).toEqual([]); + }); + + it("keeps every preset that binds a credential create-time required", () => { + // The binding is what makes the credential injectable: + // - The sandbox reads the provider environment once, at boot; the agent + // inherits that read for the life of the container. + // - A preset applied after boot leaves the agent with no credential at all. + // - Slack was already create-time required. Discord and Teams were not, + // which is how their tokens went missing on OpenClaw. + expect(presetsBindingACredentialWithoutCreateTimeApply()).toEqual([]); + }); +}); diff --git a/src/lib/messaging/channels/slack/hooks/status-health.test.ts b/src/lib/messaging/channels/slack/hooks/status-health.test.ts index 3423f5f1eec..b7aa9c1d6a8 100644 --- a/src/lib/messaging/channels/slack/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/slack/hooks/status-health.test.ts @@ -11,7 +11,7 @@ const BASE_INPUTS = { agent: "openclaw", probedAt: "2026-08-07T12:00:00.000Z", channelEnabledInRegistry: true, - presetInRegistry: true, + presetApplied: true, presetOnGateway: true as boolean | null, }; const READY_ACCOUNT = { @@ -102,7 +102,7 @@ describe("slack.statusHealth hook", () => { it.each([ ["Slack is not registered", { channelEnabledInRegistry: false }, "channel_not_registered"], - ["the preset is not registered", { presetInRegistry: false }, "policy_missing"], + ["the preset is not registered", { presetApplied: false }, "policy_missing"], ["the preset is not applied", { presetOnGateway: false }, "policy_missing"], ["gateway policy is unknown", { presetOnGateway: null }, "policy_status_unavailable"], ] as const)("skips the live probe when %s (#7383)", (_condition, inputs, reason) => { diff --git a/src/lib/messaging/channels/slack/hooks/status-health.ts b/src/lib/messaging/channels/slack/hooks/status-health.ts index 052549de494..9ce40cdd363 100644 --- a/src/lib/messaging/channels/slack/hooks/status-health.ts +++ b/src/lib/messaging/channels/slack/hooks/status-health.ts @@ -32,7 +32,7 @@ export type SlackStatusHealthHookOptions = ChannelStatusHealthHookOptions; function canRunSlackProbe(inputs: MessagingHookInputMap | undefined): boolean { return ( inputs?.channelEnabledInRegistry === true && - inputs.presetInRegistry === true && + inputs.presetApplied === true && inputs.presetOnGateway === true ); } @@ -138,7 +138,7 @@ function evaluateSlackReadiness( probedAt: normalizeString(inputs?.probedAt) ?? "", lastTransitionAt: probe.lastTransitionAt, channelEnabledInRegistry: Boolean(inputs?.channelEnabledInRegistry), - presetInRegistry: Boolean(inputs?.presetInRegistry), + presetApplied: Boolean(inputs?.presetApplied), presetOnGateway: normalizeBoolean(inputs?.presetOnGateway), probeReachable: probe.probeReachable, pluginConfigured: probe.pluginConfigured, @@ -168,7 +168,7 @@ function evaluateSlackReadiness( const classify = (): ChannelReadiness => { if (!input.channelEnabledInRegistry) return result("terminal", "runtime", "channel_not_registered"); - if (!input.presetInRegistry || input.presetOnGateway === false) + if (!input.presetApplied || input.presetOnGateway === false) return result("terminal", "policy", "policy_missing"); if (input.presetOnGateway === null) return result("waiting", "network", "policy_status_unavailable"); @@ -237,7 +237,7 @@ function evaluateSlackReadiness( }; const readiness = classify(); - const policyMissing = !input.presetInRegistry || input.presetOnGateway === false; + const policyMissing = !input.presetApplied || input.presetOnGateway === false; const liveSignals = canProbe ? [ runtimeSignal(), @@ -276,7 +276,7 @@ function evaluateSlackReadiness( signal( "Policy coverage", policyMissing ? "fail" : input.presetOnGateway === true ? "ok" : "info", - !input.presetInRegistry + !input.presetApplied ? "slack preset not recorded for the sandbox" : input.presetOnGateway === false ? "slack preset missing from the OpenShell gateway" diff --git a/src/lib/messaging/channels/telegram/hooks/status-health-eval.test.ts b/src/lib/messaging/channels/telegram/hooks/status-health-eval.test.ts index 56676c09306..c07accfbc2f 100644 --- a/src/lib/messaging/channels/telegram/hooks/status-health-eval.test.ts +++ b/src/lib/messaging/channels/telegram/hooks/status-health-eval.test.ts @@ -16,7 +16,7 @@ function baseInput(overrides: Partial = {}): TelegramProbeIn gatewayProcessAlive: true, breadcrumbs: null, probedAt: "2026-07-14T00:00:00.000Z", - presetInRegistry: true, + presetApplied: true, presetOnGateway: true, channelEnabledInRegistry: true, ...overrides, @@ -121,7 +121,7 @@ describe("evaluateTelegramDiagnostics verdict", () => { expect( evaluateTelegramDiagnostics(baseInput({ channelEnabledInRegistry: false })).verdict, ).toBe("config_gap"); - expect(evaluateTelegramDiagnostics(baseInput({ presetInRegistry: false })).verdict).toBe( + expect(evaluateTelegramDiagnostics(baseInput({ presetApplied: false })).verdict).toBe( "policy_gap", ); }); diff --git a/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts b/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts index 2f3e19090d3..695c7a63f50 100644 --- a/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts @@ -73,7 +73,7 @@ export type TelegramProbeInput = { // ISO timestamp captured by the orchestrator when the probe ran. probedAt: string; // Whether the telegram preset is recorded in the sandbox registry. - presetInRegistry: boolean; + presetApplied: boolean; // Whether the telegram preset's network policy is loaded on the gateway, // or null when the gateway could not be reached. presetOnGateway: boolean | null; @@ -103,7 +103,7 @@ function configCoverageSignal(input: TelegramProbeInput): DiagnosticSignal { } function policyCoverageSignal(input: TelegramProbeInput): DiagnosticSignal { - if (input.presetOnGateway === false && input.presetInRegistry) { + if (input.presetOnGateway === false && input.presetApplied) { return { label: "Policy coverage", severity: "fail", @@ -111,7 +111,7 @@ function policyCoverageSignal(input: TelegramProbeInput): DiagnosticSignal { hint: "rebuild the sandbox so the preset is reapplied to the OpenShell gateway", }; } - if (!input.presetInRegistry) { + if (!input.presetApplied) { return { label: "Policy coverage", severity: "fail", diff --git a/src/lib/messaging/channels/telegram/hooks/status-health.test.ts b/src/lib/messaging/channels/telegram/hooks/status-health.test.ts index ad2429cd732..1be4eefd9ed 100644 --- a/src/lib/messaging/channels/telegram/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/telegram/hooks/status-health.test.ts @@ -12,7 +12,7 @@ const BASE_INPUTS = { agent: "openclaw", probedAt: "2026-07-14T00:00:00.000Z", channelEnabledInRegistry: true, - presetInRegistry: true, + presetApplied: true, presetOnGateway: true, }; @@ -132,7 +132,7 @@ describe("telegram.statusHealth hook", () => { expect( reportOf(hook(context({ ...BASE_INPUTS, channelEnabledInRegistry: false })))?.verdict, ).toBe("config_gap"); - expect(reportOf(hook(context({ ...BASE_INPUTS, presetInRegistry: false })))?.verdict).toBe( + expect(reportOf(hook(context({ ...BASE_INPUTS, presetApplied: false })))?.verdict).toBe( "policy_gap", ); }); diff --git a/src/lib/messaging/channels/telegram/hooks/status-health.ts b/src/lib/messaging/channels/telegram/hooks/status-health.ts index 9dab4534c35..039e8ad9905 100644 --- a/src/lib/messaging/channels/telegram/hooks/status-health.ts +++ b/src/lib/messaging/channels/telegram/hooks/status-health.ts @@ -82,7 +82,7 @@ export function createTelegramStatusHealthHook( gatewayProcessAlive, breadcrumbs, probedAt: normalizeString(context.inputs?.probedAt) ?? "", - presetInRegistry: Boolean(context.inputs?.presetInRegistry), + presetApplied: Boolean(context.inputs?.presetApplied), presetOnGateway: normalizeTristate(context.inputs?.presetOnGateway), channelEnabledInRegistry: Boolean(context.inputs?.channelEnabledInRegistry), }; diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts index d79b2cc8004..248bc08d61d 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts @@ -22,7 +22,7 @@ function baseInput(overrides: Partial = {}): WhatsappProbeIn recentLogSignals: [], probeReachable: true, probedAt: PROBED_AT, - presetInRegistry: true, + presetApplied: true, presetOnGateway: true, channelEnabledInRegistry: true, ...overrides, @@ -53,7 +53,7 @@ describe("evaluateWhatsappDiagnostics", () => { it("returns policy_gap when the whatsapp preset is missing", () => { const report = evaluateWhatsappDiagnostics( - baseInput({ presetInRegistry: false, presetOnGateway: false }), + baseInput({ presetApplied: false, presetOnGateway: false }), ); expect(report.verdict).toBe("policy_gap"); const policy = report.signals.find((s) => s.label === "Policy coverage"); @@ -306,7 +306,7 @@ describe("evaluateWhatsappDiagnostics", () => { it("warns when the preset is recorded locally but missing from the gateway", () => { const report = evaluateWhatsappDiagnostics( - baseInput({ presetInRegistry: true, presetOnGateway: false }), + baseInput({ presetApplied: true, presetOnGateway: false }), ); const policy = report.signals.find((s) => s.label === "Policy coverage"); expect(policy?.severity).toBe("fail"); @@ -337,7 +337,7 @@ describe("evaluateWhatsappDiagnostics", () => { it("treats a missing local preset as fail even when the gateway is unreachable", () => { const report = evaluateWhatsappDiagnostics( - baseInput({ presetInRegistry: false, presetOnGateway: null }), + baseInput({ presetApplied: false, presetOnGateway: null }), ); const policy = report.signals.find((s) => s.label === "Policy coverage"); expect(policy?.severity).toBe("fail"); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts index 694ced1aae3..881864717cb 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -79,7 +79,7 @@ export type WhatsappProbeInput = { // depending on the system clock. probedAt: string; // Whether the whatsapp preset is recorded in the sandbox registry. - presetInRegistry: boolean; + presetApplied: boolean; // Whether the whatsapp preset's network policy is loaded on the gateway, // or null when the gateway could not be reached. presetOnGateway: boolean | null; @@ -382,7 +382,7 @@ function inboundSignal(input: WhatsappProbeInput): DiagnosticSignal { } function policyCoverageSignal(input: WhatsappProbeInput): DiagnosticSignal { - if (input.presetOnGateway === false && input.presetInRegistry) { + if (input.presetOnGateway === false && input.presetApplied) { return { label: "Policy coverage", severity: "fail", @@ -390,7 +390,7 @@ function policyCoverageSignal(input: WhatsappProbeInput): DiagnosticSignal { hint: "rebuild the sandbox so the preset is reapplied to the OpenShell gateway", }; } - if (!input.presetInRegistry) { + if (!input.presetApplied) { // A missing local preset is a deterministic gap regardless of gateway // reachability — the next rebuild will not reapply WhatsApp egress and // the channel will eventually fail closed. Treat it as a fail so the diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts index 72df8dbe024..ff54d5cab67 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts @@ -11,7 +11,7 @@ const BASE_INPUTS = { agent: "openclaw", probedAt: "2026-07-14T00:00:00.000Z", channelEnabledInRegistry: true, - presetInRegistry: true, + presetApplied: true, presetOnGateway: true, }; @@ -477,26 +477,29 @@ describe("whatsapp.statusHealth openclaw CLI probe", () => { label: "non-string value", sessionPath: 42, }, - ])("keeps the durable session path for an unsupported compatibility value: $label (#8947)", ({ - sessionPath, - }) => { - const exec = hermesExec({ - configuredSessionPath: sessionPath, - credsDirs: [HERMES_DASHBOARD_SESSION_DIR], - }); - const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( - context({ ...BASE_INPUTS, agent: "hermes" }), - ); - const report = reportOf(result); - const probeCommands = exec.mock.calls - .map((call) => String(call[1] ?? "")) - .filter((command) => !command.startsWith("python3 -c ")); - expect(report?.verdict).toBe("unpaired"); - expect(report?.signals.find((s) => s.label === "Session path override")?.severity).toBe("warn"); - expect(probeCommands).toHaveLength(1); - expect(probeCommands[0]).toContain(`gateway='${HERMES_DEFAULT_SESSION_DIR}/creds.json'`); - expect(probeCommands[0]).not.toContain(String(sessionPath)); - }); + ])( + "keeps the durable session path for an unsupported compatibility value: $label (#8947)", + ({ sessionPath }) => { + const exec = hermesExec({ + configuredSessionPath: sessionPath, + credsDirs: [HERMES_DASHBOARD_SESSION_DIR], + }); + const result = createWhatsappStatusHealthHook({ executeSandboxCommand: exec })( + context({ ...BASE_INPUTS, agent: "hermes" }), + ); + const report = reportOf(result); + const probeCommands = exec.mock.calls + .map((call) => String(call[1] ?? "")) + .filter((command) => !command.startsWith("python3 -c ")); + expect(report?.verdict).toBe("unpaired"); + expect(report?.signals.find((s) => s.label === "Session path override")?.severity).toBe( + "warn", + ); + expect(probeCommands).toHaveLength(1); + expect(probeCommands[0]).toContain(`gateway='${HERMES_DEFAULT_SESSION_DIR}/creds.json'`); + expect(probeCommands[0]).not.toContain(String(sessionPath)); + }, + ); it("reads the Hermes config only when the default session path is empty (#8718)", () => { const exec = hermesExec({ @@ -730,7 +733,7 @@ describe("whatsapp.statusHealth wiring guards", () => { const hook = createWhatsappStatusHealthHook({ executeSandboxCommand: exec }); const configGap = reportOf(hook(context({ ...BASE_INPUTS, channelEnabledInRegistry: false }))); expect(configGap?.verdict).toBe("config_gap"); - const policyGap = reportOf(hook(context({ ...BASE_INPUTS, presetInRegistry: false }))); + const policyGap = reportOf(hook(context({ ...BASE_INPUTS, presetApplied: false }))); expect(policyGap?.verdict).toBe("policy_gap"); }); }); diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 3d73ecea20d..25dc50fc59a 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -119,7 +119,7 @@ export function createWhatsappStatusHealthHook( recentLogSignals: probe.recentLogSignals, probeReachable: probe.probeReachable, probedAt: normalizeString(context.inputs?.probedAt) ?? "", - presetInRegistry: Boolean(context.inputs?.presetInRegistry), + presetApplied: Boolean(context.inputs?.presetApplied), presetOnGateway: normalizeTristate(context.inputs?.presetOnGateway), channelEnabledInRegistry: Boolean(context.inputs?.channelEnabledInRegistry), ...(probe.sessionLocations ? { sessionLocations: probe.sessionLocations } : {}), diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 591fa5f328f..3756b588d78 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -1048,7 +1048,6 @@ describe("ManifestCompiler", () => { }, ], credentials: [], - policyPresets: [], render: [], hooks: [], } as const satisfies ChannelManifest; @@ -1116,7 +1115,6 @@ describe("ManifestCompiler", () => { }, ], credentials: [], - policyPresets: [], render: [], hooks: [ { @@ -1199,7 +1197,6 @@ describe("ManifestCompiler", () => { }, ], credentials: [], - policyPresets: [], render: [], hooks: [], } as const satisfies ChannelManifest; @@ -1344,7 +1341,7 @@ describe("ManifestCompiler", () => { placeholder: "openshell:resolve:env:MATRIX_ACCESS_TOKEN", }, ], - policyPresets: ["matrix"], + policyPresets: [{ name: "matrix", policyKeys: ["matrix"] }], render: [], hooks: [ { diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index 2e74f8c8dfa..54d3146c630 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -19,15 +19,11 @@ describe("messaging channel diagnostics", () => { "googlechat", ]); expect(specs.find((spec) => spec.channelId === "telegram")).toMatchObject({ - policyPresets: ["telegram"], preferredDefault: false, deepProbe: "log-tail", }); - expect(specs.find((spec) => spec.channelId === "wechat")).toMatchObject({ - policyPresets: ["wechat"], - }); + expect(specs.find((spec) => spec.channelId === "wechat")).toMatchObject({}); expect(specs.find((spec) => spec.channelId === "whatsapp")).toMatchObject({ - policyPresets: ["whatsapp"], preferredDefault: true, deepProbe: "in-sandbox-qr", doctorWhenNoHealthSignals: expect.objectContaining({ @@ -35,7 +31,6 @@ describe("messaging channel diagnostics", () => { }), }); expect(specs.find((spec) => spec.channelId === "teams")).toMatchObject({ - policyPresets: ["teams"], preferredDefault: false, }); }); diff --git a/src/lib/messaging/hydration.ts b/src/lib/messaging/hydration.ts index 07d79f3dcfd..6c736a8b32b 100644 --- a/src/lib/messaging/hydration.ts +++ b/src/lib/messaging/hydration.ts @@ -63,10 +63,7 @@ export function hydrateDerivedSandboxMessagingPlanFields( }); return { ...hydratedPlan, - networkPolicy: - plan.networkPolicy.entries.length > 0 - ? plan.networkPolicy - : planNetworkPolicy(manifests, compilerContext(hydratedPlan)), + networkPolicy: planNetworkPolicy(manifests, compilerContext(hydratedPlan)), agentRender: plan.agentRender.length > 0 ? plan.agentRender @@ -230,9 +227,7 @@ function selectHookInputs( function runtimeSetupHasEntries(setup: SandboxMessagingRuntimeSetupPlan | undefined): boolean { return Boolean( setup && - (setup.nodePreloads.length > 0 || - setup.envAliases.length > 0 || - setup.secretScans.length > 0), + (setup.nodePreloads.length > 0 || setup.envAliases.length > 0 || setup.secretScans.length > 0), ); } diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 665b5a635cc..48bbd4bb797 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -20,7 +20,6 @@ function makeManifest( }, inputs: [], credentials: [], - policyPresets: [id], render: [], hooks: [], }; diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 400bf5ed531..b1c2dbac2c1 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -65,7 +65,6 @@ const telegramManifest = { placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", }, ], - policyPresets: ["telegram"], render: [ { id: "telegram-openclaw", @@ -127,7 +126,6 @@ const wechatHookManifest = { placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN", }, ], - policyPresets: ["wechat"], render: [], hooks: [ { diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index c1461f7f079..5cadbeb1713 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -67,7 +67,6 @@ export type PersistedSandboxMessagingPlan = Omit< > & { readonly channels: readonly PersistedSandboxMessagingChannelPlan[]; readonly credentialBindings?: readonly PersistedSandboxMessagingCredentialBindingPlan[]; - readonly networkPolicy?: SandboxMessagingPlan["networkPolicy"]; readonly agentRender?: readonly SandboxMessagingAgentRenderPlan[]; readonly buildSteps?: readonly SandboxMessagingBuildStepPlan[]; readonly runtimeSetup?: SandboxMessagingRuntimeSetupPlan; @@ -81,7 +80,7 @@ export function compactSandboxMessagingPlanForPersistence( const { channels, credentialBindings, - networkPolicy, + networkPolicy: _networkPolicy, agentRender: _agentRender, buildSteps: _buildSteps, runtimeSetup: _runtimeSetup, @@ -91,7 +90,6 @@ export function compactSandboxMessagingPlanForPersistence( } = clonePlan(plan); return { ...rest, - networkPolicy, channels: channels.map((channel) => ({ channelId: channel.channelId, active: channel.active, diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 2c3da6b59da..070dab172f7 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import type { SandboxMessagingPlan } from "./manifest"; import { compactSandboxMessagingPlanForPersistence } from "./persistence"; +import { hydrateDerivedSandboxMessagingPlanFields } from "./hydration"; import { getActiveChannelIdsFromPlan, getConfiguredChannelIdsFromPlan, @@ -100,7 +101,7 @@ describe("parseSandboxMessagingPlan", () => { const compact = compactSandboxMessagingPlanForPersistence(source); const parsed = parseSandboxMessagingPlan(compact); - expect(compact.networkPolicy).toEqual(source.networkPolicy); + expect(compact).not.toHaveProperty("networkPolicy"); expect(compact).not.toHaveProperty("agentRender"); expect(compact).not.toHaveProperty("buildSteps"); expect(compact).not.toHaveProperty("runtimeSetup"); @@ -211,7 +212,7 @@ describe("parseSandboxMessagingPlan", () => { const compact = compactSandboxMessagingPlanForPersistence(source); - expect(compact.networkPolicy).toEqual(source.networkPolicy); + expect(compact).not.toHaveProperty("networkPolicy"); expect(compact).not.toHaveProperty("agentRender"); expect(compact).not.toHaveProperty("buildSteps"); expect(compact).not.toHaveProperty("runtimeSetup"); @@ -228,6 +229,28 @@ describe("parseSandboxMessagingPlan", () => { ]); }); + it("drops legacy persisted policy references and regenerates transient policy from manifests", () => { + const compact = compactSandboxMessagingPlanForPersistence(makePlan()); + const parsed = parseSandboxMessagingPlan({ + ...compact, + networkPolicy: { + presets: ["stale-shadow"], + entries: [ + { + channelId: "telegram", + presetName: "stale-shadow", + policyKeys: ["stale-shadow"], + source: "manifest", + }, + ], + }, + }); + + const hydrated = hydrateDerivedSandboxMessagingPlanFields(parsed!); + expect(hydrated.networkPolicy.presets).toEqual(["telegram"]); + expect(hydrated.networkPolicy.presets).not.toContain("stale-shadow"); + }); + it("rejects mismatched selectors, duplicate channels, and unsupported channels", () => { expect(parseSandboxMessagingPlan(makePlan(), { sandboxName: "other" })).toBeNull(); expect(parseSandboxMessagingPlan(makePlan(), { agent: "hermes" })).toBeNull(); diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index 5eb3194ea27..f1deb4c2258 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -143,6 +143,11 @@ function hasMatchingAgentRenderEntries(value: unknown, agent: string): boolean { ); } +function hasCanonicalNetworkPolicyReferences(value: unknown): boolean { + if (!isObjectRecord(value) || !Object.hasOwn(value, "entries")) return true; + return hasCanonicalChannelReferences(value.entries); +} + export function cloneSandboxMessagingPlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { return JSON.parse(JSON.stringify(plan)) as SandboxMessagingPlan; } @@ -268,11 +273,6 @@ function hasCanonicalChannelReferences(value: unknown): boolean { ); } -function hasCanonicalNetworkPolicyReferences(value: unknown): boolean { - if (!isObjectRecord(value) || !Object.hasOwn(value, "entries")) return true; - return hasCanonicalChannelReferences(value.entries); -} - function hasCanonicalRuntimeSetupReferences(value: unknown): boolean { if (value === undefined) return true; if (!isObjectRecord(value)) return false; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index fa2242bc602..3d1a45d06be 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -146,7 +146,6 @@ const { OllamaProbeFailureTracker, }: typeof import("./onboard/ollama-probe-failure-tracker") = require("./onboard/ollama-probe-failure-tracker"); const crypto = require("node:crypto"); -const fs = require("fs"); const os = require("os"); const path = require("path"); const runner: typeof import("./runner") = require("./runner"); @@ -238,11 +237,10 @@ const { } = require("./inference/ollama/windows"); const vllmInference = require("./inference/vllm"); const inferenceConfig: typeof import("./inference/config") = require("./inference/config"); -const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference } = inferenceConfig; +const { getProviderSelectionConfig, parseGatewayInference } = inferenceConfig; const onboardProviders = require("./onboard/providers"); const credentialProviderRegistration: typeof import("./onboard/credential-provider-registration") = require("./onboard/credential-provider-registration"); -const inferenceProviders: typeof import("./onboard/inference-providers") = require("./onboard/inference-providers"); const setupInferenceFactory: typeof import("./onboard/setup-inference") = require("./onboard/setup-inference"); const hermesProviderAuth = require("./hermes-provider-auth"); const onboardHermesDashboard: typeof import("./onboard/hermes-dashboard") = require("./onboard/hermes-dashboard"); @@ -327,7 +325,6 @@ const { removeLegacyCredentialsFile, normalizeCredentialValue, resolveProviderCredential, - saveCredential, } = credentials; const { hashCredential, @@ -338,8 +335,6 @@ const { const registry: typeof import("./state/registry") = require("./state/registry"); const sandboxMutationLock: typeof import("./state/mcp-lifecycle-lock") = require("./state/mcp-lifecycle-lock"); const gatewayRouteMutationLock: typeof import("./inference/gateway-route-mutation-lock") = require("./inference/gateway-route-mutation-lock"); -const { resolveSandboxImageTagFromCreateOutput } = - require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); const { markCancellationRecovery: recordRecovery } = onboardSession; @@ -415,7 +410,6 @@ const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require(" const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata"); const sandboxReuse: typeof import("./onboard/sandbox-reuse") = require("./onboard/sandbox-reuse"); const sandboxRecreateTransaction: typeof import("./onboard/sandbox-recreate-transaction") = require("./onboard/sandbox-recreate-transaction"); -const sandboxRegistration: typeof import("./onboard/sandbox-registration") = require("./onboard/sandbox-registration"); const { formatSandboxAgentName, getAgentInferenceProviderOptions, @@ -436,7 +430,6 @@ const promptValidatedSandboxName = sandboxAgent.createPromptValidatedSandboxName }); const modelRouter: typeof import("./onboard/model-router") = require("./onboard/model-router"); const { - DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV, isRoutedInferenceProvider, loadBlueprintProfile, reconcileModelRouter, @@ -472,7 +465,6 @@ const { const { skippedStepMessage, }: typeof import("./onboard/skipped-step-message") = require("./onboard/skipped-step-message"); -const policyPresetCarry: typeof import("./onboard/policy-preset-persistence") = require("./onboard/policy-preset-persistence"); const { findAvailableDashboardPort, preflightDashboardPortRangeAvailability, @@ -525,8 +517,6 @@ const agentOnboard = require("./agent/onboard"); const agentDefs = require("./agent/defs"); const gatewayState: typeof import("./state/gateway") = require("./state/gateway"); -const openClawPluginRestore: typeof import("./state/openclaw-plugin-restore") = require("./state/openclaw-plugin-restore"); -const sandboxState: typeof import("./state/sandbox") = require("./state/sandbox"); const validation: typeof import("./validation") = require("./validation"); const urlUtils: typeof import("./core/url-utils") = require("./core/url-utils"); const buildContext = require("./build-context"); @@ -534,10 +524,8 @@ const httpProbe: typeof import("./adapters/http/probe") = require("./adapters/ht const modelPrompts: typeof import("./inference/model-prompts") = require("./inference/model-prompts"); const providerModels: typeof import("./inference/provider-models") = require("./inference/provider-models"); const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); -const webSearch: typeof import("./inference/web-search") = require("./inference/web-search"); const openshellInstallFlow: typeof import("./onboard/openshell-install") = require("./onboard/openshell-install"); const openshellPinFlow: typeof import("./onboard/openshell-pin") = require("./onboard/openshell-pin"); -const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-failure") = require("./onboard/sandbox-create-failure"); import type { CurlProbeResult } from "./adapters/http/probe"; import type { AgentDefinition } from "./agent/defs"; @@ -984,7 +972,7 @@ const { validateSelectedRemoteModel } = createRemoteModelValidator({ ...reasoningMode.compatibleEndpointReasoningConfigureDeps, }); -const { promptCloudModel, promptRemoteModel, promptInputModel } = modelPrompts; +const { promptRemoteModel, promptInputModel } = modelPrompts; const { validateAnthropicModel, validateOpenAiLikeModel } = providerModels; const nousModels: typeof import("./inference/nous-models") = require("./inference/nous-models"); @@ -1125,7 +1113,6 @@ const { removeDockerDriverGatewayRegistration, retireLegacyGatewayForDockerDriverUpgrade, runQuietOpenshell, - stopDockerDriverGatewayProcess, } = createGatewayProcessLifecycle({ gatewayName: () => GATEWAY_NAME, dashboardPort: getOnboardDashboardPort, @@ -1522,13 +1509,10 @@ const gatewayRecovery = createGatewayRecoveryOrchestration({ }); const { - attachGatewayMetadataIfNeeded, recoverGatewayRuntime, - registerDockerDriverGatewayEndpoint, startDockerDriverGateway, startGateway, startGatewayForRecovery, - startGatewayWithOptions, } = createGatewayLifecycleApplication({ dockerDriverStart: dockerDriverGatewayStart, recovery: gatewayRecovery, @@ -1599,7 +1583,6 @@ const sandboxCreateOrchestrationRuntime = { isWsl, managedWorkloadOnboard, messagingChannelSetup, - nim, normalizeHermesAuthMethod, normalizeHermesToolGatewaySelections, note, @@ -1611,7 +1594,6 @@ const sandboxCreateOrchestrationRuntime = { openshellArgv, path, planRegisteredExtraProviders, - policyPresetCarry, preparedDcodeRebuild, promptValidatedSandboxName, promptYesNoOrDefault, @@ -1636,7 +1618,6 @@ const sandboxCreateOrchestrationRuntime = { sandboxLifecycle, sandboxMutationLock, sandboxRecreateTransaction, - sandboxRegistration, sandboxRegistryMetadata, sandboxReuse, shouldSkipPreRecreateBackup, @@ -2593,13 +2574,10 @@ const { buildControlUiUrls, buildOrphanedSandboxRollbackMessage, ensureDashboardForward, - ensureAgentDashboardForward, ensureFinalizationAgentDashboardForward, - ensureFinalizationDashboardForward, ensureAgentFixedForward, fetchGatewayAuthTokenFromSandbox, getDashboardForwardPort, - getWslHostAddress, printDashboard, stopAllDashboardForwards, } = onboardDashboard.createOnboardDashboardHelpers({ @@ -2648,9 +2626,6 @@ const { withSandboxMutationLock: sandboxMutationLock.withSandboxMutationLock, waitForSandboxReady, waitForSandboxControlPlaneReady: finalizationHandlerDeps.waitForSandboxControlPlaneReady, - setPolicyTier: (sandboxName, tierName) => - registry.updateSandbox(sandboxName, { policyTier: tierName }), - getRecordedPolicyTier: (sandboxName) => registry.getSandbox(sandboxName)?.policyTier ?? null, parsePolicyPresetEnv, env: process.env, }); @@ -2976,11 +2951,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { requestedGpuPassthrough: opts.gpu === true, }; type InitialOnboardFlowContext = typeof initialFlowContext; - const policyAuthorityBindings = - sandboxCreateOrchestration.createOnboardPolicyAuthorityBindings( - sandboxCreateOrchestrationRuntime, - opts.policyTier, - ); const [preflightPhase, gatewayPhase]: readonly [ import("./onboard/machine/sequence-runner").OnboardSequencePhase, import("./onboard/machine/sequence-runner").OnboardSequencePhase, @@ -3022,7 +2992,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { assertGatewayReadiness: () => onboardPreflightGatewayAuthority.collectGatewayReadiness().then(() => undefined), gatewayName: GATEWAY_NAME, - bindPolicyAuthority: policyAuthorityBindings.bindPolicyAuthority, recreateSandbox: isRecreateSandbox, requiresBindMounts: effectiveHostMounts.length > 0, gatewayDeps: { @@ -3114,7 +3083,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { deps: { checkGatewayRouteCompatibility, preflightGatewayRouteDiscovery, - preflightPolicyRequirements: policyAuthorityBindings.preflightPolicyRequirements, getSandboxRecoveryAuthority: providerRecovery.getSandboxRecoveryAuthority, withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock, normalizeHermesAuthMethod, @@ -3127,7 +3095,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { assertRouteCompatible, canProbeRoute, recoverySessionId, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ) => setupNim( g, @@ -3139,7 +3107,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { assertRouteCompatible, canProbeRoute, recoverySessionId, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), setupInference, resolveHostLocalInferenceStartupSelection: @@ -3250,7 +3218,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { getRegistrySandboxMessagingAuthority: messagingChannelSetup.getRegistrySandboxMessagingAuthority, providerMatchesGatewayCredential, - preflightPolicyRequirements: policyAuthorityBindings.preflightPolicyRequirements, stageSandboxCredentialProviders, promptValidatedSandboxName, selectResourceProfileForSandbox: () => @@ -3312,13 +3279,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { import("./verify-deployment").VerifyDeploymentResult >({ branchState: agent ? "agent_setup" : "openclaw", - authoritativePolicyTier: - opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : undefined, - revalidatePolicyRequirements: policyAuthorityBindings.revalidatePolicyRequirements, agentSetupDeps: { handleAgentSetup: agentOnboard.handleAgentSetup, - agentSetupContext: (revalidatePolicyRequirements) => ({ - ...{ step, runCaptureOpenshell, captureOpenshell, revalidatePolicyRequirements }, + agentSetupContext: () => ({ + ...{ step, runCaptureOpenshell, captureOpenshell }, openshellShellCommand, openshellBinary: getOpenshellBinary(), startRecordedStep, @@ -3326,11 +3290,11 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { recordStepFailed, skippedStepMessage, }), - ensureAgentDashboardForward: (name, selectedAgent, revalidate) => + ensureAgentDashboardForward: (name, selectedAgent) => ensureFinalizationAgentDashboardForward( name, selectedAgent, - revalidate, + undefined, hermesApiPortReservationScope, ), persistDashboardPort: (name, port) => @@ -3364,11 +3328,9 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { recordStateSkipped, startRecordedStep, setupPoliciesWithSelection, - updateSession: onboardSession.updateSession, recordStepComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), - persistAppliedPolicyPresets: policyPresetCarry.persistFinalizedPolicyPresets, }, finalization: { stagedLegacyKeys, diff --git a/src/lib/onboard/agent-dashboard-forward.test.ts b/src/lib/onboard/agent-dashboard-forward.test.ts index 9f2dd0cf37a..8c78fdbe1fc 100644 --- a/src/lib/onboard/agent-dashboard-forward.test.ts +++ b/src/lib/onboard/agent-dashboard-forward.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { PolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; import { ensureAgentDashboardForward } from "./agent-dashboard-forward"; describe("ensureAgentDashboardForward", () => { @@ -159,52 +158,6 @@ describe("ensureAgentDashboardForward", () => { expect(process.env.CHAT_UI_URL).toBe("https://hermes.example.test:9120/ui"); }); - it("compensates primary and optional forwards when final authority is refused (#9833)", async () => { - const refusal = new PolicyAuthorityRefusalError("policy authority changed at finality"); - const revalidatePolicyAuthority = vi - .fn<(operation: string) => void>() - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => { - throw refusal; - }); - const compensateDashboardForward = vi.fn(); - const ensureDashboardForward = vi.fn( - ( - _sandboxName, - chatUiUrl = "", - options?: { - revalidatePolicyAuthority?: (operation: string) => void; - onForwardStarted?: (port: number) => void; - }, - ) => { - const port = Number(new URL(chatUiUrl).port); - options?.revalidatePolicyAuthority?.(`start dashboard forward ${String(port)}`); - options?.onForwardStarted?.(port); - return port; - }, - ); - - await expect( - ensureAgentDashboardForward({ - sandboxName: "hm", - agent: { - dashboard: { kind: "ui" }, - forwardPort: 18789, - forward_ports: [18789, 8642], - }, - ensureDashboardForward, - revalidatePolicyAuthority, - compensateDashboardForward, - }), - ).rejects.toBe(refusal); - - expect(ensureDashboardForward).toHaveBeenCalledTimes(2); - expect(compensateDashboardForward.mock.calls).toEqual([[8642], [18789]]); - expect(process.env.CHAT_UI_URL).toBeUndefined(); - }); - it("forwards an API-kind agent on the sandbox-owned primary port", async () => { const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "") => { return Number(new URL(chatUiUrl).port); diff --git a/src/lib/onboard/agent-dashboard-forward.ts b/src/lib/onboard/agent-dashboard-forward.ts index 7e7b4012ca0..632b46b5ebd 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isPolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; +import { isPolicyObservationError } from "../adapters/openshell/policy-state"; import { DASHBOARD_PORT, HERMES_OPENAI_API_PORT } from "../core/ports"; import { type DashboardRuntimeAgent, @@ -23,7 +23,7 @@ export type EnsureDashboardForward = ( options?: { preserveSandboxPorts?: Array; allowPortReallocation?: boolean; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; onForwardStarted?: (port: number) => void; }, ) => number; @@ -43,7 +43,7 @@ export async function ensureAgentDashboardForward(options: { hermesApiPort?: number | null; preserveForwardPorts?: readonly (number | null | undefined)[]; beforeForwardPort?: (port: number) => Promise | void; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; compensateDashboardForward?: (port: number) => void; warn?: (message: string) => void; }): Promise { @@ -56,7 +56,7 @@ export async function ensureAgentDashboardForward(options: { hermesApiPort, preserveForwardPorts = [], beforeForwardPort, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, compensateDashboardForward, warn = (message: string) => console.warn(message), } = options; @@ -69,7 +69,7 @@ export async function ensureAgentDashboardForward(options: { if (!startedForwardPorts.includes(port)) startedForwardPorts.push(port); }; const startedForwardCallback = - revalidatePolicyAuthority && compensateDashboardForward ? recordStartedForward : undefined; + verifyLivePolicyRequirements && compensateDashboardForward ? recordStartedForward : undefined; const restoreChatUiUrl = (): void => { if (previousChatUiUrl === undefined) delete process.env.CHAT_UI_URL; else process.env.CHAT_UI_URL = previousChatUiUrl; @@ -112,10 +112,10 @@ export async function ensureAgentDashboardForward(options: { const actualAgentDashboardPort = ensureDashboardForward(sandboxName, requestedDashboardUrl, { preserveSandboxPorts: preservePorts, ...(startedForwardCallback ? { onForwardStarted: startedForwardCallback } : {}), - ...(revalidatePolicyAuthority ? { revalidatePolicyAuthority } : {}), + ...(verifyLivePolicyRequirements ? { verifyLivePolicyRequirements } : {}), }); if (!usesFixedApiPort) { - revalidatePolicyAuthority?.(`publish the dashboard URL for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`publish the dashboard URL for sandbox '${sandboxName}'`); process.env.CHAT_UI_URL = replaceUrlPort(requestedDashboardUrl, actualAgentDashboardPort); } @@ -132,10 +132,10 @@ export async function ensureAgentDashboardForward(options: { preserveSandboxPorts: portsToPreserve, allowPortReallocation: false, ...(startedForwardCallback ? { onForwardStarted: startedForwardCallback } : {}), - ...(revalidatePolicyAuthority ? { revalidatePolicyAuthority } : {}), + ...(verifyLivePolicyRequirements ? { verifyLivePolicyRequirements } : {}), }); } catch (err) { - if (isPolicyAuthorityRefusalError(err)) throw err; + if (isPolicyObservationError(err)) throw err; warn( ` ! Could not start optional agent port forward ${port}: ${ err instanceof Error ? err.message : String(err) @@ -144,19 +144,19 @@ export async function ensureAgentDashboardForward(options: { } } - revalidatePolicyAuthority?.( + verifyLivePolicyRequirements?.( `report successful dashboard forwarding for sandbox '${sandboxName}'`, ); return actualAgentDashboardPort; } catch (error) { - if (isPolicyAuthorityRefusalError(error)) { + if (isPolicyObservationError(error)) { restoreChatUiUrl(); for (const port of [...startedForwardPorts].reverse()) { try { compensateDashboardForward?.(port); } catch (cleanupError) { warn( - ` ! Could not stop dashboard forward ${String(port)} after policy authority refusal: ${ + ` ! Could not stop dashboard forward ${String(port)} after policy verification failure: ${ cleanupError instanceof Error ? cleanupError.message : String(cleanupError) }`, ); diff --git a/src/lib/onboard/agent-fixed-forward.test.ts b/src/lib/onboard/agent-fixed-forward.test.ts index f7ab6acb477..eeb27f4d1c0 100644 --- a/src/lib/onboard/agent-fixed-forward.test.ts +++ b/src/lib/onboard/agent-fixed-forward.test.ts @@ -56,13 +56,13 @@ describe("ensureAgentFixedForward", () => { }); }); - it("rechecks policy authority before each fixed-forward start (#9833)", () => { + it("rechecks policy requirements before each fixed-forward start (#9833)", () => { const deps = makeDeps(() => ""); - const revalidatePolicyAuthority = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); vi.mocked(runDetachedForwardStartWithRetries).mockImplementationOnce((runSpawn) => { runSpawn({ stdout: 1, stderr: 2 }); @@ -75,14 +75,14 @@ describe("ensureAgentFixedForward", () => { "my-sandbox", 18789, "messaging webhook", - revalidatePolicyAuthority, + verifyLivePolicyRequirements, ), - ).toThrow("policy authority changed"); + ).toThrow("policy requirements changed"); expect(deps.runOpenshell).toHaveBeenCalledWith( ["forward", "stop", "18789", "my-sandbox"], expect.anything(), ); - expect(revalidatePolicyAuthority).toHaveBeenCalledTimes(2); + expect(verifyLivePolicyRequirements).toHaveBeenCalledTimes(2); }); }); diff --git a/src/lib/onboard/agent-fixed-forward.ts b/src/lib/onboard/agent-fixed-forward.ts index 4fb70451ab6..e26b3bf80b9 100644 --- a/src/lib/onboard/agent-fixed-forward.ts +++ b/src/lib/onboard/agent-fixed-forward.ts @@ -24,7 +24,7 @@ export function ensureAgentFixedForward( sandboxName: string, port: number, label: string, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): boolean { const forwardTarget = String(port); const stopForwardForSandbox = (portToStop: string | number) => @@ -34,7 +34,7 @@ export function ensureAgentFixedForward( portToStop, sandboxName, () => - revalidatePolicyAuthority?.( + verifyLivePolicyRequirements?.( `stop ${label} forward ${String(portToStop)} for sandbox '${sandboxName}'`, ), ); @@ -45,7 +45,7 @@ export function ensureAgentFixedForward( ); const { ok, diagnostic } = runDetachedForwardStartWithRetries( (stdio) => { - revalidatePolicyAuthority?.( + verifyLivePolicyRequirements?.( `start ${label} forward ${String(port)} for sandbox '${sandboxName}'`, ); return startForward(stdio); diff --git a/src/lib/onboard/agent-resume-state.ts b/src/lib/onboard/agent-resume-state.ts index 88a74113c0e..06e173d9964 100644 --- a/src/lib/onboard/agent-resume-state.ts +++ b/src/lib/onboard/agent-resume-state.ts @@ -49,7 +49,6 @@ export function clearAgentScopedResumeState(session: Session, selectedAgentName: updatedAt: new Date().toISOString(), }; } - session.policyPresets = null; const resetSteps = [ "provider_selection", diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index 34cd3ab28c8..e8e9bebf7ae 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -28,25 +28,19 @@ const target = { const originalGateway = process.env.OPENSHELL_GATEWAY; describe("authoritative rebuild sandbox flow options", () => { - it("clones authoritative policy state and ignores non-authoritative injection", () => { - const rebuildPolicyPresets = ["github"]; + it("carries only the bounded live OpenShell policy handoff", () => { const projected = authoritativeRebuildSandboxFlowOptions({ authoritativeResumeConfig: true, - policyTier: "balanced", - rebuildPolicyPresets, + rebuildPolicySourcePath: "/tmp/current-policy.yaml", }); expect(projected).toEqual({ authoritativeResumeConfig: true, - authoritativePolicyTier: "balanced", - rebuildPolicyPresets: ["github"], + rebuildPolicySourcePath: "/tmp/current-policy.yaml", }); - expect(projected.rebuildPolicyPresets).not.toBe(rebuildPolicyPresets); expect( authoritativeRebuildSandboxFlowOptions({ authoritativeResumeConfig: false, - policyTier: "balanced", - rebuildPolicyPresets: ["mcp-bridge-fake"], }), ).toEqual({ authoritativeResumeConfig: false }); }); diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index cb6bcb6d5f4..a59756802ce 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -16,18 +16,16 @@ import type { OnboardOptions } from "./types"; export type AuthoritativeOnboardGatewayBinding = { name: string; port: number }; export function authoritativeRebuildSandboxFlowOptions( - opts: Pick, + opts: Pick, ): { authoritativeResumeConfig: boolean; - authoritativePolicyTier?: string | null; - rebuildPolicyPresets?: readonly string[]; + rebuildPolicySourcePath?: string; } { if (opts.authoritativeResumeConfig !== true) return { authoritativeResumeConfig: false }; return { authoritativeResumeConfig: true, - authoritativePolicyTier: opts.policyTier ?? null, - ...(Array.isArray(opts.rebuildPolicyPresets) - ? { rebuildPolicyPresets: [...opts.rebuildPolicyPresets] } + ...(opts.rebuildPolicySourcePath + ? { rebuildPolicySourcePath: opts.rebuildPolicySourcePath } : {}), }; } diff --git a/src/lib/onboard/bedrock-runtime.ts b/src/lib/onboard/bedrock-runtime.ts index ee08c521e8a..51a53b59522 100644 --- a/src/lib/onboard/bedrock-runtime.ts +++ b/src/lib/onboard/bedrock-runtime.ts @@ -81,7 +81,7 @@ export async function selectBedrockRuntimeCustomAnthropic( label: string, helpUrl: string | null, validator?: ((value: string) => string | null) | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; credentialMutationGuard?: (operation: string) => void; } & BedrockRuntimeDependencies, diff --git a/src/lib/onboard/cancel-rollback.test.ts b/src/lib/onboard/cancel-rollback.test.ts index 6fd504321cb..75714827e29 100644 --- a/src/lib/onboard/cancel-rollback.test.ts +++ b/src/lib/onboard/cancel-rollback.test.ts @@ -140,7 +140,6 @@ describe("installSandboxCancelRollback", () => { gatewayName: "nemoclaw-18080", gatewayPort: 18080, lifecycleGeneration: "00000000-0000-4000-8000-000000000004", - verifiedEffectivePolicyIdentity: { hash: "sha256:policy-4", activeVersion: 4 }, } as const; const rollback = createSandboxCancelRollback({ log: vi.fn(), recordRecovery }); const armWithContext = rollback.arm as ( diff --git a/src/lib/onboard/compatible-endpoint-smoke.test.ts b/src/lib/onboard/compatible-endpoint-smoke.test.ts index 6f2f8e23efc..21094fcd531 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.test.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.test.ts @@ -304,7 +304,7 @@ describe("compatible endpoint sandbox smoke helpers", () => { ); }); - it("withholds sandbox-route success output when policy authority changes during proof (#9833)", () => { + it("withholds sandbox-route success output when policy requirements changes during proof (#9833)", () => { const runOpenshell = vi .fn() .mockReturnValueOnce({ status: 0, stdout: "provider ready" }) @@ -320,10 +320,10 @@ describe("compatible endpoint sandbox smoke helpers", () => { redact: (value) => value, messagingChannels: ["telegram"], beforeSuccess: () => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }, }), - ).toThrow("policy authority changed"); + ).toThrow("policy requirements changed"); expect(runOpenshell).toHaveBeenCalledTimes(2); expect(log.mock.calls.flat().join("\n")).not.toContain( diff --git a/src/lib/onboard/compatible-endpoint-smoke.ts b/src/lib/onboard/compatible-endpoint-smoke.ts index c4a19eeda21..7f6e140dfce 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.ts @@ -111,7 +111,7 @@ export function verifyCompatibleEndpointSandboxSmoke(options: { /** Force the provider-neutral inference.local proof for any supported agent. */ forceCanonicalRoute?: boolean; hostLocalInferenceProofAuthority?: HostLocalInferenceSandboxProofAuthority; - /** Recheck policy authority after the sandbox proof and before success output. */ + /** Recheck policy state after the sandbox proof and before success output. */ beforeSuccess?: () => void; }): void { const agentName = options.agent?.name || "openclaw"; diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index bff64429b6c..cfc16dc5a1a 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -20,7 +20,7 @@ import { } from "./created-sandbox-finalization"; import { getDcodeSelectionDrift } from "./dcode-selection-drift"; import type { HermesPortableConfiguredReceipt } from "./experimental/hermes-portable-receipt"; -import { pendingSandboxPolicyVerificationForBoundary } from "./sandbox-create/policy-creation-receipt"; +import { pendingSandboxCreateIdentityForBoundary } from "./sandbox-create/identity-boundary"; import type { SandboxGpuCreateFlowResult } from "./sandbox-gpu-create-flow"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; import type { CreatedSandboxRegistrationInput } from "./sandbox-registration"; @@ -86,7 +86,7 @@ describe("new sandbox cancellation recovery", () => { markCancellationRecovery, dockerInfoFormat: () => "", runCapture: () => "", - revalidatePolicyAuthority: vi.fn(), + verifyLivePolicyRequirements: vi.fn(), applyVmDnsMonkeypatch: vi.fn(), }, ), @@ -380,23 +380,7 @@ describe("created DCode sandbox finalization", () => { it("passes the fresh create endpoint through the production completion constructor (#9555)", async () => { const endpointUrl = "https://openrouter.ai/api/v1"; const model = "nvidia/nemotron-3-ultra-550b-a55b"; - const policyCreationReceipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "dcode", - lifecycleGeneration: "generation-1", - sandboxIdentityFingerprint: "a".repeat(64), - policyHash: "sha256:effective", - policyVersion: 1, - }; - const verifiedPolicyBoundary = { - registration: { - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt, - observedPolicyAuthority: "owner-unknown" as const, - }, + const verifiedCreateBoundary = { sandboxName: "dcode", gatewayName: "nemoclaw", gatewayPort: 8080, @@ -406,7 +390,7 @@ describe("created DCode sandbox finalization", () => { }; const verifiedCreate = { reservation: {} as never, - checkpoint: pendingSandboxPolicyVerificationForBoundary(verifiedPolicyBoundary), + checkpoint: pendingSandboxCreateIdentityForBoundary(verifiedCreateBoundary), } as NonNullable; const runCaptureOpenshell = vi.fn(() => [ @@ -439,7 +423,7 @@ describe("created DCode sandbox finalization", () => { { createIntent: { endpointUrl, endpointSource: null, observabilityEnabled: false }, resolvedCreateIntent: { - policy: { options: { baselineExclusions: [] } }, + policy: { options: {} }, hostMounts: undefined, }, }, @@ -469,12 +453,10 @@ describe("created DCode sandbox finalization", () => { policyPath: "/private/initial-policy.yaml", }, compatibilityPolicyPath: null, - policyTier: null, - policyAuthority: "nemoclaw-managed", dashboardRemoteBindPrepared: false, - getVerifiedPolicyBoundary: () => verifiedPolicyBoundary, + getVerifiedCreateBoundary: () => verifiedCreateBoundary, getVerifiedCreateRegistrationAuthority: () => verifiedCreate, - revalidatePolicyAuthority: vi.fn(), + verifyLivePolicyRequirements: vi.fn(), }, null, "build-1", @@ -1028,23 +1010,7 @@ describe("created sandbox completion actions", () => { order.push("registry"); return input as unknown as SandboxEntry; }); - const policyCreationReceipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "hermes", - lifecycleGeneration: "generation-1", - sandboxIdentityFingerprint: "a".repeat(64), - policyHash: "sha256:effective", - policyVersion: 1, - }; - const verifiedPolicyBoundary = { - registration: { - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt, - observedPolicyAuthority: "owner-unknown" as const, - }, + const verifiedCreateBoundary = { sandboxName: "hermes", gatewayName: "nemoclaw", gatewayPort: 8080, @@ -1073,7 +1039,7 @@ describe("created sandbox completion actions", () => { } satisfies QualifiedSandboxInferenceRouteReservation; const verifiedCreate = { reservation: inferenceRouteReservation, - checkpoint: pendingSandboxPolicyVerificationForBoundary(verifiedPolicyBoundary), + checkpoint: pendingSandboxCreateIdentityForBoundary(verifiedCreateBoundary), } as NonNullable; const completion = createCreatedSandboxCompletionActions( { @@ -1112,7 +1078,6 @@ describe("created sandbox completion actions", () => { }, agent: null, agentVersionKnown: true, - appliedPolicies: ["personal-open-internet"], plannedMessagingState: undefined, hermesToolGateways: [], gatewayName: "nemoclaw", @@ -1121,7 +1086,7 @@ describe("created sandbox completion actions", () => { policy: { initialPolicyPath: "/private/initial-policy.yaml", compatibilityPolicyPath: "/private/compatibility-policy.yaml", - getVerifiedPolicyBoundary: () => verifiedPolicyBoundary, + getVerifiedCreateBoundary: () => verifiedCreateBoundary, getVerifiedCreateRegistrationAuthority: () => verifiedCreate, }, gpu: { @@ -1243,14 +1208,9 @@ describe("created sandbox completion actions", () => { expect.objectContaining({ imageTag: "hermes:test", hermesPortableLifecycle: schema5, - appliedPolicies: ["personal-open-internet"], dashboardPort: manageDashboard ? 8644 : 0, lifecycleGeneration: "generation-1", lifecycleLiveIdentityFingerprint: "a".repeat(64), - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: expect.objectContaining({ - policyHash: "sha256:effective", - }), inferenceSelection: inferenceRouteReservation.authority.selection, inferenceRouteReservation, verifiedCreate, diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index 5c2d3deadbf..2c568ec7a26 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -29,9 +29,9 @@ import type { HermesPortableConfiguredReceipt } from "./experimental/hermes-port import { warnIfLandlockUnsupported } from "./landlock-warning"; import * as managedWorkloadOnboard from "./managed-workload/onboard-orchestration"; import { printMessagingProviderMissing } from "./preflight-messages"; -import { pendingSandboxPolicyVerificationForBoundary } from "./sandbox-create/policy-creation-receipt"; +import { pendingSandboxCreateIdentityForBoundary } from "./sandbox-create/identity-boundary"; import type { SandboxGpuCreateFlowResult } from "./sandbox-gpu-create-flow"; -import type { VerifiedSandboxPolicyBoundary, VerifiedSandboxPolicyRegistration } from "./types"; +import type { VerifiedSandboxCreateBoundary } from "./types"; import type { SelectionDrift } from "./selection-drift"; import { applyOnboardVmDnsMonkeypatch } from "./vm-dns-monkeypatch"; import { @@ -61,7 +61,7 @@ export type CreatedSandboxFinalizationOptions = { }; export type CreatedSandboxFinalizationDeps = { - revalidatePolicyAuthority?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; discoverFreshOpenClawImagePluginInstalls( sandboxName: string, ): OpenClawManagedExtensionDiscoveryResult; @@ -107,7 +107,7 @@ export interface CreatedSandboxCompletionOptions { readonly policy: { readonly initialPolicyPath: string; readonly compatibilityPolicyPath: string | null; - readonly getVerifiedPolicyBoundary: () => VerifiedSandboxPolicyBoundary; + readonly getVerifiedCreateBoundary: () => VerifiedSandboxCreateBoundary; readonly getVerifiedCreateRegistrationAuthority: () => NonNullable< CreatedSandboxRegistrationInput["verifiedCreate"] >; @@ -134,7 +134,7 @@ export interface CreatedSandboxCompletionOptions { chatUiUrl: string, options: { rollbackSandboxOnFailure: true; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }, ) => number; readonly getForwardPort: (chatUiUrl: string) => string; @@ -143,7 +143,7 @@ export interface CreatedSandboxCompletionOptions { state: HermesDashboardOnboardState, sandboxName: string, rollback: true, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => void; }; readonly workload: Omit< @@ -258,13 +258,13 @@ export function completeOrdinaryOnboardSandboxCreation( readonly markCancellationRecovery: (sandboxName: string) => unknown; readonly dockerInfoFormat: Parameters[0]["dockerInfoFormat"]; readonly runCapture: Parameters[0]["runCapture"]; - readonly revalidatePolicyAuthority: (operation: string) => void; + readonly verifyLivePolicyRequirements: (operation: string) => void; readonly applyVmDnsMonkeypatch?: typeof applyOnboardVmDnsMonkeypatch; }, ): string { - deps.revalidatePolicyAuthority(`completing sandbox '${input.sandboxName}'`); + deps.verifyLivePolicyRequirements(`completing sandbox '${input.sandboxName}'`); restoreDefaultAfterRecreate(deps.setDefault, input.sandboxName, input.sandboxWasLiveDefault); - deps.revalidatePolicyAuthority(`starting DNS setup for sandbox '${input.sandboxName}'`); + deps.verifyLivePolicyRequirements(`starting DNS setup for sandbox '${input.sandboxName}'`); if (input.runtimeFields.openshellDriver === "kubernetes") { console.log(" Setting up sandbox DNS proxy..."); deps.runFile( @@ -272,17 +272,17 @@ export function completeOrdinaryOnboardSandboxCreation( [path.join(deps.scriptsDir, "setup-dns-proxy.sh"), deps.gatewayName, input.sandboxName], { ignoreError: true }, ); - deps.revalidatePolicyAuthority(`applying DNS settings for sandbox '${input.sandboxName}'`); + deps.verifyLivePolicyRequirements(`applying DNS settings for sandbox '${input.sandboxName}'`); } (deps.applyVmDnsMonkeypatch ?? applyOnboardVmDnsMonkeypatch)( input.sandboxName, { ...input.runtimeFields, gatewayPort: input.gatewayPort }, - { revalidatePolicyAuthority: deps.revalidatePolicyAuthority }, + { verifyLivePolicyRequirements: deps.verifyLivePolicyRequirements }, ); for (const provider of input.messagingProviders) { if (!deps.providerExistsInGateway(provider)) printMessagingProviderMissing(provider); } - deps.revalidatePolicyAuthority(`reporting sandbox '${input.sandboxName}' creation success`); + deps.verifyLivePolicyRequirements(`reporting sandbox '${input.sandboxName}' creation success`); console.log(` ✓ Sandbox '${input.sandboxName}' created`); warnIfLandlockUnsupported(deps); if (!input.liveExists) { @@ -368,7 +368,7 @@ export function createCreatedSandboxCompletionActions( }, created.runtimePatch, () => - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `committing GPU capability for sandbox '${options.finalization.sandboxName}'`, ), ); @@ -380,14 +380,14 @@ export function createCreatedSandboxCompletionActions( } async function finalizeDashboard(): Promise { await options.dashboard.releasePort(); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `configuring dashboard capability for sandbox '${options.finalization.sandboxName}'`, ); dashboardPort = options.dashboard.ensureForward(options.finalization.sandboxName, chatUiUrl, { rollbackSandboxOnFailure: true, - revalidatePolicyAuthority: deps.revalidatePolicyAuthority, + verifyLivePolicyRequirements: deps.verifyLivePolicyRequirements, }); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `configuring dashboard capability for sandbox '${options.finalization.sandboxName}'`, ); if (dashboardPort !== Number(options.dashboard.getForwardPort(chatUiUrl))) { @@ -395,16 +395,16 @@ export function createCreatedSandboxCompletionActions( } process.env.CHAT_UI_URL = chatUiUrl; hermesDashboardState = options.dashboard.resolveHermesState(dashboardPort); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `configuring Hermes dashboard capability for sandbox '${options.finalization.sandboxName}'`, ); options.dashboard.ensureHermesForward( hermesDashboardState, options.finalization.sandboxName, true, - deps.revalidatePolicyAuthority, + deps.verifyLivePolicyRequirements, ); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `recording Hermes dashboard capability for sandbox '${options.finalization.sandboxName}'`, ); } @@ -421,30 +421,30 @@ export function createCreatedSandboxCompletionActions( const verifiedLifecycle = lifecycle.revalidate( lifecycle.capture(resolveLifecycleRegistrationFields()), ); - const verifiedPolicyBoundary = options.policy.getVerifiedPolicyBoundary(); - assertVerifiedPolicyBoundaryMatchesLifecycle( - verifiedPolicyBoundary, + const verifiedCreateBoundary = options.policy.getVerifiedCreateBoundary(); + assertVerifiedCreateBoundaryMatchesLifecycle( + verifiedCreateBoundary, options.finalization.sandboxName, options.registration.gatewayName, options.registration.gatewayPort, verifiedLifecycle, ); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `finalizing verified policy for sandbox '${options.finalization.sandboxName}'`, ); if (providerGpuDisposition === "created") { - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `committing GPU capability for sandbox '${options.finalization.sandboxName}'`, ); await verifyCreatedProviderGpu(created!); } else if (providerGpuDisposition === "hermes") { - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `recording GPU capability for sandbox '${options.finalization.sandboxName}'`, ); recordHermesGpuProof(); } if (manageDashboard) { - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `configuring dashboard capability for sandbox '${options.finalization.sandboxName}'`, ); await finalizeDashboard(); @@ -456,24 +456,23 @@ export function createCreatedSandboxCompletionActions( createOutput: created?.origin === "created" ? created.createResult.output : "", }); const finalLifecycle = lifecycle.revalidate(verifiedLifecycle); - assertVerifiedPolicyBoundaryMatchesLifecycle( - verifiedPolicyBoundary, + assertVerifiedCreateBoundaryMatchesLifecycle( + verifiedCreateBoundary, options.finalization.sandboxName, options.registration.gatewayName, options.registration.gatewayPort, finalLifecycle, ); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `publishing sandbox '${options.finalization.sandboxName}' registry authority`, ); - const verifiedPolicyRegistration = verifiedPolicyBoundary.registration; return finalizeCreatedSandbox( { ...options.finalization, gatewayName: options.registration.gatewayName }, { ...deps, register: (openclawImagePluginInstalls) => { const verifiedCreate = options.policy.getVerifiedCreateRegistrationAuthority(); - assertVerifiedCreateMatchesPolicyBoundary(verifiedPolicyBoundary, verifiedCreate); + assertVerifiedCreateMatchesCreateBoundary(verifiedCreateBoundary, verifiedCreate); const verifiedInferenceRouteReservation = verifiedCreate.reservation; if ( inferenceRouteReservation && @@ -502,16 +501,6 @@ export function createCreatedSandboxCompletionActions( hermesDashboardState, dashboardPort, ...finalLifecycle, - appliedPolicies: - verifiedPolicyRegistration.policyAuthority === "externally-managed" - ? [] - : options.registration.appliedPolicies, - policyAuthority: verifiedPolicyRegistration.policyAuthority, - ...(verifiedPolicyRegistration.policyAuthority === "nemoclaw-managed" - ? { - policyCreationReceipt: verifiedPolicyRegistration.policyCreationReceipt, - } - : {}), inferenceRouteReservation: verifiedInferenceRouteReservation, verifiedCreate, }); @@ -522,27 +511,27 @@ export function createCreatedSandboxCompletionActions( }; } -function assertVerifiedCreateMatchesPolicyBoundary( - boundary: VerifiedSandboxPolicyBoundary, +function assertVerifiedCreateMatchesCreateBoundary( + boundary: VerifiedSandboxCreateBoundary, verifiedCreate: NonNullable, ): void { if ( !isDeepStrictEqual( verifiedCreate.checkpoint, - pendingSandboxPolicyVerificationForBoundary(boundary), + pendingSandboxCreateIdentityForBoundary(boundary), ) ) { - throw new Error("Verified sandbox create checkpoint does not match final policy authority."); + throw new Error("Pending sandbox create identity does not match the final create boundary."); } } -function assertVerifiedPolicyBoundaryMatchesLifecycle( - boundary: VerifiedSandboxPolicyBoundary, +function assertVerifiedCreateBoundaryMatchesLifecycle( + boundary: VerifiedSandboxCreateBoundary, sandboxName: string, gatewayName: string, gatewayPort: number, lifecycle: CreatedSandboxLifecycleRegistration, -): VerifiedSandboxPolicyRegistration { +): void { if ( boundary.sandboxName !== sandboxName || boundary.gatewayName !== gatewayName || @@ -550,9 +539,8 @@ function assertVerifiedPolicyBoundaryMatchesLifecycle( boundary.lifecycleGeneration !== lifecycle.lifecycleGeneration || boundary.lifecycleLiveIdentityFingerprint !== lifecycle.lifecycleLiveIdentityFingerprint ) { - throw new Error("Verified sandbox policy authority does not match the final lifecycle."); + throw new Error("Verified sandbox create identity does not match the final lifecycle."); } - return boundary.registration; } type OnboardCreateIntent = { @@ -561,9 +549,7 @@ type OnboardCreateIntent = { } | null; type OnboardResolvedCreateIntent = { readonly policy: { - readonly options: { - readonly baselineExclusions: NonNullable; - }; + readonly options: object; }; readonly hostMounts?: RegistrationSeed["hostMounts"]; }; @@ -590,7 +576,7 @@ type OnboardCreationFidelity = { readonly webSearchConfig: Parameters[0]; readonly hermesAuthMethod: Parameters[2]; }; -type OnboardPolicyRegistration = { +type OnboardSandboxRegistrationOptions = { readonly toolDisclosure: RegistrationSeed["toolDisclosure"]; readonly dcodeAutoApprovalMode: RegistrationSeed["dcodeAutoApprovalMode"]; }; @@ -598,20 +584,16 @@ type OnboardGatewayBinding = { readonly gatewayName: string; readonly gatewayPort: number; }; -type OnboardPreparedPolicy = Omit< - Pick< - managedWorkloadOnboard.PreparedOnboardSandboxWorkloadLaunch, - "initialSandboxPolicy" | "policyTier" | "policyAuthority" | "dashboardRemoteBindPrepared" - >, - "policyAuthority" +type OnboardPreparedPolicy = Pick< + managedWorkloadOnboard.PreparedOnboardSandboxWorkloadLaunch, + "initialSandboxPolicy" | "dashboardRemoteBindPrepared" > & { - readonly policyAuthority: NonNullable; readonly compatibilityPolicyPath: string | null; - readonly getVerifiedPolicyBoundary: () => VerifiedSandboxPolicyBoundary; + readonly getVerifiedCreateBoundary: () => VerifiedSandboxCreateBoundary; readonly getVerifiedCreateRegistrationAuthority: () => NonNullable< CreatedSandboxRegistrationInput["verifiedCreate"] >; - readonly revalidatePolicyAuthority: (operation: string) => void; + readonly verifyLivePolicyRequirements: (operation: string) => void; }; /** Assemble the exact post-Ready owners without adding an onboarding decision. */ @@ -626,7 +608,7 @@ export function createOnboardCreatedSandboxCompletion( createContext: OnboardCreateContext, runtimeFields: RegistrationSeed["runtimeFields"], portableLifecycle: boolean, - policyRegistration: OnboardPolicyRegistration, + sandboxRegistrationOptions: OnboardSandboxRegistrationOptions, creation: OnboardCreationFidelity, messaging: OnboardMessagingRegistration, hermesApiPort: number | null, @@ -679,23 +661,16 @@ export function createOnboardCreatedSandboxCompletion( agent, agentVersionKnown: !fromDockerfile, portableLifecycle, - appliedPolicies: - preparedPolicy.policyAuthority === "externally-managed" - ? [] - : preparedPolicy.initialSandboxPolicy.appliedPresets, - policyAuthority: preparedPolicy.policyAuthority, - toolDisclosure: policyRegistration.toolDisclosure, + toolDisclosure: sandboxRegistrationOptions.toolDisclosure, observabilityEnabled: createIntent?.observabilityEnabled === true, ...(agentFlags.isManagedDcodeAgent - ? { dcodeAutoApprovalMode: policyRegistration.dcodeAutoApprovalMode } + ? { dcodeAutoApprovalMode: sandboxRegistrationOptions.dcodeAutoApprovalMode } : {}), - policyTier: preparedPolicy.policyTier, ...creationFidelity( creation.webSearchConfig, fromDockerfile, creation.hermesAuthMethod, preparedPolicy.dashboardRemoteBindPrepared, - resolvedCreateIntent.policy.options.baselineExclusions, ), ...messaging, hermesApiPort, @@ -705,7 +680,7 @@ export function createOnboardCreatedSandboxCompletion( policy: { initialPolicyPath: preparedPolicy.initialSandboxPolicy.policyPath, compatibilityPolicyPath: preparedPolicy.compatibilityPolicyPath, - getVerifiedPolicyBoundary: preparedPolicy.getVerifiedPolicyBoundary, + getVerifiedCreateBoundary: preparedPolicy.getVerifiedCreateBoundary, getVerifiedCreateRegistrationAuthority: preparedPolicy.getVerifiedCreateRegistrationAuthority, }, @@ -749,7 +724,7 @@ export function createOnboardCreatedSandboxCompletion( note, error: console.error, exitProcess: (code) => process.exit(code), - revalidatePolicyAuthority: preparedPolicy.revalidatePolicyAuthority, + verifyLivePolicyRequirements: preparedPolicy.verifyLivePolicyRequirements, }, ); } @@ -787,7 +762,7 @@ export function finalizeCreatedSandbox( ? " Restoring workspace state from pre-upgrade backup..." : " Restoring workspace state from pre-recreate backup...", ); - deps.revalidatePolicyAuthority?.(`restoring files for sandbox '${options.sandboxName}'`); + deps.verifyLivePolicyRequirements?.(`restoring files for sandbox '${options.sandboxName}'`); const restore = deps.restoreRecreatedSandboxState( options.sandboxName, options.restoreBackupPath, @@ -799,7 +774,7 @@ export function finalizeCreatedSandbox( : {}), }, ); - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `reporting restored state for sandbox '${options.sandboxName}'`, ); if (restore.success) { @@ -877,6 +852,6 @@ export function finalizeCreatedSandbox( } } - deps.revalidatePolicyAuthority?.(`registering sandbox '${options.sandboxName}'`); + deps.verifyLivePolicyRequirements?.(`registering sandbox '${options.sandboxName}'`); return deps.register(freshOpenClawImagePluginInstalls); } diff --git a/src/lib/onboard/credential-navigation.test.ts b/src/lib/onboard/credential-navigation.test.ts index b1147fadb60..1d284c61d84 100644 --- a/src/lib/onboard/credential-navigation.test.ts +++ b/src/lib/onboard/credential-navigation.test.ts @@ -77,7 +77,7 @@ describe("credential prompt navigation helpers", () => { } }); - it("rechecks policy authority after the credential prompt before persistence (#9833)", async () => { + it("rechecks policy requirements after the credential prompt before persistence (#9833)", async () => { const prompt = vi .spyOn(credentials, "readCredentialPrompt") .mockResolvedValue({ kind: "credential", value: "new-secret" }); @@ -89,11 +89,11 @@ describe("credential prompt navigation helpers", () => { envName: "NEMOCLAW_TEST_POLICY_CREDENTIAL", label: "Policy credential", exitOnboardFromPrompt: () => process.exit(1), - revalidatePolicyRequirements: () => { - throw new Error("external policy authority must supply the selected route"); + verifyLivePolicyRequirements: () => { + throw new Error("live policy requirements changed before the selected route"); }, }), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(prompt).toHaveBeenCalledOnce(); expect(saveCredential).not.toHaveBeenCalled(); @@ -102,7 +102,7 @@ describe("credential prompt navigation helpers", () => { }); it.each(["configured", "bridged"] as const)( - "stops Model Router %s credential persistence when policy authority changes (#9833)", + "stops Model Router %s credential persistence when policy requirements changes (#9833)", async (source) => { const saveCredential = vi.fn(); const stageRouterProviderKeyBridge = vi.fn(); @@ -116,8 +116,8 @@ describe("credential prompt navigation helpers", () => { preferredInferenceApi: null, nimContainer: null, allowToolsIncompatible: false, - revalidatePolicyRequirements: () => { - throw new Error("external policy authority must supply the selected route"); + verifyLivePolicyRequirements: () => { + throw new Error("live policy requirements changed before the selected route"); }, } satisfies SetupNimSelectionState; @@ -151,7 +151,7 @@ describe("credential prompt navigation helpers", () => { returningToProviderSelection: () => false, }, }), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(saveCredential).not.toHaveBeenCalled(); expect(stageRouterProviderKeyBridge).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/credential-navigation.ts b/src/lib/onboard/credential-navigation.ts index df706d38f17..e0a43ef5f32 100644 --- a/src/lib/onboard/credential-navigation.ts +++ b/src/lib/onboard/credential-navigation.ts @@ -69,7 +69,7 @@ export async function replaceNamedCredential({ validator = null, allowEmpty = false, exitOnboardFromPrompt, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }: { envName: string; label: string; @@ -77,7 +77,7 @@ export async function replaceNamedCredential({ validator?: ((value: string) => string | null) | null; allowEmpty?: boolean; exitOnboardFromPrompt: () => never; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }): Promise { if (helpUrl) { console.log(""); @@ -98,7 +98,7 @@ export async function replaceNamedCredential({ console.error(validationError); continue; } - revalidatePolicyRequirements?.(`save ${label}`); + verifyLivePolicyRequirements?.(`save ${label}`); credentials.saveCredential(envName, key); process.env[envName] = key; console.log(""); @@ -115,7 +115,7 @@ export async function ensureNamedCredential({ validator = null, allowEmpty = false, exitOnboardFromPrompt, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }: { envName: string | null; label: string; @@ -123,7 +123,7 @@ export async function ensureNamedCredential({ validator?: ((value: string) => string | null) | null; allowEmpty?: boolean; exitOnboardFromPrompt: () => never; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }): Promise { if (!envName) { console.error(` Missing credential target for ${label}.`); @@ -145,7 +145,7 @@ export async function ensureNamedCredential({ validator, allowEmpty, exitOnboardFromPrompt, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }); } @@ -156,7 +156,7 @@ export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never label: string, helpUrl?: string | null, validator?: ((value: string) => string | null) | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; ensureNamedCredential: ( envName: string | null, @@ -164,7 +164,7 @@ export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never helpUrl?: string | null, validator?: ((value: string) => string | null) | null, allowEmpty?: boolean, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; shouldReturnToProviderSelection: (result: unknown) => boolean; returningToProviderSelection: (result: unknown) => result is BackNavigationResult; @@ -176,7 +176,7 @@ export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never label, helpUrl = null, validator = null, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ) => replaceNamedCredential({ envName, @@ -184,7 +184,7 @@ export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never helpUrl, validator, exitOnboardFromPrompt, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), ensureNamedCredential: ( envName, @@ -192,7 +192,7 @@ export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never helpUrl = null, validator = null, allowEmpty = false, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ) => ensureNamedCredential({ envName, @@ -201,7 +201,7 @@ export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never validator, allowEmpty, exitOnboardFromPrompt, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), shouldReturnToProviderSelection: (result) => shouldReturnToProviderSelection(result, exitOnboardFromPrompt), diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 2316b8e5fca..8d81dea1e6e 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -783,7 +783,7 @@ describe("credential provider registration", () => { registration.stageSandboxCredentialProviders( { ...sandboxInput(requiredBindings([tokenDef])), - revalidatePolicyRequirements: () => { + verifyLivePolicyRequirements: () => { throw new Error("authority changed"); }, }, @@ -810,7 +810,7 @@ describe("credential provider registration", () => { { name: "alpha-first", envKey: "FIRST_TOKEN", token: "first-secret" }, { name: "alpha-second", envKey: "SECOND_TOKEN", token: "second-secret" }, ]; - const revalidatePolicyRequirements = vi.fn((operation: string) => + const verifyLivePolicyRequirements = vi.fn((operation: string) => operation === 'inspect or change provider "alpha-second"' ? refuseAuthorityChange() : undefined, @@ -820,7 +820,7 @@ describe("credential provider registration", () => { registration.stageSandboxCredentialProviders( { ...sandboxInput(requiredBindings(tokenDefs)), - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }, async () => ({ messagingTokenDefs: tokenDefs }), ), @@ -847,7 +847,7 @@ describe("credential provider registration", () => { const deps = registrationDeps(runOpenshell, session); deps.stagedLegacyValues = new Map([["DISCORD_BOT_TOKEN", DISCORD_SECRET]]); const registration = createCredentialProviderRegistration(deps); - const revalidatePolicyRequirements = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => undefined) @@ -862,7 +862,7 @@ describe("credential provider registration", () => { token: DISCORD_SECRET, }, ], - { revalidatePolicyRequirements }, + { verifyLivePolicyRequirements }, ), ).toThrow("authority changed"); diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 6f74183cc25..e0c6e9bd04a 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -19,14 +19,14 @@ export interface StageSandboxCredentialProvidersInput { agent: Agent; requiredBindings: readonly CheckpointProviderBinding[]; replaceExisting?: boolean; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; } export interface MessagingProviderRegistrationOptions { replaceExisting?: boolean; bestEffort?: boolean; allowedSandboxes?: readonly string[]; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; } type PreparedCredentialProviders = { @@ -52,7 +52,7 @@ function recordMigratedLegacyMessagingCredentials( tokenDefs: readonly MessagingTokenDef[], registeredProviderNames: readonly string[], deps: CredentialProviderRegistrationDeps, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): void { const registeredProviders = new Set(registeredProviderNames); const migrations: Array<{ envKey: string; migrated: boolean }> = []; @@ -63,7 +63,7 @@ function recordMigratedLegacyMessagingCredentials( migrations.push({ envKey: def.envKey, migrated: def.token === stagedValue }); } if (migrations.length === 0) return; - revalidatePolicyRequirements?.("record migrated messaging provider credentials"); + verifyLivePolicyRequirements?.("record migrated messaging provider credentials"); for (const migration of migrations) { if (migration.migrated) deps.migratedLegacyKeys.add(migration.envKey); else deps.migratedLegacyKeys.delete(migration.envKey); @@ -160,7 +160,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg if (result.ok && credentialEnv) { const stagedValue = deps.stagedLegacyValues.get(credentialEnv); if (stagedValue !== undefined) { - options.revalidatePolicyRequirements?.( + options.verifyLivePolicyRequirements?.( `record migrated credential for provider ${JSON.stringify(name)}`, ); const upsertedValue = env[credentialEnv] ?? deps.getCredential(credentialEnv); @@ -189,7 +189,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg tokenDefs, upserted, deps, - options.revalidatePolicyRequirements, + options.verifyLivePolicyRequirements, ); return upserted; } @@ -249,7 +249,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg prepareCredentialProviders: PrepareCredentialProviders, ): Promise { const messaging = await prepareCredentialProviders(input); - input.revalidatePolicyRequirements?.("stage sandbox credential providers after planning"); + input.verifyLivePolicyRequirements?.("stage sandbox credential providers after planning"); const plannedBindings = validatePlannedCredentialProviderBindings( messaging.messagingTokenDefs, input.requiredBindings, @@ -266,7 +266,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg runOpenshell, input.replaceExisting === true, ); - input.revalidatePolicyRequirements?.("clear staged credential provider receipts"); + input.verifyLivePolicyRequirements?.("clear staged credential provider receipts"); setStagedCredentialProviderReceipts( tokenDefs.map((tokenDef) => tokenDef.name), false, @@ -277,11 +277,11 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg { replaceExisting: input.replaceExisting === true, allowedSandboxes: input.replaceExisting === true ? [input.sandboxName] : undefined, - revalidatePolicyRequirements: input.revalidatePolicyRequirements, + verifyLivePolicyRequirements: input.verifyLivePolicyRequirements, }, runOpenshell, ); - input.revalidatePolicyRequirements?.("record staged credential provider receipts"); + input.verifyLivePolicyRequirements?.("record staged credential provider receipts"); setStagedCredentialProviderReceipts(registered, true, deps); return registered.map((name) => { const binding = plannedBindings.get(name); diff --git a/src/lib/onboard/dashboard-forward-control.test.ts b/src/lib/onboard/dashboard-forward-control.test.ts index 83b839c4d85..5d32ec8cc49 100644 --- a/src/lib/onboard/dashboard-forward-control.test.ts +++ b/src/lib/onboard/dashboard-forward-control.test.ts @@ -23,22 +23,22 @@ describe("createSandboxForwardStopper", () => { expect(runOpenshell).not.toHaveBeenCalled(); }); - it("rechecks policy authority after the forward read and before stop (#9833)", () => { + it("rechecks policy requirements after the forward read and before stop (#9833)", () => { const runOpenshell = vi.fn(); const runCaptureOpenshell = vi.fn().mockReturnValue(""); - const revalidatePolicyAuthority = vi.fn(() => { - throw new Error("policy authority changed"); + const verifyLivePolicyRequirements = vi.fn(() => { + throw new Error("policy requirements changed"); }); const stopForward = createSandboxForwardStopper({ runOpenshell, runCaptureOpenshell, sandboxName: "my-sandbox", - revalidatePolicyAuthority, + verifyLivePolicyRequirements, }); - expect(() => stopForward(18789)).toThrow("policy authority changed"); + expect(() => stopForward(18789)).toThrow("policy requirements changed"); expect(runCaptureOpenshell).toHaveBeenCalledOnce(); - expect(revalidatePolicyAuthority).toHaveBeenCalledOnce(); + expect(verifyLivePolicyRequirements).toHaveBeenCalledOnce(); expect(runOpenshell).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/onboard/dashboard-forward-control.ts b/src/lib/onboard/dashboard-forward-control.ts index 8d912a08486..0b36482b982 100644 --- a/src/lib/onboard/dashboard-forward-control.ts +++ b/src/lib/onboard/dashboard-forward-control.ts @@ -8,7 +8,7 @@ export interface DashboardForwardOptions { gatewayName?: string; preserveSandboxPorts?: Array; allowPortReallocation?: boolean; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; onForwardStarted?: (port: number) => void; } @@ -28,7 +28,7 @@ export function createSandboxForwardStopper(deps: { runOpenshell: Parameters[0]; runCaptureOpenshell: (args: string[], opts?: Record) => string | null; sandboxName: string; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }): (port: string | number) => ReturnType | null { const stoppedPorts = new Set(); return (port: string | number) => { @@ -40,7 +40,7 @@ export function createSandboxForwardStopper(deps: { port, deps.sandboxName, () => - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `stop dashboard forward ${String(port)} for sandbox '${deps.sandboxName}'`, ), ); diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index a3ad884771b..03a0fb9c144 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; -import { isPolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; +import { isPolicyObservationError } from "../adapters/openshell/policy-state"; import type { AgentDefinition } from "../agent/defs"; import { getInteractiveAgentCommand } from "../agent/gateway-restart-scripts"; import { DASHBOARD_PORT } from "../core/ports"; @@ -124,17 +124,17 @@ export interface OnboardDashboardHelpers { agent: { forwardPort?: number | null; forward_ports?: number[] | null }, options?: { beforeForwardPort?: (port: number) => Promise | void; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }, ): Promise; ensureFinalizationDashboardForward( sandboxName: string, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): number; ensureFinalizationAgentDashboardForward( sandboxName: string, agent: { name: string; forwardPort?: number | null; forward_ports?: number[] | null } | null, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, portReservation?: { releaseBeforeForward(agentName: string, port: number): Promise | void; }, @@ -143,7 +143,7 @@ export interface OnboardDashboardHelpers { sandboxName: string, port: number, label: string, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): boolean; fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null; fetchAgentWebAuthTokenFromSandbox(sandboxName: string, agent: AgentDefinition): string | null; @@ -335,7 +335,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa chatUiUrl ||= `http://127.0.0.1:${CONTROL_UI_PORT}`; const { rollbackSandboxOnFailure, preservedPorts, allowPortReallocation } = normalizeDashboardForwardOptions(options); - const { revalidatePolicyAuthority } = options; + const { verifyLivePolicyRequirements } = options; const messagingForward = resolveMessagingHostForwardForSandbox(sandboxName); if (messagingForward) preservedPorts.add(String(messagingForward.port)); const preferredPort = Number(getDashboardForwardPort(chatUiUrl)); @@ -344,7 +344,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa runOpenshell: deps.runOpenshell, runCaptureOpenshell: deps.runCaptureOpenshell, sandboxName, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, }); const stopForwardForSandbox = makeStopForwardForSandbox(); let existingForwards = deps.runCaptureOpenshell(["forward", "list"], { ignoreError: true }); @@ -418,7 +418,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa ); const { ok: fwdOk, diagnostic: fwdDiagnostic } = runDetachedForwardStartWithRetries( (stdio) => { - revalidatePolicyAuthority?.( + verifyLivePolicyRequirements?.( `start dashboard forward ${String(actualPort)} for sandbox '${sandboxName}'`, ); return startDashboardForward(stdio); @@ -466,7 +466,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa ensureMessagingHostForwardForSandbox({ sandboxName, ensureForward: (name, port, label) => - ensureFixedAgentForward(deps, name, port, label, revalidatePolicyAuthority), + ensureFixedAgentForward(deps, name, port, label, verifyLivePolicyRequirements), note: deps.note, rollbackOnFailure: { runOpenshell: deps.runOpenshell, @@ -474,7 +474,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa buildOrphanedSandboxRollbackMessage(name, error, options.gatewayName), cliName: deps.cliName, forwardPortsToStop: [actualPort], - beforeMutation: revalidatePolicyAuthority, + beforeMutation: verifyLivePolicyRequirements, }, }); } @@ -496,7 +496,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa */ function ensureFinalizationDashboardForward( sandboxName: string, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): number { const envUrl = process.env.CHAT_UI_URL; const persistedPort = envUrl @@ -508,19 +508,19 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa try { const actualPort = ensureDashboardForward(sandboxName, requestedUrl, { ...(persistedPort === null ? {} : { allowPortReallocation: false }), - ...(revalidatePolicyAuthority ? { revalidatePolicyAuthority } : {}), + ...(verifyLivePolicyRequirements ? { verifyLivePolicyRequirements } : {}), onForwardStarted: (port) => { startedPort = port; }, }); - revalidatePolicyAuthority?.(`publish the dashboard URL for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`publish the dashboard URL for sandbox '${sandboxName}'`); process.env.CHAT_UI_URL = replaceUrlPort( requestedUrl || `http://127.0.0.1:${String(actualPort)}`, actualPort, ); return actualPort; } catch (error) { - if (isPolicyAuthorityRefusalError(error) && startedPort !== null) { + if (isPolicyObservationError(error) && startedPort !== null) { try { bestEffortForwardStopForSandbox( deps.runOpenshell, @@ -541,7 +541,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa agent: { forwardPort?: number | null; forward_ports?: number[] | null }, options: { beforeForwardPort?: (port: number) => Promise | void; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; } = {}, ): Promise { const chatUiUrl = process.env.CHAT_UI_URL; @@ -552,7 +552,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa chatUiUrl, controlUiPort: chatUiUrl ? Number(getDashboardForwardPort(chatUiUrl)) : undefined, beforeForwardPort: options.beforeForwardPort, - revalidatePolicyAuthority: options.revalidatePolicyAuthority, + verifyLivePolicyRequirements: options.verifyLivePolicyRequirements, compensateDashboardForward: (port) => { bestEffortForwardStopForSandbox( deps.runOpenshell, @@ -567,28 +567,28 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa function ensureFinalizationAgentDashboardForward( sandboxName: string, agent: { name: string; forwardPort?: number | null; forward_ports?: number[] | null } | null, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, portReservation?: { releaseBeforeForward(agentName: string, port: number): Promise | void; }, ): Promise | number { return agent ? ensureAgentDashboardForward(sandboxName, agent, { - revalidatePolicyAuthority, + verifyLivePolicyRequirements, beforeForwardPort: portReservation ? (port) => portReservation.releaseBeforeForward(agent.name, port) : undefined, }) - : ensureFinalizationDashboardForward(sandboxName, revalidatePolicyAuthority); + : ensureFinalizationDashboardForward(sandboxName, verifyLivePolicyRequirements); } function ensureAgentFixedForward( sandboxName: string, port: number, label: string, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): boolean { - return ensureFixedAgentForward(deps, sandboxName, port, label, revalidatePolicyAuthority); + return ensureFixedAgentForward(deps, sandboxName, port, label, verifyLivePolicyRequirements); } /** diff --git a/src/lib/onboard/docker-gpu-local-inference.test.ts b/src/lib/onboard/docker-gpu-local-inference.test.ts index c3d1d52e0ef..717ebee0be3 100644 --- a/src/lib/onboard/docker-gpu-local-inference.test.ts +++ b/src/lib/onboard/docker-gpu-local-inference.test.ts @@ -450,7 +450,7 @@ describe("verifyGpuSandboxLocalInferenceAndCommitAfterReady", () => { expect(runtimePatch.rollbackManagedStartupAfterCreateFailure).not.toHaveBeenCalled(); }); - it("rechecks policy authority after verification and before GPU commit (#9833)", async () => { + it("rechecks policy requirements after verification and before GPU commit (#9833)", async () => { const runtimePatch = { commitAfterReady: vi.fn(), rollbackManagedStartupAfterCreateFailure: vi.fn(), @@ -468,10 +468,10 @@ describe("verifyGpuSandboxLocalInferenceAndCommitAfterReady", () => { }, runtimePatch, () => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(runtimePatch.commitAfterReady).not.toHaveBeenCalled(); expect(runtimePatch.rollbackManagedStartupAfterCreateFailure).toHaveBeenCalledOnce(); diff --git a/src/lib/onboard/exit-step-failure.test.ts b/src/lib/onboard/exit-step-failure.test.ts index 30225b90dbd..1c267781144 100644 --- a/src/lib/onboard/exit-step-failure.test.ts +++ b/src/lib/onboard/exit-step-failure.test.ts @@ -259,9 +259,7 @@ describe("incomplete-onboard --resume backstop (#6003)", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, createAttemptNonce: "c".repeat(62), - policyCreationReceipt: null, }); const beforeExit = requireLoadedSession(); diff --git a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts index 36db676264b..6a38e174c6b 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** Exact shipped files admitted by the schema-5 Hermes Dockerfile COPY contract. */ +/** Exact shipped files admitted by the schema-7 Hermes Dockerfile COPY contract. */ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "agents/hermes/build-mcp-digest.py", mode: "100644" }, { path: "agents/hermes/config/build-env.ts", mode: "100644" }, diff --git a/src/lib/onboard/experimental/hermes-portable-container.test.ts b/src/lib/onboard/experimental/hermes-portable-container.test.ts index c53db0ce93a..8f5f912610d 100644 --- a/src/lib/onboard/experimental/hermes-portable-container.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-container.test.ts @@ -77,7 +77,7 @@ describe("Hermes portable Podman environment", () => { function receipt(): HermesPortablePendingReceipt { const uid = process.getuid!(); return { - schemaVersion: 5, + schemaVersion: 7, agent: "hermes", phase: "pending", transactionId: randomUUID(), @@ -133,11 +133,12 @@ function inspect( } function activeReceipt(running = true): HermesPortableConfiguredReceipt { + const pending = receipt(); + const { policy: _policy, ...transaction } = pending; return { - ...receipt(), + ...transaction, phase: "active", previousPhaseSha256: "c".repeat(64), - verifiedLivePolicySemanticSha256: "d".repeat(64), startup: { health: { successStatus: 200 } } as never, container: { containerId: ID, @@ -210,6 +211,7 @@ describe("Hermes portable container authority", () => { it("updates one exact full ID and verifies running restart authority (#9203)", () => { const pending = receipt(); + const { policy: _policy, ...transaction } = pending; const container = { containerId: ID, sandboxId: SANDBOX_ID, @@ -220,10 +222,9 @@ describe("Hermes portable container authority", () => { restartPolicy: "no", }; const configuring = { - ...pending, + ...transaction, phase: "configuring" as const, previousPhaseSha256: "c".repeat(64), - verifiedLivePolicySemanticSha256: "d".repeat(64), container, }; const podman = vi @@ -248,11 +249,11 @@ describe("Hermes portable container authority", () => { it("preserves configuring authority when update outcome is ambiguous (#9203)", () => { const pending = receipt(); + const { policy: _policy, ...transaction } = pending; const configuring = { - ...pending, + ...transaction, phase: "configuring" as const, previousPhaseSha256: "c".repeat(64), - verifiedLivePolicySemanticSha256: "d".repeat(64), container: { ...enrollHermesPortableContainer(pending, SANDBOX_ID, { podman: vi diff --git a/src/lib/onboard/experimental/hermes-portable-container.ts b/src/lib/onboard/experimental/hermes-portable-container.ts index 973a960ba74..17a8b01e3ce 100644 --- a/src/lib/onboard/experimental/hermes-portable-container.ts +++ b/src/lib/onboard/experimental/hermes-portable-container.ts @@ -104,7 +104,7 @@ export interface HermesPortableContainerDeps { export type HermesPortableContainerStartResult = "already-running" | "started"; export type HermesPortableContainerStopResult = "already-stopped" | "stopped"; -/** Bind schema-5 Podman to the receipt-owned current-user namespace. */ +/** Bind schema-7 Podman to the receipt-owned current-user namespace. */ export function buildHermesPortablePodmanEnvironment( runtimeAuthority: CheckpointPortableRuntimeAuthority, sourceEnv: NodeJS.ProcessEnv = process.env, diff --git a/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts b/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts index 43e87a92a0c..622ddc16dce 100644 --- a/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts @@ -27,10 +27,6 @@ import { stopHermesPortableSandboxLifecycle, type HermesPortableLifecycleDeps, } from "./hermes-portable-lifecycle"; -import { - hermesPortableCreatePolicySemanticDigest, - resolveHermesPortableExpectedPolicyBytes, -} from "./hermes-portable-policy-authority"; import { captureHermesPortablePolicySource, publishHermesPortableDurablePolicySource, @@ -175,17 +171,15 @@ function activeReceipt(homeDir = "/home/test"): HermesPortableConfiguredReceipt const uid = process.getuid!(); const socketPath = `/run/user/${String(uid)}/podman/podman.sock`; const transactionId = randomUUID(); - const policyBytes = fs.readFileSync(policyPath); const policy = publishHermesPortableDurablePolicySource({ sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: hermesPortableCreatePolicySemanticDigest(policyBytes), source: captureHermesPortablePolicySource(policyPath), hooks: { assertLifecycleLock: () => undefined }, }); const pending: HermesPortablePendingReceipt = { - schemaVersion: 5, + schemaVersion: 7, agent: "hermes", phase: "pending", transactionId, @@ -229,11 +223,11 @@ function activeReceipt(homeDir = "/home/test"): HermesPortableConfiguredReceipt const first = publishHermesPortableLifecycleReceipt(pending, stateDir, { assertLifecycleLock: () => undefined, }); + const { policy: _policy, ...transaction } = pending; const configuring: HermesPortableConfiguredReceipt = { - ...pending, + ...transaction, phase: "configuring", previousPhaseSha256: first.sha256, - verifiedLivePolicySemanticSha256: policy.intendedSemanticSha256, container: { containerId: CONTAINER_ID, sandboxId: SANDBOX_ID, @@ -400,51 +394,7 @@ afterEach(() => { }); describe("Hermes portable lifecycle", () => { - it("migrates an identical same-path schema-5 copy only under both probe fences (#10423)", async () => { - const receipt = activeReceipt(stateDir); - const copiedPolicy = `${receipt.policy.sourcePath}.copy`; - fs.writeFileSync(copiedPolicy, fs.readFileSync(receipt.policy.sourcePath), { mode: 0o600 }); - fs.renameSync(copiedPolicy, receipt.policy.sourcePath); - const fixture = lifecycleDeps(receipt); - - expect(() => - withMcpLifecycleLockSync( - SANDBOX, - () => recoverHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), fixture.deps), - { stateDir: path.join(stateDir, "state") }, - ), - ).toThrow("durable policy source disagrees with its receipt authority"); - - expect(() => - withMcpLifecycleLockSync( - SANDBOX, - () => requalifyHermesPortableSandboxAuthority(SANDBOX, lifecycleContext(), fixture.deps), - { stateDir: path.join(stateDir, "state") }, - ), - ).toThrow("Portable host authority mutation requires the current HOME fence"); - - const migrated = await withPortableHostFence(stateDir, () => - withMcpLifecycleLockSync( - SANDBOX, - () => requalifyHermesPortableSandboxAuthority(SANDBOX, lifecycleContext(), fixture.deps), - { stateDir: path.join(stateDir, "state") }, - ), - ); - - expect(migrated.kind).toBe("migrated"); - expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)?.successor).toBeDefined(); - expect( - fixture.podman.mock.calls.some(([args]) => args[1] === "start" || args[1] === "stop"), - ).toBe(false); - const removal = withMcpLifecycleLockSync( - SANDBOX, - () => prepareHermesPortableSandboxRemoval(SANDBOX, lifecycleContext(), fixture.deps), - { stateDir: path.join(stateDir, "state") }, - ); - expect(removal.receipt.socketAuthority.inode).toBe("102"); - }); - - it("reconciles an interrupted schema-6 publication inside both probe fences (#10423)", async () => { + it("reconciles an interrupted schema-8 publication inside both probe fences (#10423)", async () => { const receipt = activeReceipt(stateDir); expect(() => withMcpLifecycleLockSync( @@ -452,12 +402,12 @@ describe("Hermes portable lifecycle", () => { () => publishHermesPortableSuccessorReceipt(SANDBOX, stateDir, { afterCanonicalLink: () => { - throw new Error("simulated schema-6 process exit"); + throw new Error("simulated schema-8 process exit"); }, }), { stateDir: path.join(stateDir, "state") }, ), - ).toThrow("simulated schema-6 process exit"); + ).toThrow("simulated schema-8 process exit"); const fixture = lifecycleDeps(receipt); const recovered = await withPortableHostFence(stateDir, () => @@ -484,10 +434,10 @@ describe("Hermes portable lifecycle", () => { expect(() => publishHermesPortableSuccessorReceipt(SANDBOX, stateDir, { afterCanonicalLink: () => { - throw new Error("simulated schema-6 process exit"); + throw new Error("simulated schema-8 process exit"); }, }), - ).toThrow("simulated schema-6 process exit"); + ).toThrow("simulated schema-8 process exit"); expect(() => hermesPortableLifecycleInternals.qualify( @@ -505,176 +455,7 @@ describe("Hermes portable lifecycle", () => { ); }); - it("rejects policy generation replacement during schema-6 publication (#10423)", async () => { - const receipt = activeReceipt(stateDir); - const copiedPolicy = `${receipt.policy.sourcePath}.copy`; - fs.writeFileSync(copiedPolicy, fs.readFileSync(receipt.policy.sourcePath), { mode: 0o600 }); - fs.renameSync(copiedPolicy, receipt.policy.sourcePath); - const fixture = lifecycleDeps(receipt); - const publishWithReplacement: typeof publishHermesPortableSuccessorReceipt = ( - sandboxName, - receiptStateDir, - hooks, - authority, - ) => - publishHermesPortableSuccessorReceipt( - sandboxName, - receiptStateDir, - { - ...hooks, - afterStageWrite: (written, total) => { - const replacement = `${receipt.policy.sourcePath}.during-publication`; - fs.writeFileSync(replacement, fs.readFileSync(receipt.policy.sourcePath), { - mode: 0o600, - }); - fs.renameSync(replacement, receipt.policy.sourcePath); - hooks?.afterStageWrite?.(written, total); - }, - }, - authority, - ); - - await expect( - withPortableHostFence(stateDir, () => - withMcpLifecycleLockSync( - SANDBOX, - () => - requalifyHermesPortableSandboxAuthority(SANDBOX, lifecycleContext(), { - ...fixture.deps, - publishSuccessorReceipt: publishWithReplacement, - }), - { stateDir: path.join(stateDir, "state") }, - ), - ), - ).rejects.toThrow("operation-local filesystem or runtime identity changed"); - }); - - it.each(["socket", "openshell", "podman"] as const)( - "rejects %s identity generation replacement during schema-6 publication (#10423)", - async (owner) => { - const receipt = activeReceipt(stateDir); - let replaceIdentity = false; - const fixture = lifecycleDeps(receipt); - const operatingAuthority = { - ...fixture.deps.operatingAuthority, - captureSocketAuthority: () => ({ - ...receipt.socketAuthority, - inode: owner === "socket" && replaceIdentity ? "103" : "102", - }), - captureOpenShellExecutableAuthority: () => ({ - ...receipt.openshellExecutableAuthority, - executable: { - ...receipt.openshellExecutableAuthority.executable, - inode: owner === "openshell" && replaceIdentity ? "11" : "10", - }, - }), - capturePodmanExecutableAuthority: () => ({ - ...receipt.podmanExecutableAuthority, - executable: { - ...receipt.podmanExecutableAuthority.executable, - inode: owner === "podman" && replaceIdentity ? "31" : "30", - }, - }), - }; - const publishWithReplacement: typeof publishHermesPortableSuccessorReceipt = ( - sandboxName, - receiptStateDir, - hooks, - authority, - ) => - publishHermesPortableSuccessorReceipt( - sandboxName, - receiptStateDir, - { - ...hooks, - afterStageWrite: (written, total) => { - replaceIdentity = true; - hooks?.afterStageWrite?.(written, total); - }, - }, - authority, - ); - - await expect( - withPortableHostFence(stateDir, () => - withMcpLifecycleLockSync( - SANDBOX, - () => - requalifyHermesPortableSandboxAuthority(SANDBOX, lifecycleContext(), { - ...fixture.deps, - operatingAuthority, - publishSuccessorReceipt: publishWithReplacement, - }), - { stateDir: path.join(stateDir, "state") }, - ), - ), - ).rejects.toThrow("operation-local filesystem or runtime identity changed"); - }, - ); - - it("passes only private state, terminal, locale, and TLS variables to child commands (#9203)", () => { - const runtimeAuthority = { - schemaVersion: 1 as const, - kind: "podman" as const, - ownership: "current-user" as const, - uid: process.getuid!(), - homeDir: "/home/test", - configHome: "/home/test/.config", - runtimeDir: "/run/user/1000", - socketPath: "/run/user/1000/podman/podman.sock", - }; - const sourceEnv = { - HOME: "/home/test", - PATH: "/usr/bin", - TERM: "xterm-256color", - LANG: "C.UTF-8", - XDG_CONFIG_HOME: "/home/test/.config", - XDG_RUNTIME_DIR: "/run/user/1000", - XDG_CACHE_HOME: "/tmp/ambient-cache", - HTTPS_PROXY: "http://127.0.0.1:8118", - SSL_CERT_FILE: "/etc/ssl/cert.pem", - DOCKER_HOST: "unix:///run/docker.sock", - KUBECONFIG: "/home/test/.kube/config", - SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock", - OPENSHELL_GATEWAY: "ambient", - OPENSHELL_GATEWAY_ENDPOINT: "https://ambient.example", - NVIDIA_INFERENCE_API_KEY: "do-not-forward", - GITHUB_TOKEN: "do-not-forward", - AWS_SECRET_ACCESS_KEY: "do-not-forward", - }; - const env = hermesPortableLifecycleInternals.buildHermesPortableOpenShellEnv( - sourceEnv, - runtimeAuthority, - ); - - expect(env).toMatchObject({ - HOME: "/home/test", - PATH: "/usr/bin", - TERM: "xterm-256color", - LANG: "C.UTF-8", - XDG_CONFIG_HOME: "/home/test/.config", - XDG_RUNTIME_DIR: "/run/user/1000", - SSL_CERT_FILE: "/etc/ssl/cert.pem", - }); - expect(env).not.toHaveProperty("HTTPS_PROXY"); - expect(env).not.toHaveProperty("XDG_CACHE_HOME"); - expect(env).not.toHaveProperty("DOCKER_HOST"); - expect(env).not.toHaveProperty("KUBECONFIG"); - expect(env).not.toHaveProperty("SSH_AUTH_SOCK"); - expect(env).not.toHaveProperty("OPENSHELL_GATEWAY"); - expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); - expect(env).not.toHaveProperty("NVIDIA_INFERENCE_API_KEY"); - expect(env).not.toHaveProperty("GITHUB_TOKEN"); - expect(env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY"); - expect(() => - hermesPortableLifecycleInternals.buildHermesPortableOpenShellEnv( - { ...sourceEnv, XDG_CONFIG_HOME: "/tmp/other-config" }, - runtimeAuthority, - ), - ).toThrow("XDG_CONFIG_HOME disagrees with runtime authority"); - }); - - it("constructs production Podman dependencies from the receipt authority (#9203)", () => { + it("constructs production Podman dependencies from the receipt identity (#9203)", () => { const receipt = activeReceipt(); const capture = vi.fn( (_executable, args, _timeoutMs, _input, environment) => { @@ -1078,18 +859,10 @@ describe("Hermes portable lifecycle", () => { expect(podman.mock.calls.filter(([args]) => args[1] === "stop")).toEqual([]); }); - it("recovers against the finalized Personal policy authority (#9211)", () => { + it("recovers against the current live OpenShell policy (#9211)", () => { const receipt = activeReceipt(); - const registry = { - policyTier: "personal", - policies: ["personal-open-internet"], - policyPresetsFinalized: true, - } satisfies Partial; - const livePolicy = resolveHermesPortableExpectedPolicyBytes(Buffer.from(POLICY), { - name: SANDBOX, - agent: "hermes", - ...registry, - } as SandboxEntry).bytes.toString("utf8"); + const registry = {} satisfies Partial; + const livePolicy = POLICY; const { deps, podman } = lifecycleDeps(receipt, false, { livePolicy, registry }); const result = withMcpLifecycleLockSync( @@ -1129,32 +902,26 @@ describe("Hermes portable lifecycle", () => { ); }); - it("rejects Personal policy without finalized registry authority (#9211)", () => { + it("accepts a valid host-edited policy without a finalized policy receipt (#9211)", () => { const receipt = activeReceipt(); const finalized = { name: SANDBOX, agent: "hermes", - policyTier: "personal", - policies: ["personal-open-internet"], - policyPresetsFinalized: true, } as SandboxEntry; - const livePolicy = resolveHermesPortableExpectedPolicyBytes( - Buffer.from(POLICY), - finalized, - ).bytes.toString("utf8"); + const livePolicy = POLICY; const { deps, podman } = lifecycleDeps(receipt, false, { livePolicy, - registry: { ...finalized, policyPresetsFinalized: undefined }, + registry: { ...finalized }, }); - expect(() => + expect( withMcpLifecycleLockSync( SANDBOX, () => recoverHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), deps), { stateDir: path.join(stateDir, "state") }, ), - ).toThrow("base policy disagrees with create input"); - expect(podman).not.toHaveBeenCalled(); + ).toEqual({ kind: "recovered" }); + expect(podman).toHaveBeenCalled(); }); it("rejects an ambient OpenShell endpoint before Podman or OpenShell effects (#9203)", () => { diff --git a/src/lib/onboard/experimental/hermes-portable-lifecycle.ts b/src/lib/onboard/experimental/hermes-portable-lifecycle.ts index d1aa6770409..86c559e0142 100644 --- a/src/lib/onboard/experimental/hermes-portable-lifecycle.ts +++ b/src/lib/onboard/experimental/hermes-portable-lifecycle.ts @@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process"; import path from "node:path"; -import { isDeepStrictEqual, TextDecoder } from "node:util"; +import { TextDecoder } from "node:util"; import { fingerprintOpenShellSandboxId, @@ -48,12 +48,12 @@ import { assertCurrentHermesPortableStoredStartupContract } from "./hermes-porta import { proveHermesPortableLivePolicy, type HermesPortablePolicyCaptureResult, -} from "./hermes-portable-policy-authority"; +} from "./hermes-portable-policy-state"; import { - assertHermesPortableDurablePolicyAuthority, + publishHermesPortableSuccessorReceipt, readHermesPortableLifecycleReceipt, readHermesPortableLifecycleReceiptForRequalification, - publishHermesPortableSuccessorReceipt, + retireHermesPortableCreatePolicyState, type HermesPortableConfiguredReceipt, type HermesPortableLifecycleReceipt, type HermesPortableReceiptSnapshot, @@ -103,6 +103,7 @@ export interface HermesPortableLifecycleDeps { readonly podmanAuthorityDeps?: HermesPortablePodmanAuthorityDeps; readonly operatingAuthority?: HermesPortableOperatingAuthorityDeps; readonly publishSuccessorReceipt?: typeof publishHermesPortableSuccessorReceipt; + readonly retireCreatePolicyState?: typeof retireHermesPortableCreatePolicyState; readonly now?: () => number; readonly sleep?: (milliseconds: number) => void; readonly log?: (message: string) => void; @@ -250,7 +251,6 @@ function sameSnapshot( left.identity.ino === right.identity.ino && left.sha256 === right.sha256 && left.bytes.equals(right.bytes) && - isDeepStrictEqual(left.receipt.policy, right.receipt.policy) && left.successorPublicationPending === right.successorPublicationPending && left.successor?.path === right.successor?.path && left.successor?.identity.dev === right.successor?.identity.dev && @@ -383,7 +383,6 @@ function qualify( const receipt = operatingAuthority.receipt; if (!contextMatches(receipt, context)) fail("registry context disagrees with the active receipt"); assertCurrentHermesPortableStoredStartupContract(receipt.startup, sandboxName); - const durablePolicy = assertHermesPortableDurablePolicyAuthority(receipt.policy); const assertExecutable = deps.assertOpenShellExecutableAuthority ?? assertHermesPortableOpenShellExecutableAuthority; const initialCommandAuthority = buildHermesPortableOpenShellCommandAuthority( @@ -406,21 +405,12 @@ function qualify( return rawCapture(args, timeoutMs); }; const liveIdentity = observeOpenShellIdentity(receipt, capture, acceptedPhases); - const registryEntry = requireRegistry(receipt, liveIdentity.liveIdentityFingerprint, deps); - const policy = proveHermesPortableLivePolicy({ + requireRegistry(receipt, liveIdentity.liveIdentityFingerprint, deps); + proveHermesPortableLivePolicy({ gatewayName: receipt.gatewayName, sandboxName, - createPolicyBytes: durablePolicy, - finalizedRegistryEntry: registryEntry, capture: policyCapture(capture), }); - if ( - policy.expectedPolicySource === "create" && - (policy.intendedSemanticSha256 !== receipt.policy.intendedSemanticSha256 || - policy.verifiedLivePolicySemanticSha256 !== receipt.verifiedLivePolicySemanticSha256) - ) { - fail("live policy authority disagrees with the active receipt"); - } const baseContainerDeps = typeof deps.container === "function" ? deps.container(receipt) @@ -450,7 +440,7 @@ export type HermesPortableAuthorityRequalificationResult = | { readonly kind: "already-current"; readonly snapshot: HermesPortableReceiptSnapshot } | { readonly kind: "migrated"; readonly snapshot: HermesPortableReceiptSnapshot }; -/** Publish schema 6 only after the exact schema-5 authority passes the probe fence. */ +/** Publish policy-free authority and retire create-policy history after the probe fence. */ export function requalifyHermesPortableSandboxAuthority( sandboxName: string, context: PortableDemoLifecycleContext, @@ -479,7 +469,11 @@ export function requalifyHermesPortableSandboxAuthority( qualified.assertOperatingAuthority(); qualify(sandboxName, context, deps, published, ["Ready", "Error", "Stopped"]); qualified.assertOperatingAuthority(); - return { kind: snapshot.successor ? "already-current" : "migrated", snapshot: published }; + const retireCreatePolicyState = + deps.retireCreatePolicyState ?? retireHermesPortableCreatePolicyState; + const compacted = retireCreatePolicyState(sandboxName, published.receipt.transactionId, stateDir); + qualified.assertOperatingAuthority(); + return { kind: snapshot.successor ? "already-current" : "migrated", snapshot: compacted }; } function openshellExecArgs(receipt: HermesPortableConfiguredReceipt, command: readonly string[]) { @@ -806,7 +800,6 @@ export function prepareHermesPortableSandboxRemoval( if (!snapshot || !sameSnapshot(snapshot, expectedSnapshot)) fail("receipt authority changed"); operatingAuthority.assertCurrent(); assertCurrentHermesPortableStoredStartupContract(receipt.startup, sandboxName); - assertHermesPortableDurablePolicyAuthority(receipt.policy); requireStaticRegistry(receipt, deps); const assertExecutable = deps.assertOpenShellExecutableAuthority ?? assertHermesPortableOpenShellExecutableAuthority; diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts b/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts index 1d8edf57592..f5c1aafc988 100644 --- a/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts +++ b/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts @@ -1017,7 +1017,7 @@ export interface PreparedHermesPortableOllamaProviderRetirement { readonly verifyAbsent: () => void; } -/** Bind and retire only the exact committed schema-5 gateway provider authority. */ +/** Bind and retire only the exact committed schema-7 gateway provider authority. */ export function prepareHermesPortableOllamaProviderRetirement(options: { readonly directory: string; readonly transactionId: string; diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts b/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts index 51f2b69db86..8289af1eca4 100644 --- a/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts +++ b/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts @@ -193,7 +193,7 @@ function prepareHermesPortableOllamaRegistryRecovery(options: { } } -/** Reconstruct the exact schema-5 Podman inference owner without acquiring images. */ +/** Reconstruct the exact schema-7 Podman inference owner without acquiring images. */ export function createHermesPortableOllamaRuntimeAuthority(options: { readonly receipt: HermesPortableConfiguredReceipt; readonly publishedRecovery?: { diff --git a/src/lib/onboard/experimental/hermes-portable-onboarding-authority-recovery.test.ts b/src/lib/onboard/experimental/hermes-portable-onboarding-authority-recovery.test.ts deleted file mode 100644 index f8f6c1b9bfb..00000000000 --- a/src/lib/onboard/experimental/hermes-portable-onboarding-authority-recovery.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// 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, beforeEach, describe, expect, it } from "vitest"; - -import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; -import { - createHermesPortableTestInput, - createHermesPortableTransactionFixture, - HERMES_PORTABLE_TEST_POLICY as POLICY, -} from "../../../../test/helpers/hermes-portable-onboarding-fixture"; -import { runHermesPortableOnboardingTransaction } from "./hermes-portable-onboarding"; -import { - hermesPortableReceiptDirectory, - publishHermesPortableSuccessorReceipt, -} from "./hermes-portable-receipt"; - -const SANDBOX = "alpha"; -let stateDir: string; -let policyPath: string; - -function input() { - return createHermesPortableTestInput(stateDir, policyPath); -} - -beforeEach(() => { - stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-authority-recovery-")); - policyPath = path.join(stateDir, "create.yaml"); - fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); -}); - -afterEach(() => fs.rmSync(stateDir, { recursive: true, force: true })); - -describe("Hermes portable onboarding authority recovery", () => { - it("leaves a pre-existing active schema-5 receipt for probe-only migration (#10423)", async () => { - const fixture = createHermesPortableTransactionFixture(input()); - await runHermesPortableOnboardingTransaction(input(), fixture.value); - const authorityPath = path.join( - hermesPortableReceiptDirectory(SANDBOX, stateDir), - "authority.json", - ); - fs.unlinkSync(authorityPath); - fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); - - const resumed = await runHermesPortableOnboardingTransaction(input(), fixture.value); - - expect(resumed.active.successor).toBeUndefined(); - expect(fs.existsSync(authorityPath)).toBe(false); - }); - - it("finishes only an interrupted schema-6 publication during active resume (#10423)", async () => { - const fixture = createHermesPortableTransactionFixture(input()); - await runHermesPortableOnboardingTransaction(input(), fixture.value); - const authorityPath = path.join( - hermesPortableReceiptDirectory(SANDBOX, stateDir), - "authority.json", - ); - fs.unlinkSync(authorityPath); - await withMcpLifecycleLock( - SANDBOX, - async () => { - expect(() => - publishHermesPortableSuccessorReceipt(SANDBOX, stateDir, { - afterCanonicalLink: () => { - throw new Error("simulated schema-6 process exit"); - }, - }), - ).toThrow("simulated schema-6 process exit"); - }, - { stateDir: path.join(stateDir, "state") }, - ); - fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); - - const resumed = await runHermesPortableOnboardingTransaction(input(), fixture.value); - - expect(resumed.active.successor?.receipt.schemaVersion).toBe(6); - expect(fs.statSync(authorityPath).nlink).toBe(1); - }); -}); diff --git a/src/lib/onboard/experimental/hermes-portable-onboarding-policy-source.test.ts b/src/lib/onboard/experimental/hermes-portable-onboarding-policy-source.test.ts index ebfae377d09..bcbf4355e47 100644 --- a/src/lib/onboard/experimental/hermes-portable-onboarding-policy-source.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-onboarding-policy-source.test.ts @@ -17,20 +17,20 @@ import { hermesPortableReservationForOnboarding, } from "../../../../test/helpers/hermes-portable-onboarding-fixture"; import type { SandboxEntry } from "../../state/registry"; +import { pendingSandboxCreateIdentityForBoundary } from "../sandbox-create/identity-boundary"; import { - pendingSandboxPolicyVerificationForBoundary, - revalidateCreatedSandboxPolicyRegistration, - type CreatedSandboxPolicyRegistrationInput, -} from "../sandbox-create/policy-creation-receipt"; + verifyLiveCreatedSandboxPolicyRequirements, + type LiveCreatedSandboxPolicyRequirementsCheck, +} from "../sandbox-create/live-policy-requirements"; import { - runSandboxCreateWithPolicyAuthorityChecks, - verifyCreatedSandboxEffectivePolicy, - type EffectiveVerifiedSandboxPolicyBoundary, + runSandboxCreateWithPolicyVerification, + type EffectiveVerifiedSandboxCreateBoundary, } from "../sandbox-create/orchestration"; import { runHermesPortableOnboardCreate, runHermesPortableOnboardingTransaction, } from "./hermes-portable-onboarding"; +import { hermesPortableReceiptDirectory } from "./hermes-portable-receipt"; import type { SelectedDockerGpuRoute } from "../docker-gpu-route"; const GATEWAY_PORT = 8080; @@ -80,23 +80,7 @@ function checkpointFor( input: ReturnType, liveIdentityFingerprint = HERMES_PORTABLE_TEST_LIVE_IDENTITY, ) { - const policyCreationReceipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: input.gatewayName, - gatewayPort: GATEWAY_PORT, - sandboxName: input.sandboxName, - lifecycleGeneration: input.lifecycleGeneration, - sandboxIdentityFingerprint: liveIdentityFingerprint, - policyHash: "sha256:effective", - policyVersion: 4, - }; - return pendingSandboxPolicyVerificationForBoundary({ - registration: { - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt, - observedPolicyAuthority: "owner-unknown" as const, - }, + return pendingSandboxCreateIdentityForBoundary({ sandboxName: input.sandboxName, gatewayName: input.gatewayName, gatewayPort: GATEWAY_PORT, @@ -115,26 +99,20 @@ function checkpointEntry( gatewayPort: checkpoint.gatewayPort, lifecycleGeneration: checkpoint.lifecycleGeneration, lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - pendingPolicyVerification: checkpoint, + pendingCreateIdentity: checkpoint, }; } -function policyRegistrationInput(boundary: EffectiveVerifiedSandboxPolicyBoundary): Omit< - CreatedSandboxPolicyRegistrationInput, - "plannedAuthority" -> & { - readonly registration: EffectiveVerifiedSandboxPolicyBoundary["registration"]; -} { +function policyRequirementsInput( + boundary: EffectiveVerifiedSandboxCreateBoundary, +): LiveCreatedSandboxPolicyRequirementsCheck { return { sandboxName: boundary.sandboxName, gatewayName: boundary.gatewayName, gatewayPort: boundary.gatewayPort, - lifecycleGeneration: boundary.lifecycleGeneration, lifecycleLiveIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint, policySourcePath: boundary.policySourcePath, - route: boundary.route, operation: "continue composed Hermes Portable onboarding", - registration: boundary.registration, }; } @@ -149,7 +127,7 @@ afterEach(() => { }); describe("Hermes portable create policy source", () => { - it("carries the receipt-owned source through the generic create gate (#10423)", async () => { + it("carries the transaction-scoped source through the generic create gate (#10423)", async () => { fs.writeFileSync( policyPath, `version: 1 @@ -167,34 +145,35 @@ network_policies: lifecycleGeneration: LIFECYCLE_GENERATION, createPolicySourceBytes: Buffer.from(HERMES_PORTABLE_TEST_POLICY), }; - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce(readiness()) - .mockReturnValueOnce({ - status: 0, - output: HERMES_PORTABLE_TEST_POLICY, - stdout: HERMES_PORTABLE_TEST_POLICY, - stderr: "", - }) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce(metadata()); + vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell").mockImplementation((args) => { + const argv = Array.from(args, String); + switch (true) { + case argv[0] === "gateway" && argv[1] === "info": + return gatewayInfo(); + case argv[0] === "sandbox" && argv[1] === "list": + return readiness(); + case argv.includes("--output") && argv.includes("json"): + return metadata(); + case argv[0] === "policy" && argv[1] === "get": + return { + status: 0, + output: HERMES_PORTABLE_TEST_POLICY, + stdout: HERMES_PORTABLE_TEST_POLICY, + stderr: "", + }; + default: + throw new Error(`Unexpected OpenShell command: ${argv.join(" ")}`); + } + }); const readFile = vi.spyOn(fs, "readFileSync"); - let routeFallbackCalls = 0; let verifiedCreateEffectCalls = 0; let createSandboxCalls = 0; let recordedCheckpointEntry: SandboxEntry | null = null; let updateRegistry: (name: string, updates: Partial) => boolean; const persistedPolicySources: string[] = []; - const routeFallback = () => { - routeFallbackCalls += 1; - return policyPath; - }; - const persistVerifiedPolicy = (boundary: EffectiveVerifiedSandboxPolicyBoundary) => { + const persistCreateIdentity = (boundary: EffectiveVerifiedSandboxCreateBoundary) => { persistedPolicySources.push(boundary.policySourcePath); - const checkpoint = pendingSandboxPolicyVerificationForBoundary(boundary); + const checkpoint = pendingSandboxCreateIdentityForBoundary(boundary); recordedCheckpointEntry = checkpointEntry(current, checkpoint); const { name, ...updates } = recordedCheckpointEntry; expect(updateRegistry(name, updates)).toBe(true); @@ -211,9 +190,9 @@ network_policies: ) => { createSandboxCalls += 1; const readCountBeforeVerification = readFile.mock.calls.length; - const result = await runSandboxCreateWithPolicyAuthorityChecks< + const result = await runSandboxCreateWithPolicyVerification< CreatedPolicyIdentity, - EffectiveVerifiedSandboxPolicyBoundary, + EffectiveVerifiedSandboxCreateBoundary, { ready: true } >({ sandboxName: "alpha", @@ -227,27 +206,31 @@ network_policies: captureCreatedSandboxIdentity: () => HERMES_PORTABLE_TEST_LIVE_IDENTITY, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: (identity) => - verifyCreatedSandboxEffectivePolicy({ + verifyCreatedPolicyRequirements: (identity) => { + verifyLiveCreatedSandboxPolicyRequirements({ + sandboxName: "alpha", + gatewayName: "nemoclaw", + gatewayPort: GATEWAY_PORT, + lifecycleLiveIdentityFingerprint: HERMES_PORTABLE_TEST_LIVE_IDENTITY, + policySourcePath: effectivePolicySourcePath, + operation: "verify composed Hermes Portable policy", + }); + return { sandboxName: "alpha", gatewayName: "nemoclaw", gatewayPort: GATEWAY_PORT, lifecycleGeneration: current.lifecycleGeneration, lifecycleLiveIdentityFingerprint: HERMES_PORTABLE_TEST_LIVE_IDENTITY, route: identity.route, - hermesPortable: true, - effectivePolicySourcePath, - policySourcePathForRoute: routeFallback, - apfInterceptorRequested: false, - plannedAuthority: "nemoclaw-managed", - operation: "verify composed Hermes Portable policy", - }), - persistVerifiedPolicy: (_identity, _exactIdentity, boundary) => { - persistVerifiedPolicy(boundary); + policySourcePath: effectivePolicySourcePath, + }; }, - revalidateVerifiedPolicy: (_identity, _exactIdentity, boundary) => { + persistCreateIdentity: (_identity, _exactIdentity, boundary) => { + persistCreateIdentity(boundary); + }, + verifyCurrentPolicyRequirements: (_identity, _exactIdentity, boundary) => { expect(boundary.policySourcePath).toBe(effectivePolicySourcePath); - revalidateCreatedSandboxPolicyRegistration(policyRegistrationInput(boundary)); + verifyLiveCreatedSandboxPolicyRequirements(policyRequirementsInput(boundary)); }, runVerifiedCreateEffects, cleanupTemporarySources: vi.fn(), @@ -286,9 +269,12 @@ network_policies: expect(completed.active.receipt.phase).toBe("active"); expect(persistedPolicySources).toEqual([expect.stringMatching(/policy\..+\.yaml$/u)]); - expect(routeFallbackCalls).toBe(0); expect(verifiedCreateEffectCalls).toBe(1); expect(createSandboxCalls).toBe(1); + expect(fs.readdirSync(hermesPortableReceiptDirectory("alpha", stateDir)).sort()).toEqual([ + "active.json", + "authority.json", + ]); }); it("rejects a separately valid checkpoint replacement before configuration effects (#10423)", async () => { @@ -364,18 +350,13 @@ network_policies: }); it("rejects a compatibility result before policy persistence or effects (#10423)", async () => { - let persistVerifiedPolicyCalls = 0; + let persistCreateIdentityCalls = 0; let verifiedCreateEffectCalls = 0; - let routeFallbackCalls = 0; - const routeFallback = () => { - routeFallbackCalls += 1; - return policyPath; - }; await expect( - runSandboxCreateWithPolicyAuthorityChecks< + runSandboxCreateWithPolicyVerification< CreatedPolicyIdentity, - EffectiveVerifiedSandboxPolicyBoundary, + EffectiveVerifiedSandboxCreateBoundary, string >({ sandboxName: "alpha", @@ -387,33 +368,21 @@ network_policies: captureCreatedSandboxIdentity: () => HERMES_PORTABLE_TEST_LIVE_IDENTITY, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: (identity) => - verifyCreatedSandboxEffectivePolicy({ - sandboxName: "alpha", - gatewayName: "nemoclaw", - gatewayPort: GATEWAY_PORT, - lifecycleGeneration: "generation-1", - lifecycleLiveIdentityFingerprint: HERMES_PORTABLE_TEST_LIVE_IDENTITY, - route: identity.route, - hermesPortable: true, - effectivePolicySourcePath: "/durable.yaml", - policySourcePathForRoute: routeFallback, - apfInterceptorRequested: false, - plannedAuthority: "nemoclaw-managed", - operation: "verify incompatible Hermes Portable policy", - }), - persistVerifiedPolicy: () => { - persistVerifiedPolicyCalls += 1; + verifyCreatedPolicyRequirements: (identity) => { + expect(identity.route).toBe("compatibility"); + throw new Error("Hermes portable create selected an unsupported GPU route."); + }, + persistCreateIdentity: () => { + persistCreateIdentityCalls += 1; }, - revalidateVerifiedPolicy: vi.fn(), + verifyCurrentPolicyRequirements: vi.fn(), runVerifiedCreateEffects: async () => { verifiedCreateEffectCalls += 1; }, cleanupTemporarySources: vi.fn(), }), ).rejects.toThrow("automatic sandbox cleanup was not safe"); - expect(routeFallbackCalls).toBe(0); - expect(persistVerifiedPolicyCalls).toBe(0); + expect(persistCreateIdentityCalls).toBe(0); expect(verifiedCreateEffectCalls).toBe(0); }); }); diff --git a/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts b/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts index a3ba6d8970c..9eb38f7c1e3 100644 --- a/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts @@ -13,13 +13,10 @@ import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition import { captureHermesPortablePolicySource, createHermesPortableTransactionId, + hermesPortablePolicySourcePath, hermesPortableReceiptDirectory, publishHermesPortableDurablePolicySource, } from "./hermes-portable-receipt"; -import { - hermesPortableCreatePolicySemanticDigest, - resolveHermesPortableExpectedPolicyBytes, -} from "./hermes-portable-policy-authority"; import { classifyHermesPortableRegistry, createHermesPortableAuthenticatedHealthCapture, @@ -303,7 +300,7 @@ describe("Hermes portable onboarding transaction", () => { ); }); - it("does not enroll dashboard/TUI forward authority for schema-5 Hermes (#9203)", () => { + it("does not enroll dashboard/TUI forward authority for schema-7 Hermes (#9203)", () => { expect( shouldManageHermesPortableDashboard(true, loadAgent("hermes"), { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", @@ -644,9 +641,11 @@ network_policies: const resumed = await runHermesPortableOnboardingTransaction(resumedInput, second.value); expect(resumed.active.receipt.phase).toBe("active"); - expect(resumed.active.receipt.policy.intendedSemanticSha256).toBe( - hermesPortableCreatePolicySemanticDigest(Buffer.from(POLICY)), - ); + expect( + fs.existsSync( + hermesPortablePolicySourcePath("alpha", resumed.active.receipt.transactionId, stateDir), + ), + ).toBe(false); expect(resumed.created).toBe(true); expect(second.events.filter((event) => event === "create")).toHaveLength(1); }); @@ -847,7 +846,7 @@ network_policies: it("resumes an exact interrupted pending receipt prefix after process-style reentry (#9203)", async () => { interruptReceiptWrite( - Buffer.from('{"schemaVersion":5'), + Buffer.from('{"schemaVersion":7'), "simulated process exit during pending write", (length) => Math.floor(length / 2), ); @@ -933,7 +932,6 @@ network_policies: sandboxName: "alpha", transactionId, stateDir, - intendedSemanticSha256: hermesPortableCreatePolicySemanticDigest(Buffer.from(POLICY)), source: captureHermesPortablePolicySource(policyPath), hooks: { afterCanonicalLink: () => { @@ -990,21 +988,15 @@ network_policies: expect(fixture.events.filter((event) => event === "registry")).toHaveLength(1); }); - it("resumes configuring against the finalized Personal policy authority (#9211)", async () => { + it("resumes configuring against the current live OpenShell policy (#9211)", async () => { const first = deps({ failAfterRegistry: true }); await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( "registry-to-active exit", ); const finalizedRegistry = { ...first.value.readRegistry()!, - policyTier: "personal", - policies: ["personal-open-internet"], - policyPresetsFinalized: true, }; - const expectedPolicy = resolveHermesPortableExpectedPolicyBytes( - Buffer.from(POLICY), - finalizedRegistry, - ).bytes; + const expectedPolicy = Buffer.from(POLICY); fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); const resumed = deps({ existingSandbox: true, @@ -1131,7 +1123,7 @@ network_policies: ).toHaveLength(updatesBeforeResume); }); - it("rejects a different allowed GPU policy enrichment after configuring publication (#10121)", async () => { + it("accepts a host-edited live policy after configuring publication (#10121)", async () => { fs.writeFileSync(policyPath, NATIVE_GPU_CREATE, { mode: 0o600 }); const first = deps({ updateFails: true, policySource: NATIVE_GPU_LIVE }); await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( @@ -1144,19 +1136,13 @@ network_policies: policySource: NATIVE_GPU_LIVE.replace("/dev/nvidia0", "/dev/nvidia1"), }); - await expect(runHermesPortableOnboardingTransaction(input(), second.value)).rejects.toThrow( - "live policy authority disagrees with the configured receipt", - ); + await expect( + runHermesPortableOnboardingTransaction(input(), second.value), + ).resolves.toMatchObject({ active: { receipt: { phase: "active" } }, created: false }); - expect( - second.podman.mock.calls.some( - ([args]) => Array.isArray(args) && args[0] === "container" && args[1] === "update", - ), - ).toBe(false); - expect(second.events).not.toContain("registry"); expect( fs.existsSync(path.join(hermesPortableReceiptDirectory("alpha", stateDir), "active.json")), - ).toBe(false); + ).toBe(true); }); it("keeps a contender outside the lock through registry and active publication (#9203)", async () => { diff --git a/src/lib/onboard/experimental/hermes-portable-onboarding.ts b/src/lib/onboard/experimental/hermes-portable-onboarding.ts index 081034c4ace..3d9af55f811 100644 --- a/src/lib/onboard/experimental/hermes-portable-onboarding.ts +++ b/src/lib/onboard/experimental/hermes-portable-onboarding.ts @@ -63,12 +63,11 @@ import { type ResolveHermesPortableStartupContractInput, } from "./hermes-portable-contract"; import { - hermesPortableCreatePolicySemanticDigest, proveHermesPortableLivePolicy, type HermesPortablePolicyCapture, -} from "./hermes-portable-policy-authority"; +} from "./hermes-portable-policy-state"; import { - assertHermesPortableDurablePolicyAuthority, + assertHermesPortablePolicySource, captureHermesPortablePolicySource, createHermesPortableTransactionId, inspectPortableAgentReceiptAuthorityForPublicationRecovery, @@ -78,6 +77,7 @@ import { readHermesPortableLifecycleReceipt, reconcileHermesPortableCurrentPhasePublication, recoverableHermesPortablePolicyTransactionId, + retireHermesPortableCreatePolicyState, type HermesPortableConfiguredReceipt, type HermesPortableLifecycleReceipt, type HermesPortablePendingReceipt, @@ -315,7 +315,7 @@ function scopeHermesPortableReadyGetArgs( return null; } -/** Route create readiness and failed-create cleanup through exact schema-5 authority. */ +/** Route create readiness and failed-create cleanup through exact schema-7 authority. */ export function createHermesPortableReadyRunner( sandboxName: string, gatewayName: string, @@ -384,7 +384,7 @@ export function isHermesPortableLifecycleMode( return isPortableExperimentalProfile(env) && agent?.name === "hermes"; } -/** Keep unowned dashboard/TUI forwards out of schema-5 enrollment. */ +/** Keep unowned dashboard/TUI forwards out of schema-7 enrollment. */ export function shouldManageHermesPortableDashboard( ordinaryDecision: boolean, agent: AgentDefinition | null, @@ -542,7 +542,7 @@ function createHermesPortableCreateIntentSha256( fail("create argv policy option does not name the captured source"); } foundPolicy = true; - canonicalArgs.push(option, ""); + canonicalArgs.push(option, ""); continue; } if (option === "--name") { @@ -571,7 +571,7 @@ function createHermesPortableCreateIntentSha256( ); } if (!foundFrom || !foundName || !foundPolicy || !foundGateway) { - fail("create argv is missing required image, sandbox, gateway, or policy authority"); + fail("create argv is missing required image, sandbox, gateway, or policy state"); } return createHash("sha256") .update( @@ -861,7 +861,7 @@ function commonReceipt( startup: ReturnType, ) { return { - schemaVersion: 5 as const, + schemaVersion: 7 as const, agent: "hermes" as const, createIntentSha256, sandboxName: input.sandboxName, @@ -899,41 +899,19 @@ function assertCurrentTransaction( // match. Configuring and active receipts instead prove the already-created // sandbox, container, policy, and registry; their retired build-context plan // may be regenerated without authorizing another create. - assertHermesPortableDurablePolicyAuthority(receipt.policy); + if (receipt.phase === "pending") assertHermesPortablePolicySource(receipt.policy); } function proveLivePolicy( receipt: HermesPortableLifecycleReceipt, capture: HermesPortablePolicyCapture, - registryDisposition: HermesPortableRegistryDisposition = { kind: "missing" }, -): string { - const durable = assertHermesPortableDurablePolicyAuthority(receipt.policy); - const finalizedRegistryEntry = - receipt.phase === "pending" - ? null - : registryDisposition.kind === "matching" || - registryDisposition.kind === "matching-without-gateway-port" - ? registryDisposition.entry - : null; - const proof = proveHermesPortableLivePolicy({ +): void { + if (receipt.phase === "pending") assertHermesPortablePolicySource(receipt.policy); + proveHermesPortableLivePolicy({ gatewayName: receipt.gatewayName, sandboxName: receipt.sandboxName, - createPolicyBytes: durable, - finalizedRegistryEntry, capture, }); - if (proof.expectedPolicySource === "create") { - if (proof.intendedSemanticSha256 !== receipt.policy.intendedSemanticSha256) { - fail("live policy proof disagrees with pending intent"); - } - if ( - receipt.phase !== "pending" && - proof.verifiedLivePolicySemanticSha256 !== receipt.verifiedLivePolicySemanticSha256 - ) { - fail("live policy authority disagrees with the configured receipt"); - } - } - return proof.verifiedLivePolicySemanticSha256; } function assertRegistryMissingBeforeConfiguration( @@ -1046,15 +1024,14 @@ function requireConfiguredContainerReady(container: HermesPortableContainerInspe function configuringReceipt( pending: HermesPortableReceiptSnapshot, - livePolicyDigest: string, container: HermesPortableContainerInspection, ): HermesPortableConfiguredReceipt { if (pending.receipt.phase !== "pending") fail("configuring requires pending authority"); + const { policy: _policy, ...transaction } = pending.receipt; return { - ...pending.receipt, + ...transaction, phase: "configuring", previousPhaseSha256: pending.sha256, - verifiedLivePolicySemanticSha256: livePolicyDigest, container: container.authority, }; } @@ -1105,9 +1082,6 @@ export async function runHermesPortableOnboardingTransaction( sha256: createHash("sha256").update(input.createPolicySourceBytes).digest("hex"), } : captureHermesPortablePolicySource(input.createPolicyPath); - const currentIntendedSemanticSha256 = hermesPortableCreatePolicySemanticDigest( - temporaryPolicy.bytes, - ); const socketAuthority = (deps.captureSocketAuthority ?? capturePodmanSocketAuthority)( input.runtimeAuthority.socketPath, ); @@ -1216,7 +1190,7 @@ export async function runHermesPortableOnboardingTransaction( detail: "the inference route reservation changed after admission", }; } - if (entry?.pendingPolicyVerification !== undefined) { + if (entry?.pendingCreateIdentity !== undefined) { if (committedRegistryEntry || !deps.revalidatePendingCreateRegistry) { return { kind: "conflict", @@ -1324,18 +1298,20 @@ export async function runHermesPortableOnboardingTransaction( podmanExecutableAuthority, createIntentSha256, ); - createArgv = rewriteHermesPortableCreatePolicyArgv( - validatedCreateArgv, - input.createPolicyPath, - snapshot.receipt.policy.sourcePath, - ); + createArgv = + snapshot.receipt.phase === "pending" + ? rewriteHermesPortableCreatePolicyArgv( + validatedCreateArgv, + input.createPolicyPath, + snapshot.receipt.policy.sourcePath, + ) + : validatedCreateArgv; } else { const transactionId = recoverableTransactionId ?? createHermesPortableTransactionId(); const policy = publishHermesPortableDurablePolicySource({ sandboxName: input.sandboxName, transactionId, stateDir: input.stateDir, - intendedSemanticSha256: currentIntendedSemanticSha256, source: temporaryPolicy, }); createArgv = rewriteHermesPortableCreatePolicyArgv( @@ -1373,11 +1349,7 @@ export async function runHermesPortableOnboardingTransaction( requireConfiguredContainerReady( assertCurrentHermesPortableContainer(activeSnapshot.receipt, containerDeps), ); - proveLivePolicy( - activeSnapshot.receipt, - capturePolicy, - registryDisposition(activeSnapshot.receipt), - ); + proveLivePolicy(activeSnapshot.receipt, capturePolicy); requireMatchingRegistry( activeSnapshot.receipt, repairRegistryGatewayPort(activeSnapshot.receipt, liveIdentity.liveIdentityFingerprint), @@ -1396,23 +1368,23 @@ export async function runHermesPortableOnboardingTransaction( requireConfiguredContainerReady( assertCurrentHermesPortableContainer(activeSnapshot.receipt, containerDeps), ); - proveLivePolicy( - activeSnapshot.receipt, - capturePolicy, - registryDisposition(activeSnapshot.receipt), - ); + proveLivePolicy(activeSnapshot.receipt, capturePolicy); requireMatchingRegistry( activeSnapshot.receipt, repairRegistryGatewayPort(activeSnapshot.receipt, finalIdentity.liveIdentityFingerprint), finalIdentity.liveIdentityFingerprint, ); - if (activeSnapshot.successor || activeSnapshot.successorPublicationPending) { - activeSnapshot = publishHermesPortableSuccessorReceipt(input.sandboxName, input.stateDir); - } + activeSnapshot = publishHermesPortableSuccessorReceipt(input.sandboxName, input.stateDir); + activeSnapshot = retireHermesPortableCreatePolicyState( + activeSnapshot.receipt.sandboxName, + activeSnapshot.receipt.transactionId, + input.stateDir, + ); return { active: activeSnapshot, createResult, created }; } if (snapshot.receipt.phase === "pending") { + const createPolicySourcePath = snapshot.receipt.policy.sourcePath; assertRegistryMissingBeforeConfiguration( snapshot.receipt, registryDisposition(snapshot.receipt), @@ -1464,7 +1436,7 @@ export async function runHermesPortableOnboardingTransaction( buildContext, ), buildContext.buildContextPath, - snapshot.receipt.policy.sourcePath, + createPolicySourcePath, ); buildContext.assertCurrent(); input.buildContext.assertCurrentSource(); @@ -1491,18 +1463,14 @@ export async function runHermesPortableOnboardingTransaction( podmanExecutableAuthority, createIntentSha256, ); - const livePolicyDigest = proveLivePolicy( - snapshot.receipt, - capturePolicy, - registryDisposition(snapshot.receipt), - ); + proveLivePolicy(snapshot.receipt, capturePolicy); const container = enrollHermesPortableContainer( snapshot.receipt, observation.sandboxId, containerDeps, ); snapshot = publishHermesPortableLifecycleReceipt( - configuringReceipt(snapshot, livePolicyDigest, container), + configuringReceipt(snapshot, container), input.stateDir, ); } @@ -1530,11 +1498,7 @@ export async function runHermesPortableOnboardingTransaction( configuringSnapshot.receipt, observeSandbox(), ); - proveLivePolicy( - configuringSnapshot.receipt, - capturePolicy, - registryDisposition(configuringSnapshot.receipt), - ); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); requireRegistryBeforeConfigurationMutation( repairRegistryGatewayPort(configuringSnapshot.receipt, liveIdentity.liveIdentityFingerprint), liveIdentity.liveIdentityFingerprint, @@ -1557,11 +1521,7 @@ export async function runHermesPortableOnboardingTransaction( configuringSnapshot.receipt, observeSandbox(), ); - proveLivePolicy( - configuringSnapshot.receipt, - capturePolicy, - registryDisposition(configuringSnapshot.receipt), - ); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); requireConfiguredContainerReady( assertCurrentHermesPortableContainer(configuringSnapshot.receipt, containerDeps), ); @@ -1591,11 +1551,7 @@ export async function runHermesPortableOnboardingTransaction( createIntentSha256, ); liveIdentity = requireCurrentOpenShellIdentity(configuringSnapshot.receipt, observeSandbox()); - proveLivePolicy( - configuringSnapshot.receipt, - capturePolicy, - registryDisposition(configuringSnapshot.receipt), - ); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); const currentContainer = assertCurrentHermesPortableContainer( configuringSnapshot.receipt, containerDeps, @@ -1609,11 +1565,7 @@ export async function runHermesPortableOnboardingTransaction( probeHermesPortableAuthenticatedHealth(configuringSnapshot.receipt, containerDeps); configuringSnapshot = requireCurrentReceiptSnapshot(configuringSnapshot, input.stateDir, true); liveIdentity = requireCurrentOpenShellIdentity(configuringSnapshot.receipt, observeSandbox()); - proveLivePolicy( - configuringSnapshot.receipt, - capturePolicy, - registryDisposition(configuringSnapshot.receipt), - ); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); requireConfiguredContainerReady( assertCurrentHermesPortableContainer(configuringSnapshot.receipt, containerDeps), ); @@ -1626,7 +1578,12 @@ export async function runHermesPortableOnboardingTransaction( activeReceipt(configuringSnapshot, currentContainer), input.stateDir, ); - const active = publishHermesPortableSuccessorReceipt(input.sandboxName, input.stateDir); + const published = publishHermesPortableSuccessorReceipt(input.sandboxName, input.stateDir); + const active = retireHermesPortableCreatePolicyState( + published.receipt.sandboxName, + published.receipt.transactionId, + input.stateDir, + ); return { active, createResult, created }; }); } @@ -1671,7 +1628,7 @@ interface RunHermesPortableOnboardCreateInput { readonly createSandbox: HermesPortableOnboardingFromOnboardInput["createSandbox"]; } -/** Carry one receipt-owned create source through the outer generic create gate. */ +/** Carry one transaction-scoped create source through the outer generic create gate. */ export function runHermesPortableOnboardCreate( input: RunHermesPortableOnboardCreateInput, ): Promise { diff --git a/src/lib/onboard/experimental/hermes-portable-operating-authority.test.ts b/src/lib/onboard/experimental/hermes-portable-operating-authority.test.ts index d555971b438..bd881b2654f 100644 --- a/src/lib/onboard/experimental/hermes-portable-operating-authority.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-operating-authority.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -82,8 +82,6 @@ function environment(): NodeJS.ProcessEnv { function snapshot(withSuccessor = true): HermesPortableReceiptSnapshot & { readonly receipt: HermesPortableConfiguredReceipt; } { - const bytes = fs.readFileSync(policyPath); - const stat = fs.statSync(policyPath, { bigint: true }); const openshellExecutableAuthority: HermesPortableOpenShellExecutableAuthority = { version: "0.0.106", executable: executable("/usr/bin/openshell", "b".repeat(64)), @@ -93,7 +91,7 @@ function snapshot(withSuccessor = true): HermesPortableReceiptSnapshot & { executable: executable("/usr/bin/podman", "c".repeat(64)), }; const receipt: HermesPortableConfiguredReceipt = { - schemaVersion: 5, + schemaVersion: 7, phase: "active", agent: "hermes", transactionId: randomUUID(), @@ -132,22 +130,7 @@ function snapshot(withSuccessor = true): HermesPortableReceiptSnapshot & { configDir: "/sandbox/.hermes", stateIdentitySha256: "e".repeat(64), }, - policy: { - sourcePath: policyPath, - sourceSha256: createHash("sha256").update(bytes).digest("hex"), - intendedSemanticSha256: "f".repeat(64), - sourceIdentity: { - dev: String(stat.dev), - ino: String(stat.ino), - size: String(stat.size), - mode: 0o600, - uid: uid(), - mtimeNs: String(stat.mtimeNs), - ctimeNs: String(stat.ctimeNs), - }, - }, previousPhaseSha256: SHA, - verifiedLivePolicySemanticSha256: "f".repeat(64), container: { containerId: "1".repeat(64), sandboxId: "sandbox-id", @@ -187,8 +170,8 @@ beforeEach(() => { afterEach(() => fs.rmSync(root, { recursive: true, force: true })); -describe("Hermes Portable schema-6 operation authority", () => { - it("keeps schema-5 authority durable unless requalification is explicit (#10423)", () => { +describe("Hermes Portable schema-8 operation authority", () => { + it("keeps schema-7 authority durable unless requalification is explicit (#10423)", () => { const durable = snapshot(false); const captureSocketAuthority = vi.fn(() => socket("99")); const captureOpenShellExecutableAuthority = vi.fn(); @@ -208,7 +191,7 @@ describe("Hermes Portable schema-6 operation authority", () => { expect(capturePodmanExecutableAuthority).not.toHaveBeenCalled(); }); - it("captures operation-local authority for an explicitly admitted schema-5 receipt (#10423)", () => { + it("captures operation-local authority for an explicitly admitted schema-7 receipt (#10423)", () => { const captureSocketAuthority = vi.fn(() => socket("99")); const captureOpenShellExecutableAuthority = vi.fn(() => ({ version: "0.0.106" as const, @@ -330,7 +313,7 @@ describe("Hermes Portable schema-6 operation authority", () => { ); }); - it("rejects operation-local policy replacement before completion (#10423)", () => { + it("does not treat the old create-policy file as operating authority (#10423)", () => { const authority = qualifyHermesPortableOperatingAuthority(snapshot(), { env: environment(), captureSocketAuthority: () => socket("99"), @@ -347,9 +330,7 @@ describe("Hermes Portable schema-6 operation authority", () => { fs.writeFileSync(replacement, fs.readFileSync(policyPath), { mode: 0o600 }); fs.renameSync(replacement, policyPath); - expect(authority.assertCurrent).toThrow( - "operation-local filesystem or runtime identity changed", - ); + expect(authority.assertCurrent).not.toThrow(); }); it.each(["openshell", "podman"] as const)( diff --git a/src/lib/onboard/experimental/hermes-portable-operating-authority.ts b/src/lib/onboard/experimental/hermes-portable-operating-authority.ts index 16efc6c7b30..132954d921a 100644 --- a/src/lib/onboard/experimental/hermes-portable-operating-authority.ts +++ b/src/lib/onboard/experimental/hermes-portable-operating-authority.ts @@ -14,13 +14,10 @@ import { type HermesPortablePodmanExecutableAuthority, } from "./hermes-portable-podman-authority"; import { - assertHermesPortableDurablePolicyAuthority, createHermesPortableSuccessorReceipt, - requalifyHermesPortablePolicyAuthority, stableHermesPortableExecutableAuthority, stableHermesPortableSocketAuthority, type HermesPortableConfiguredReceipt, - type HermesPortablePolicyAuthority, type HermesPortableReceiptSnapshot, type HermesPortableStableSocketAuthority, type HermesPortableSuccessorReceipt, @@ -47,7 +44,7 @@ export interface QualifiedHermesPortableOperatingAuthority { } function fail(message: string): never { - throw new Error(`Hermes portable schema-6 authority ${message}`); + throw new Error(`Hermes portable schema-8 authority ${message}`); } const MODE_TYPE_MASK = 0o170000n; @@ -107,7 +104,6 @@ function sameStableSocketSemantics( function requireStableAuthority( expected: HermesPortableSuccessorReceipt, receipt: HermesPortableConfiguredReceipt, - policy: HermesPortablePolicyAuthority, socket: PodmanSocketAuthority, openshell: HermesPortableOpenShellExecutableAuthority, podman: HermesPortablePodmanExecutableAuthority, @@ -116,12 +112,6 @@ function requireStableAuthority( !isDeepStrictEqual(expected.runtimeAuthority, receipt.runtimeAuthority) || !isDeepStrictEqual(expected.startup, receipt.startup) || !isDeepStrictEqual(expected.container, receipt.container) || - expected.policy.sourcePath !== policy.sourcePath || - expected.policy.sourceSha256 !== policy.sourceSha256 || - expected.policy.intendedSemanticSha256 !== policy.intendedSemanticSha256 || - expected.policy.size !== policy.sourceIdentity.size || - expected.policy.mode !== policy.sourceIdentity.mode || - expected.policy.uid !== policy.sourceIdentity.uid || !sameStableSocketSemantics(expected.socketAuthority, socket) || expected.openshellExecutableAuthority.version !== openshell.version || !isDeepStrictEqual( @@ -138,7 +128,7 @@ function requireStableAuthority( } } -/** Capture one operation-local filesystem/runtime generation from durable schema-6 semantics. */ +/** Capture one operation-local filesystem/runtime generation from durable schema-8 semantics. */ export function qualifyHermesPortableOperatingAuthority( snapshot: HermesPortableReceiptSnapshot & { readonly receipt: HermesPortableConfiguredReceipt; @@ -148,10 +138,9 @@ export function qualifyHermesPortableOperatingAuthority( ): QualifiedHermesPortableOperatingAuthority { if (snapshot.receipt.phase !== "active") fail("requires active Hermes receipt authority"); if (!snapshot.successor && options.permitSchema5Requalification !== true) { - assertHermesPortableDurablePolicyAuthority(snapshot.receipt.policy); return { receipt: snapshot.receipt, - assertCurrent: () => assertHermesPortableDurablePolicyAuthority(snapshot.receipt.policy), + assertCurrent: () => undefined, }; } const env = deps.env ?? process.env; @@ -170,7 +159,6 @@ export function qualifyHermesPortableOperatingAuthority( sourceEnv, )); const capture = () => { - const policy = requalifyHermesPortablePolicyAuthority(snapshot.receipt.policy).authority; const socket = captureSocket( snapshot.receipt.runtimeAuthority.socketPath, snapshot.receipt.runtimeAuthority.uid, @@ -181,17 +169,15 @@ export function qualifyHermesPortableOperatingAuthority( childEnv, env, ); - const receiptWithCurrentSocket = { ...snapshot.receipt, policy, socketAuthority: socket }; + const receiptWithCurrentSocket = { ...snapshot.receipt, socketAuthority: socket }; const podman = capturePodman(socket, receiptWithCurrentSocket, env); - requireStableAuthority(expected, snapshot.receipt, policy, socket, openshell, podman); + requireStableAuthority(expected, snapshot.receipt, socket, openshell, podman); return { - policy, socket, openshell, podman, receipt: { ...snapshot.receipt, - policy, socketAuthority: socket, openshellExecutableAuthority: openshell, podmanExecutableAuthority: podman, @@ -204,7 +190,6 @@ export function qualifyHermesPortableOperatingAuthority( assertCurrent: () => { const current = capture(); if ( - !isDeepStrictEqual(current.policy, initial.policy) || !isDeepStrictEqual(current.socket, initial.socket) || !isDeepStrictEqual(current.openshell, initial.openshell) || !isDeepStrictEqual(current.podman, initial.podman) diff --git a/src/lib/onboard/experimental/hermes-portable-policy-authority.test.ts b/src/lib/onboard/experimental/hermes-portable-policy-authority.test.ts deleted file mode 100644 index e75d95a144d..00000000000 --- a/src/lib/onboard/experimental/hermes-portable-policy-authority.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import type { SandboxEntry } from "../../state/registry/types"; -import { - hermesPortableCreatePolicySemanticDigest, - hermesPortablePolicyAuthorityInternals, - proveHermesPortableLivePolicy, - resolveHermesPortableExpectedPolicyBytes, - type HermesPortablePolicyCaptureResult, -} from "./hermes-portable-policy-authority"; - -const CREATE = Buffer.from(`version: 1 -network_policies: - inference: - name: inference - endpoints: - - host: inference.local - port: 443 -`); - -const FORMATTED = Buffer.from(`Policy: alpha ---- -network_policies: - inference: { endpoints: [{ port: 443, host: inference.local }], name: inference } -version: 1 -`); - -const NATIVE_GPU_CREATE = Buffer.from(`version: 1 -filesystem_policy: - include_workdir: true - read_only: - - /usr - - /lib - - /etc - - /app - - /var/log - - /dev/urandom - read_write: - - /tmp -network_policies: - inference: - name: inference - endpoints: - - host: inference.local - port: 443 -`); - -const NATIVE_GPU_LIVE = Buffer.from(`version: 1 -filesystem_policy: - include_workdir: true - read_only: - - /usr - - /lib - - /etc - - /app - - /var/log - - /dev/urandom - - /run/nvidia-persistenced - - /usr/lib/wsl - read_write: - - /tmp - - /proc - - /dev/nvidiactl - - /dev/nvidia-uvm - - /dev/nvidia-uvm-tools - - /dev/nvidia-modeset - - /dev/dxg - - /dev/nvidia0 - - /dev/nvidia1 -network_policies: - inference: - name: inference - endpoints: - - host: inference.local - port: 443 -`); - -const PERSONAL_REGISTRY = { - name: "alpha", - agent: "hermes", - policyTier: "personal", - policies: ["personal-open-internet"], - policyPresetsFinalized: true, -} satisfies SandboxEntry; - -function result( - stdout: Buffer, - status = 0, - stderr = Buffer.alloc(0), -): HermesPortablePolicyCaptureResult { - return { status, stdout, stderr }; -} - -function deeplyNestedPolicyAuthority(): Record { - const root: Record = {}; - Array.from({ length: 70 }).reduce>((current) => { - const next: Record = {}; - current.next = next; - return next; - }, root); - return root; -} - -describe("Hermes portable policy authority", () => { - it("accepts formatting and mapping-order normalization from exact scoped base/full reads (#9203)", () => { - const capture = vi.fn(() => result(FORMATTED)); - - const proof = proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture, - }); - - expect(proof.verifiedLivePolicySemanticSha256).toBe(proof.intendedSemanticSha256); - expect(capture.mock.calls).toEqual([ - [["policy", "get", "-g", "nemoclaw", "--base", "alpha"]], - [["policy", "get", "-g", "nemoclaw", "--full", "alpha"]], - ]); - }); - - it("accepts only OpenShell's documented native-GPU baseline enrichment (#10121)", () => { - const capture = vi.fn(() => result(NATIVE_GPU_LIVE)); - - const proof = proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: NATIVE_GPU_CREATE, - capture, - }); - - expect(proof.verifiedLivePolicySemanticSha256).not.toBe(proof.intendedSemanticSha256); - expect(capture).toHaveBeenCalledTimes(2); - }); - - it("accepts the exact Personal policy derived from finalized registry authority (#9211)", () => { - const expected = resolveHermesPortableExpectedPolicyBytes(CREATE, PERSONAL_REGISTRY); - const capture = vi.fn(() => result(expected.bytes)); - - const proof = proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - finalizedRegistryEntry: PERSONAL_REGISTRY, - capture, - }); - - expect(expected.source).toBe("finalized-registry"); - expect(expected.bytes.toString("utf8")).toContain("personal_open_internet"); - expect(expected.bytes.toString("utf8")).not.toContain("inference.local"); - expect(proof.expectedPolicySource).toBe("finalized-registry"); - expect(proof.verifiedLivePolicySemanticSha256).toBe(proof.intendedSemanticSha256); - }); - - it("does not accept Personal policy before registry finalization (#9211)", () => { - const expected = resolveHermesPortableExpectedPolicyBytes(CREATE, PERSONAL_REGISTRY); - - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - finalizedRegistryEntry: { ...PERSONAL_REGISTRY, policyPresetsFinalized: undefined }, - capture: () => result(expected.bytes), - }), - ).toThrow("base policy disagrees with create input"); - }); - - it.each([ - [ - "a missing tier preset", - { ...PERSONAL_REGISTRY, policies: [] }, - "finalized policy tier disagrees with preset authority", - ], - [ - "an unknown preset", - { ...PERSONAL_REGISTRY, policyTier: "restricted", policies: ["not-a-real-preset"] }, - "cannot compose finalized registry preset authority", - ], - [ - "a duplicate preset", - { - ...PERSONAL_REGISTRY, - policies: ["personal-open-internet", "personal-open-internet"], - }, - "finalized registry preset authority is invalid", - ], - [ - "a custom policy", - { - ...PERSONAL_REGISTRY, - customPolicies: [{ name: "custom", content: "network_policies: {}\n" }], - }, - "finalized custom policy authority is not supported", - ], - ])( - "rejects finalized registry authority with %s (#9211)", - (_label, entry, expectedMessage) => { - expect(() => - resolveHermesPortableExpectedPolicyBytes(CREATE, entry satisfies SandboxEntry), - ).toThrow(`Hermes portable policy authority ${expectedMessage}`); - }, - ); - - it.each([ - { - label: "an arbitrary added path", - create: NATIVE_GPU_CREATE, - live: NATIVE_GPU_LIVE.toString("utf8").replace(" - /dev/nvidia0\n", " - /home\n"), - }, - { - label: "an intended path removal", - create: NATIVE_GPU_CREATE, - live: NATIVE_GPU_LIVE.toString("utf8").replace(" - /usr\n", ""), - }, - { - label: "a non-filesystem change", - create: NATIVE_GPU_CREATE, - live: NATIVE_GPU_LIVE.toString("utf8").replace("port: 443", "port: 8443"), - }, - { - label: "a near-match GPU device path", - create: NATIVE_GPU_CREATE, - live: NATIVE_GPU_LIVE.toString("utf8").replace("/dev/nvidia0", "/dev/nvidia0/escape"), - }, - { - label: "a duplicate live path", - create: NATIVE_GPU_CREATE, - live: NATIVE_GPU_LIVE.toString("utf8").replace( - " - /dev/nvidia0\n", - " - /dev/nvidia0\n - /dev/nvidia0\n", - ), - }, - { - label: "GPU additions without the documented /proc promotion", - create: NATIVE_GPU_CREATE, - live: NATIVE_GPU_LIVE.toString("utf8").replace(" - /proc\n", ""), - }, - { - label: "GPU enrichment of an ordinary policy", - create: Buffer.from( - NATIVE_GPU_CREATE.toString("utf8").replace(" - /etc\n", " - /etc\n - /proc\n"), - ), - live: NATIVE_GPU_LIVE.toString("utf8"), - }, - ])("rejects $label as unproven policy drift (#10121)", ({ create, live }) => { - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: Buffer.isBuffer(create) ? create : Buffer.from(create), - capture: () => result(Buffer.from(live)), - }), - ).toThrow("base policy disagrees"); - }); - - it("rejects a reserved provider entry in the exact create input (#9203)", () => { - const create = Buffer.from(`version: 1 -network_policies: - _provider_injected: { endpoints: [] } -`); - - expect(() => hermesPortableCreatePolicySemanticDigest(create)).toThrow( - "create input contains a reserved provider-composed entry", - ); - }); - - it("rejects arbitrary content under a registered-looking provider key in full policy (#9203)", () => { - const full = Buffer.from(`${CREATE.toString("utf8")} _provider_nvidia_inference: - name: _provider_nvidia_inference - endpoints: [] -`); - const capture = vi.fn((args: readonly string[]) => - result(args.includes("--base") ? CREATE : full), - ); - - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture, - }), - ).toThrow("unproven provider-composed or out-of-band delta"); - }); - - it("does not erase a __proto__ policy entry during semantic comparison (#9203)", () => { - const full = Buffer.from(`${CREATE.toString("utf8")} __proto__: - name: injected - endpoints: [] -`); - - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture: (args) => result(args.includes("--base") ? CREATE : full), - }), - ).toThrow("out-of-band delta"); - }); - - it("rejects cyclic and oversized semantic structures deterministically (#9203)", () => { - const cyclic: Record = {}; - cyclic.self = cyclic; - - expect(() => hermesPortablePolicyAuthorityInternals.semanticDigest(cyclic)).toThrow( - "cyclic semantic structure", - ); - expect(() => - hermesPortablePolicyAuthorityInternals.semanticDigest(deeplyNestedPolicyAuthority()), - ).toThrow("oversized semantic structure"); - }); - - it("rejects capture ambiguity even when the child status is zero (#9203)", () => { - const capture = vi.fn(() => ({ - ...result(FORMATTED), - error: new Error("transport ended ambiguously"), - })); - - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture, - }), - ).toThrow("scoped base policy failed with status 0"); - expect(capture).toHaveBeenCalledTimes(1); - }); - - it("rejects non-reserved semantic drift in base or full policy (#9203)", () => { - const drift = Buffer.from(CREATE.toString("utf8").replace("port: 443", "port: 8443")); - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture: (args) => result(args.includes("--base") ? drift : drift), - }), - ).toThrow("base policy disagrees"); - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture: (args) => result(args.includes("--base") ? CREATE : drift), - }), - ).toThrow("out-of-band delta"); - }); - - it.each(["create", "base", "full"] as const)( - "rejects malformed UTF-8 in the %s policy bytes (#9203)", - (target) => { - const malformed = Buffer.from([0xff]); - const createPolicyBytes = target === "create" ? malformed : CREATE; - const capture = (args: readonly string[]) => - result( - target === "base" && args.includes("--base") - ? malformed - : target === "full" && args.includes("--full") - ? malformed - : CREATE, - ); - - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes, - capture, - }), - ).toThrow("strict UTF-8"); - }, - ); - - it("rejects duplicate policy documents and failed scoped reads (#9203)", () => { - const duplicate = Buffer.from(`${CREATE.toString("utf8")}---\n${CREATE.toString("utf8")}`); - expect(() => hermesPortableCreatePolicySemanticDigest(duplicate)).toThrow( - "duplicate or ambiguous", - ); - expect(() => - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture: () => result(Buffer.alloc(0), 1, Buffer.from("gateway unavailable")), - }), - ).toThrow("scoped base policy failed with status 1"); - }); - - it("does not expose malformed policy content or capture stderr in errors (#9203)", () => { - const secret = "DO_NOT_LOG_POLICY_SECRET"; - let malformedError: Error | null = null; - try { - hermesPortableCreatePolicySemanticDigest( - Buffer.from(`version: 1\nnetwork_policies:\n secret: [${secret}\n`), - ); - } catch (error) { - malformedError = error as Error; - } - expect(malformedError?.message).toContain("create input is invalid"); - expect(malformedError?.message).not.toContain(secret); - - let captureError: Error | null = null; - try { - proveHermesPortableLivePolicy({ - gatewayName: "nemoclaw", - sandboxName: "alpha", - createPolicyBytes: CREATE, - capture: () => result(Buffer.alloc(0), 1, Buffer.from(secret)), - }); - } catch (error) { - captureError = error as Error; - } - expect(captureError?.message).toContain("scoped base policy failed with status 1"); - expect(captureError?.message).not.toContain(secret); - }); -}); diff --git a/src/lib/onboard/experimental/hermes-portable-policy-authority.ts b/src/lib/onboard/experimental/hermes-portable-policy-authority.ts deleted file mode 100644 index 6adbaaaec25..00000000000 --- a/src/lib/onboard/experimental/hermes-portable-policy-authority.ts +++ /dev/null @@ -1,267 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createHash } from "node:crypto"; -import { TextDecoder } from "node:util"; - -import YAML from "yaml"; - -import { mergePresetNamesIntoPolicy } from "../../policy"; -import { parseOpenShellPolicy } from "../../policy/merge"; -import type { SandboxEntry } from "../../state/registry/types"; -import { ensureRequiredTierPolicyPresets } from "../policy-tier-suppression"; -import { isOpenShellGpuBaselineEnrichment } from "../sandbox-gpu-route-policy"; - -const UTF8 = new TextDecoder("utf-8", { fatal: true }); -const MAX_POLICY_BYTES = 256 * 1024; -const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; - -export interface HermesPortablePolicyCaptureResult { - readonly status: number | null; - readonly stdout: Buffer; - readonly stderr: Buffer; - readonly error?: Error; -} - -export interface HermesPortablePolicyCapture { - (args: readonly string[]): HermesPortablePolicyCaptureResult; -} - -export interface HermesPortableLivePolicyProof { - readonly intendedSemanticSha256: string; - readonly verifiedLivePolicySemanticSha256: string; - readonly expectedPolicySource: "create" | "finalized-registry"; -} - -function fail(message: string): never { - throw new Error(`Hermes portable policy authority ${message}`); -} - -function decode(bytes: Buffer, label: string): string { - if (bytes.length > MAX_POLICY_BYTES) fail(`${label} exceeds the byte limit`); - try { - return UTF8.decode(bytes); - } catch { - fail(`${label} is not strict UTF-8`); - } -} - -interface CanonicalState { - readonly active: WeakSet; - nodes: number; -} - -function canonical( - value: unknown, - state: CanonicalState = { active: new WeakSet(), nodes: 0 }, - depth = 0, -): unknown { - state.nodes += 1; - if (state.nodes > 16_384 || depth > 64) fail("contains an oversized semantic structure"); - if (value === null || typeof value === "string" || typeof value === "boolean") return value; - if (typeof value === "number" && Number.isFinite(value)) return value; - if (!value || typeof value !== "object") fail("contains a non-JSON semantic value"); - if (state.active.has(value)) fail("contains a cyclic semantic structure"); - state.active.add(value); - try { - if (Array.isArray(value)) { - return value.map((entry) => canonical(entry, state, depth + 1)); - } - const result: Record = Object.create(null); - for (const key of Object.keys(value as Record).sort()) { - if (key.length === 0 || key.length > 1024) fail("contains an invalid mapping key"); - result[key] = canonical((value as Record)[key], state, depth + 1); - } - return result; - } finally { - state.active.delete(value); - } -} - -function parseOnePolicyDocument(raw: string, label: string): Record { - let parsed: ReturnType; - try { - parsed = parseOpenShellPolicy(raw); - } catch { - fail(`${label} is invalid`); - } - const separators = [...raw.matchAll(/(?:^|\r?\n)---[ \t]*(?:\r?\n|$)/gu)]; - if (separators.length > 1) fail(`${label} is duplicate or ambiguous`); - if (separators.length === 1 && separators[0]!.index! > 0) { - const prefix = raw.slice(0, separators[0]!.index).trim(); - if (prefix) { - const prefixDocuments = YAML.parseAllDocuments(prefix); - const prefixPolicy = prefixDocuments[0]?.toJSON(); - if ( - prefixDocuments.length !== 1 || - prefixDocuments[0]!.errors.length > 0 || - (prefixPolicy && - typeof prefixPolicy === "object" && - !Array.isArray(prefixPolicy) && - ("version" in prefixPolicy || "network_policies" in prefixPolicy)) - ) { - fail(`${label} is duplicate or ambiguous`); - } - } - } - const documents = YAML.parseAllDocuments(parsed.yamlBody); - if (documents.length !== 1 || documents[0]!.errors.length > 0) { - fail(`${label} is duplicate or ambiguous`); - } - return parsed.policy; -} - -function semanticDigest(policy: Record): string { - return createHash("sha256") - .update(JSON.stringify(canonical(policy))) - .digest("hex"); -} - -function rejectReservedCreateEntries(policy: Record): void { - const policies = policy.network_policies; - if (!policies || typeof policies !== "object" || Array.isArray(policies)) return; - if (Object.keys(policies).some((name) => name.startsWith("_provider_"))) { - fail("create input contains a reserved provider-composed entry"); - } -} - -function parseCreatePolicy(bytes: Buffer): Record { - const policy = parseOnePolicyDocument(decode(bytes, "create input"), "create input"); - rejectReservedCreateEntries(policy); - return policy; -} - -function finalizedPresetNames(entry: SandboxEntry): string[] { - if (entry.agent !== "hermes") fail("finalized registry has another agent"); - if (entry.baselineExclusionTransition !== undefined) { - fail("finalized registry has an incomplete baseline-policy mutation"); - } - if ((entry.customPolicies ?? []).length > 0) { - fail("finalized custom policy authority is not supported"); - } - const rawPresetNames: unknown = entry.policies; - if (rawPresetNames !== undefined && !Array.isArray(rawPresetNames)) { - fail("finalized registry preset authority is invalid"); - } - const presetNames = (rawPresetNames ?? []) as unknown[]; - if ( - presetNames.some((name) => typeof name !== "string" || !NAME.test(name)) || - new Set(presetNames).size !== presetNames.length - ) { - fail("finalized registry preset authority is invalid"); - } - const names = presetNames as string[]; - const requiredNames = ensureRequiredTierPolicyPresets(entry.policyTier, names); - if ( - requiredNames.length !== names.length || - requiredNames.some((name, index) => name !== names[index]) - ) { - fail("finalized policy tier disagrees with preset authority"); - } - return names; -} - -/** Reconstruct the exact live policy authorized by a completed onboarding policy step. */ -export function resolveHermesPortableExpectedPolicyBytes( - createPolicyBytes: Buffer, - finalizedRegistryEntry?: SandboxEntry | null, -): { readonly bytes: Buffer; readonly source: "create" | "finalized-registry" } { - parseCreatePolicy(createPolicyBytes); - if (finalizedRegistryEntry?.policyPresetsFinalized !== true) { - return { bytes: Buffer.from(createPolicyBytes), source: "create" }; - } - const presetNames = finalizedPresetNames(finalizedRegistryEntry); - const excludedBaselineKeys = (finalizedRegistryEntry.baselineExclusions ?? []).map( - (entry) => entry.key, - ); - let composed: ReturnType; - try { - composed = mergePresetNamesIntoPolicy(decode(createPolicyBytes, "create input"), presetNames, { - agent: "hermes", - sandboxName: finalizedRegistryEntry.name, - excludedBaselineKeys, - }); - } catch { - fail("cannot compose finalized registry preset authority"); - } - if ( - composed.missingPresets.length > 0 || - composed.appliedPresets.length !== presetNames.length || - composed.appliedPresets.some((name, index) => name !== presetNames[index]) - ) { - fail("cannot compose finalized registry preset authority"); - } - const bytes = Buffer.from(composed.policy, "utf8"); - parseCreatePolicy(bytes); - return { bytes, source: "finalized-registry" }; -} - -/** Capture and bind the exact create-policy bytes before sandbox creation. */ -export function hermesPortableCreatePolicySemanticDigest(bytes: Buffer): string { - return semanticDigest(parseCreatePolicy(bytes)); -} - -function capturePolicy( - capture: HermesPortablePolicyCapture, - args: readonly string[], - label: string, -): Record { - const result = capture(args); - decode(result.stderr, `${label} stderr`); - if (result.status !== 0 || result.error) { - fail(`${label} failed with status ${String(result.status)}`); - } - return parseOnePolicyDocument(decode(result.stdout, label), label); -} - -/** - * Prove the current 0.0.106 Hermes matrix's empty provider projection. - * Both reads are explicitly gateway and sandbox scoped. A non-empty full/base - * delta is unsupported until OpenShell exposes an authoritative projection. - */ -export function proveHermesPortableLivePolicy(input: { - readonly gatewayName: string; - readonly sandboxName: string; - readonly createPolicyBytes: Buffer; - readonly finalizedRegistryEntry?: SandboxEntry | null; - readonly capture: HermesPortablePolicyCapture; -}): HermesPortableLivePolicyProof { - if (!NAME.test(input.gatewayName) || !NAME.test(input.sandboxName)) { - fail("gateway or sandbox identity is invalid"); - } - const expected = resolveHermesPortableExpectedPolicyBytes( - input.createPolicyBytes, - input.finalizedRegistryEntry, - ); - const intended = parseCreatePolicy(expected.bytes); - const intendedSemanticSha256 = semanticDigest(intended); - const prefix = ["policy", "get", "-g", input.gatewayName] as const; - const base = capturePolicy( - input.capture, - [...prefix, "--base", input.sandboxName], - "scoped base policy", - ); - const full = capturePolicy( - input.capture, - [...prefix, "--full", input.sandboxName], - "scoped full policy", - ); - const baseDigest = semanticDigest(base); - const fullDigest = semanticDigest(full); - if (baseDigest !== intendedSemanticSha256 && !isOpenShellGpuBaselineEnrichment(intended, base)) { - fail("scoped base policy disagrees with create input"); - } - if (fullDigest !== baseDigest) { - fail("scoped full policy contains an unproven provider-composed or out-of-band delta"); - } - return { - intendedSemanticSha256, - verifiedLivePolicySemanticSha256: fullDigest, - expectedPolicySource: expected.source, - }; -} - -export const hermesPortablePolicyAuthorityInternals = { - isOpenShellGpuBaselineEnrichment, - semanticDigest, -}; diff --git a/src/lib/onboard/experimental/hermes-portable-policy-state.ts b/src/lib/onboard/experimental/hermes-portable-policy-state.ts new file mode 100644 index 00000000000..3f78bfc8b2e --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-policy-state.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { parseOpenShellPolicy } from "../../policy/merge"; + +export interface HermesPortablePolicyCaptureResult { + readonly status: number | null; + readonly stdout: Buffer; + readonly stderr: Buffer; + readonly error?: Error; +} + +export interface HermesPortablePolicyCapture { + (args: readonly string[]): HermesPortablePolicyCaptureResult; +} + +function validatePolicy(raw: string | Buffer): void { + parseOpenShellPolicy(raw.toString()); +} + +/** Observe the current OpenShell policy without comparing it with a local desired copy. */ +export function proveHermesPortableLivePolicy(input: { + readonly gatewayName: string; + readonly sandboxName: string; + readonly capture: HermesPortablePolicyCapture; +}): void { + const result = input.capture([ + "policy", + "get", + "-g", + input.gatewayName, + "--base", + input.sandboxName, + ]); + if (result.status !== 0 || result.error) { + throw new Error("Hermes portable live OpenShell policy read failed"); + } + validatePolicy(result.stdout); +} diff --git a/src/lib/onboard/experimental/hermes-portable-receipt.test.ts b/src/lib/onboard/experimental/hermes-portable-receipt.test.ts index 4fa0117f627..16057a8a507 100644 --- a/src/lib/onboard/experimental/hermes-portable-receipt.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-receipt.test.ts @@ -24,14 +24,14 @@ import { hermesPortableReceiptRoot, inspectPortableAgentReceiptAuthority, inspectPortableAgentReceiptAuthorityForPublicationRecovery, - readHermesPortableLifecycleReceiptForClassification, publishHermesPortableDurablePolicySource, publishHermesPortableLifecycleReceipt, publishHermesPortableSuccessorReceipt, readHermesPortableLifecycleReceipt, + retireHermesPortableCreatePolicyState, type HermesPortableConfiguredReceipt, type HermesPortablePendingReceipt, - type HermesPortablePolicyAuthority, + type HermesPortablePolicySource, type HermesPortableStartupContract, } from "./hermes-portable-receipt"; import { portableDemoReceiptPath } from "./portable-runtime-receipt-readiness"; @@ -224,12 +224,11 @@ function startup(): HermesPortableStartupContract { }; } -function policy(transactionId: string): HermesPortablePolicyAuthority { +function policy(transactionId: string): HermesPortablePolicySource { return publishHermesPortableDurablePolicySource({ sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source: captureHermesPortablePolicySource(policyPath), hooks: { assertLifecycleLock: () => {} }, }); @@ -262,12 +261,17 @@ function configuring( parent: ReturnType, overrides: Partial = {}, ): HermesPortableConfiguredReceipt { - const base = parent.receipt; + switch (parent.receipt.phase) { + case "pending": + break; + default: + throw new Error("pending fixture required"); + } + const { policy: _policy, ...base } = parent.receipt; return { ...base, phase: "configuring", previousPhaseSha256: parent.sha256, - verifiedLivePolicySemanticSha256: base.policy.intendedSemanticSha256, container: { containerId: CONTAINER_ID, sandboxId: SANDBOX_ID, @@ -369,7 +373,7 @@ afterEach(() => { fs.rmSync(stateDir, { recursive: true, force: true }); }); -describe("Hermes portable receipt authority", () => { +describe("Hermes portable receipt identity", () => { it("requires the shared sandbox lifecycle lock before any receipt publication (#9203)", () => { expect(() => publishHermesPortableLifecycleReceipt(pending(), stateDir)).toThrow( "requires the sandbox lifecycle lock", @@ -392,7 +396,7 @@ describe("Hermes portable receipt authority", () => { }); }); - it("rejects a schema-5 receipt without create intent before writing a stage (#9203)", () => { + it("rejects a schema-7 receipt without create intent before writing a stage (#9203)", () => { const receipt = pending(); const missingIntent = { ...receipt } as Record; delete missingIntent.createIntentSha256; @@ -403,7 +407,7 @@ describe("Hermes portable receipt authority", () => { expect(fs.readdirSync(directory).sort()).toEqual([`policy.${receipt.transactionId}.yaml`]); }); - it("rejects a schema-5 receipt outside the exact Podman 5.7.0 authority (#9203)", () => { + it("rejects a schema-7 receipt outside the exact Podman 5.7.0 authority (#9203)", () => { const receipt = pending(); const wrongVersion = { ...receipt, @@ -463,7 +467,7 @@ describe("Hermes portable receipt authority", () => { path: target, }); expect(fs.readFileSync(target)).toEqual(legacyBytes); - expect(() => pending()).toThrow("will not reserve policy over OpenClaw authority"); + expect(() => pending()).toThrow("will not reserve policy over an OpenClaw-owned source"); expect(inspectPortableAgentReceiptAuthority(SANDBOX, stateDir)).toEqual({ kind: "openclaw", path: target, @@ -503,7 +507,7 @@ describe("Hermes portable receipt authority", () => { }); }); - it("publishes deterministic schema-6 authority without changing schema-5 history (#10423)", () => { + it("publishes deterministic policy-free schema-8 authority (#10423)", () => { const historical = publishActiveReceipt(); const published = publishSuccessor(); @@ -521,7 +525,48 @@ describe("Hermes portable receipt authority", () => { expect(repeated.identity).toEqual(historical.identity); }); - it("rejects a foreign-owned higher socket directory before schema-6 publication (#10423)", () => { + it("retires policy-bearing create history after policy-free authority is durable (#10514)", () => { + const historical = publishActiveReceipt(); + const published = publishSuccessor(); + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const sourcePath = hermesPortablePolicySourcePath( + SANDBOX, + historical.receipt.transactionId, + stateDir, + ); + + expect(fs.existsSync(sourcePath)).toBe(true); + const compacted = withMcpLifecycleLockSync( + SANDBOX, + () => + retireHermesPortableCreatePolicyState(SANDBOX, historical.receipt.transactionId, stateDir), + { stateDir: path.join(stateDir, "state") }, + ); + + expect(fs.readdirSync(directory).sort()).toEqual(["active.json", "authority.json"]); + expect(compacted.bytes).toEqual(historical.bytes); + expect(compacted.successor.bytes).toEqual(published.successor.bytes); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toEqual(compacted); + }); + + it("reads policy-free authority across interrupted history retirement (#10514)", () => { + const activeSnapshot = publishActiveReceipt(); + publishSuccessor(); + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const sourcePath = hermesPortablePolicySourcePath( + SANDBOX, + activeSnapshot.receipt.transactionId, + stateDir, + ); + + fs.unlinkSync(sourcePath); + fs.unlinkSync(path.join(directory, "pending.json")); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)?.successor).toBeDefined(); + fs.unlinkSync(path.join(directory, "configuring.json")); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)?.successor).toBeDefined(); + }); + + it("rejects a foreign-owned higher socket directory before schema-8 publication (#10423)", () => { const socket = socketAuthority(); const reserved = publish( pending({ @@ -539,76 +584,7 @@ describe("Hermes portable receipt authority", () => { expect(() => publishSuccessor()).toThrow("has invalid stable directory authority"); }); - it("rejects direct schema-6 publication after an identical policy copy (#10423)", () => { - const historical = publishActiveReceipt(); - const policyTarget = historical.receipt.policy.sourcePath; - const replacement = `${policyTarget}.replacement`; - fs.writeFileSync(replacement, fs.readFileSync(policyTarget), { mode: 0o600 }); - fs.renameSync(replacement, policyTarget); - - expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( - "durable policy source disagrees with its receipt authority", - ); - expect( - readHermesPortableLifecycleReceiptForClassification(SANDBOX, stateDir)?.receipt.phase, - ).toBe("active"); - - expect(() => publishSuccessor()).toThrow( - "durable policy source disagrees with its receipt authority", - ); - expect(readHermesPortableLifecycleReceiptForClassification(SANDBOX, stateDir)?.successor).toBe( - undefined, - ); - }); - - it("rejects changed policy bytes before schema-6 publication (#10423)", () => { - publishActiveReceipt(); - const policyTarget = readHermesPortableLifecycleReceipt(SANDBOX, stateDir)!.receipt.policy - .sourcePath; - fs.writeFileSync(policyTarget, "version: 1\nnetwork_policies:\n changed: {}\n", { - mode: 0o600, - }); - - expect(() => readHermesPortableLifecycleReceiptForClassification(SANDBOX, stateDir)).toThrow( - "durable policy source disagrees with its semantic authority", - ); - expect(() => publishSuccessor()).toThrow( - "durable policy source disagrees with its receipt authority", - ); - }); - - it.each([ - [ - "symlink substitution", - (target: string) => { - const backing = path.join(stateDir, "policy-symlink-backing.yaml"); - fs.renameSync(target, backing); - fs.symlinkSync(backing, target); - }, - () => "ELOOP", - ], - [ - "hard-link substitution", - (target: string) => fs.linkSync(target, path.join(stateDir, "policy-hardlink.yaml")), - (target: string) => `file is unsafe: ${target}`, - ], - [ - "unsafe mode", - (target: string) => fs.chmodSync(target, 0o644), - (target: string) => `file is unsafe: ${target}`, - ], - ])("rejects %s during schema-6 requalification (#10423)", (_label, mutate, expected) => { - publishActiveReceipt(); - const target = readHermesPortableLifecycleReceipt(SANDBOX, stateDir)!.receipt.policy.sourcePath; - mutate(target); - - expect(() => readHermesPortableLifecycleReceiptForClassification(SANDBOX, stateDir)).toThrow( - expected(target), - ); - expect(() => publishSuccessor()).toThrow(expected(target)); - }); - - it("reconciles an exact interrupted schema-6 publication (#10423)", () => { + it("reconciles an exact interrupted schema-8 publication (#10423)", () => { publishActiveReceipt(); expect(() => withMcpLifecycleLockSync( @@ -616,12 +592,12 @@ describe("Hermes portable receipt authority", () => { () => publishHermesPortableSuccessorReceipt(SANDBOX, stateDir, { afterCanonicalLink: () => { - throw new Error("simulated schema-6 process exit"); + throw new Error("simulated schema-8 process exit"); }, }), { stateDir: path.join(stateDir, "state") }, ), - ).toThrow("simulated schema-6 process exit"); + ).toThrow("simulated schema-8 process exit"); expect(hasHermesPortableReceiptCandidate(SANDBOX, stateDir)).toBe(true); expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( "incomplete or unknown publication evidence", @@ -634,7 +610,7 @@ describe("Hermes portable receipt authority", () => { ), ).toMatchObject({ kind: "hermes", - snapshot: { receipt: { phase: "active" }, successor: { receipt: { schemaVersion: 6 } } }, + snapshot: { receipt: { phase: "active" }, successor: { receipt: { schemaVersion: 8 } } }, }); const recovered = publishSuccessor(); @@ -1025,7 +1001,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, hooks: { assertLifecycleLock: () => {}, @@ -1050,7 +1025,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, } as const; @@ -1084,7 +1058,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, } as const; @@ -1104,7 +1077,6 @@ describe("Hermes portable receipt authority", () => { path.dirname(target), transactionId, source.sha256, - input.intendedSemanticSha256, ); const external = path.join(stateDir, "unaccounted-policy-link.yaml"); fs.linkSync(target, external); @@ -1131,7 +1103,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, } as const; installShortWrite(prefixLength); @@ -1161,7 +1132,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, } as const; const authority = publishHermesPortableDurablePolicySource({ @@ -1172,7 +1142,6 @@ describe("Hermes portable receipt authority", () => { path.dirname(authority.sourcePath), transactionId, source.sha256, - input.intendedSemanticSha256, ); const cleanup = `${staged}.cleanup`; fs.linkSync(authority.sourcePath, cleanup); @@ -1204,14 +1173,12 @@ describe("Hermes portable receipt authority", () => { directory, transactionId, source.sha256, - "f".repeat(64), ); expect(() => publishHermesPortableDurablePolicySource({ sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, hooks: { assertLifecycleLock: () => {}, @@ -1232,11 +1199,10 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source: captureHermesPortablePolicySource(policyPath), hooks: { assertLifecycleLock: () => {} }, }), - ).toThrow("directory contains other policy authority"); + ).toThrow("directory contains other policy source"); expect(fs.readFileSync(staged)).toEqual(prior); expect(fs.statSync(staged).ino).toBe(priorIdentity); expect(fs.existsSync(hermesPortablePolicySourcePath(SANDBOX, transactionId, stateDir))).toBe( @@ -1251,7 +1217,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, } as const; @@ -1282,7 +1247,6 @@ describe("Hermes portable receipt authority", () => { sandboxName: SANDBOX, transactionId, stateDir, - intendedSemanticSha256: "f".repeat(64), source, } as const; vi.spyOn(fs, "fsyncSync").mockImplementationOnce(() => { diff --git a/src/lib/onboard/experimental/hermes-portable-receipt.ts b/src/lib/onboard/experimental/hermes-portable-receipt.ts index 0f9489db3b3..b5631e5199f 100644 --- a/src/lib/onboard/experimental/hermes-portable-receipt.ts +++ b/src/lib/onboard/experimental/hermes-portable-receipt.ts @@ -22,8 +22,8 @@ import { type HermesPortablePodmanExecutableAuthority, } from "./hermes-portable-podman-authority"; -export const HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION = 5 as const; -export const HERMES_PORTABLE_SUCCESSOR_SCHEMA_VERSION = 6 as const; +export const HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION = 7 as const; +export const HERMES_PORTABLE_SUCCESSOR_SCHEMA_VERSION = 8 as const; export const HERMES_PORTABLE_RECEIPT_DIRECTORY = "hermes-portable-lifecycle"; const RECEIPT_MODE = 0o600; @@ -62,10 +62,9 @@ export interface HermesPortableStartupContract { readonly stateIdentitySha256: string; } -export interface HermesPortablePolicyAuthority { +export interface HermesPortablePolicySource { readonly sourcePath: string; readonly sourceSha256: string; - readonly intendedSemanticSha256: string; readonly sourceIdentity: { readonly dev: string; readonly ino: string; @@ -130,15 +129,6 @@ export interface HermesPortableSuccessorReceipt { }; readonly socketAuthority: HermesPortableStableSocketAuthority; readonly startup: HermesPortableStartupContract; - readonly policy: { - readonly sourcePath: string; - readonly sourceSha256: string; - readonly intendedSemanticSha256: string; - readonly size: string; - readonly mode: 384; - readonly uid: number; - }; - readonly verifiedLivePolicySemanticSha256: string; readonly container: HermesPortableContainerAuthority; } @@ -166,17 +156,16 @@ interface HermesPortableReceiptCommon { readonly podmanExecutableAuthority: HermesPortablePodmanExecutableAuthority; readonly socketAuthority: PodmanSocketAuthority; readonly startup: HermesPortableStartupContract; - readonly policy: HermesPortablePolicyAuthority; } export interface HermesPortablePendingReceipt extends HermesPortableReceiptCommon { readonly phase: "pending"; + readonly policy: HermesPortablePolicySource; } export interface HermesPortableConfiguredReceipt extends HermesPortableReceiptCommon { readonly phase: "configuring" | "active"; readonly previousPhaseSha256: string; - readonly verifiedLivePolicySemanticSha256: string; readonly container: HermesPortableContainerAuthority; } @@ -194,7 +183,7 @@ export interface HermesPortableReceiptSnapshot { readonly ino: bigint; }; readonly successor?: HermesPortableSuccessorSnapshot; - /** Exact same-predecessor schema-6 publication evidence awaiting reconciliation. */ + /** Exact same-predecessor schema-8 publication evidence awaiting reconciliation. */ readonly successorPublicationPending?: true; } @@ -312,22 +301,16 @@ function parseStartup(value: unknown): HermesPortableStartupContract { return startup as unknown as HermesPortableStartupContract; } -function parsePolicy(value: unknown): HermesPortablePolicyAuthority { +function parsePolicy(value: unknown): HermesPortablePolicySource { const policy = record(value); const identity = record(policy?.sourceIdentity); if ( !policy || - !exactKeys(policy, [ - "intendedSemanticSha256", - "sourceIdentity", - "sourcePath", - "sourceSha256", - ]) || + !exactKeys(policy, ["sourceIdentity", "sourcePath", "sourceSha256"]) || !identity || !exactKeys(identity, ["ctimeNs", "dev", "ino", "mode", "mtimeNs", "size", "uid"]) || !exactAbsolutePath(policy.sourcePath) || !SHA256.test(String(policy.sourceSha256)) || - !SHA256.test(String(policy.intendedSemanticSha256)) || !DECIMAL.test(String(identity.dev)) || !DECIMAL.test(String(identity.ino)) || !DECIMAL.test(String(identity.size)) || @@ -336,9 +319,9 @@ function parsePolicy(value: unknown): HermesPortablePolicyAuthority { identity.mode !== RECEIPT_MODE || identity.uid !== currentUid() ) { - fail("has invalid policy authority"); + fail("has invalid policy source"); } - return policy as unknown as HermesPortablePolicyAuthority; + return policy as unknown as HermesPortablePolicySource; } function parseContainer( @@ -658,12 +641,11 @@ function parseSuccessorBytes(bytes: Buffer): HermesPortableSuccessorReceipt { try { value = JSON.parse(UTF8.decode(bytes)); } catch { - fail("schema-6 successor is malformed or is not strict UTF-8"); + fail("schema-8 successor is malformed or is not strict UTF-8"); } const receipt = record(value); const openshell = record(receipt?.openshellExecutableAuthority); const podman = record(receipt?.podmanExecutableAuthority); - const policy = record(receipt?.policy); const runtimeAuthority = parsePortableRuntimeAuthority(receipt?.runtimeAuthority); if ( !receipt || @@ -676,7 +658,6 @@ function parseSuccessorBytes(bytes: Buffer): HermesPortableSuccessorReceipt { "openshellExecutableAuthority", "phase", "podmanExecutableAuthority", - "policy", "predecessorActiveSha256", "runtimeAuthority", "sandboxName", @@ -684,7 +665,6 @@ function parseSuccessorBytes(bytes: Buffer): HermesPortableSuccessorReceipt { "socketAuthority", "startup", "transactionId", - "verifiedLivePolicySemanticSha256", ]) || receipt.schemaVersion !== HERMES_PORTABLE_SUCCESSOR_SCHEMA_VERSION || receipt.phase !== "active" || @@ -703,24 +683,9 @@ function parseSuccessorBytes(bytes: Buffer): HermesPortableSuccessorReceipt { !podman || !exactKeys(podman, ["executable", "version"]) || podman.version !== HERMES_PORTABLE_PODMAN_VERSION || - !policy || - !exactKeys(policy, [ - "intendedSemanticSha256", - "mode", - "size", - "sourcePath", - "sourceSha256", - "uid", - ]) || - !exactAbsolutePath(policy.sourcePath) || - !SHA256.test(String(policy.sourceSha256)) || - !SHA256.test(String(policy.intendedSemanticSha256)) || - !DECIMAL.test(String(policy.size)) || - policy.mode !== RECEIPT_MODE || - policy.uid !== currentUid() || - !SHA256.test(String(receipt.verifiedLivePolicySemanticSha256)) + podman.version !== HERMES_PORTABLE_PODMAN_VERSION ) { - fail("has invalid schema-6 successor authority"); + fail("has invalid schema-8 successor authority"); } return { schemaVersion: HERMES_PORTABLE_SUCCESSOR_SCHEMA_VERSION, @@ -743,15 +708,6 @@ function parseSuccessorBytes(bytes: Buffer): HermesPortableSuccessorReceipt { }, socketAuthority: parseStableSocketAuthority(receipt.socketAuthority, runtimeAuthority), startup: parseStartup(receipt.startup), - policy: { - sourcePath: policy.sourcePath as string, - sourceSha256: policy.sourceSha256 as string, - intendedSemanticSha256: policy.intendedSemanticSha256 as string, - size: policy.size as string, - mode: RECEIPT_MODE, - uid: currentUid(), - }, - verifiedLivePolicySemanticSha256: receipt.verifiedLivePolicySemanticSha256 as string, container: parseContainer(receipt.container, "active"), }; } @@ -774,14 +730,13 @@ function parseReceiptBytes(bytes: Buffer): HermesPortableLifecycleReceipt { "openshellExecutableAuthority", "podmanExecutableAuthority", "phase", - "policy", "runtimeAuthority", "sandboxName", "schemaVersion", "socketAuthority", "startup", "transactionId", - ...(configured ? ["container", "previousPhaseSha256", "verifiedLivePolicySemanticSha256"] : []), + ...(configured ? ["container", "previousPhaseSha256"] : ["policy"]), ]; const authority = parsePortableRuntimeAuthority(receipt?.runtimeAuthority); if ( @@ -815,20 +770,15 @@ function parseReceiptBytes(bytes: Buffer): HermesPortableLifecycleReceipt { podmanExecutableAuthority: parsePodmanExecutableAuthority(receipt.podmanExecutableAuthority), socketAuthority: parseSocketAuthority(receipt.socketAuthority, authority), startup: parseStartup(receipt.startup), - policy: parsePolicy(receipt.policy), }; - if (phase === "pending") return { ...common, phase }; - if ( - !SHA256.test(String(receipt.previousPhaseSha256)) || - !SHA256.test(String(receipt.verifiedLivePolicySemanticSha256)) - ) { + if (phase === "pending") return { ...common, phase, policy: parsePolicy(receipt.policy) }; + if (!SHA256.test(String(receipt.previousPhaseSha256))) { fail("has invalid phase authority"); } return { ...common, phase, previousPhaseSha256: receipt.previousPhaseSha256 as string, - verifiedLivePolicySemanticSha256: receipt.verifiedLivePolicySemanticSha256 as string, container: parseContainer(receipt.container, phase), }; } @@ -881,7 +831,7 @@ export function stableHermesPortableSocketAuthority( export function createHermesPortableSuccessorReceipt( active: HermesPortableReceiptSnapshot & { readonly receipt: HermesPortableConfiguredReceipt }, ): HermesPortableSuccessorReceipt { - if (active.receipt.phase !== "active") fail("schema-6 successor requires active authority"); + if (active.receipt.phase !== "active") fail("schema-8 successor requires active authority"); const receipt = active.receipt; return { schemaVersion: HERMES_PORTABLE_SUCCESSOR_SCHEMA_VERSION, @@ -908,15 +858,6 @@ export function createHermesPortableSuccessorReceipt( }, socketAuthority: stableHermesPortableSocketAuthority(receipt.socketAuthority), startup: receipt.startup, - policy: { - sourcePath: receipt.policy.sourcePath, - sourceSha256: receipt.policy.sourceSha256, - intendedSemanticSha256: receipt.policy.intendedSemanticSha256, - size: receipt.policy.sourceIdentity.size, - mode: receipt.policy.sourceIdentity.mode, - uid: receipt.policy.sourceIdentity.uid, - }, - verifiedLivePolicySemanticSha256: receipt.verifiedLivePolicySemanticSha256, container: receipt.container, }; } @@ -925,7 +866,7 @@ function serializeSuccessor(receipt: HermesPortableSuccessorReceipt): Buffer { const normalized = parseSuccessorBytes(Buffer.from(`${JSON.stringify(receipt)}\n`, "utf8")); const bytes = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8"); if (bytes.byteLength > Number(MAX_RECEIPT_BYTES)) { - fail("serialized schema-6 successor exceeds the bounded receipt size"); + fail("serialized schema-8 successor exceeds the bounded receipt size"); } return bytes; } @@ -972,7 +913,7 @@ export function hermesPortablePolicySourcePath( function policyPublicationTransactionId(entry: string): string | null { const match = - /^(?:policy\.([a-f0-9-]{36})\.yaml|\.policy\.([a-f0-9-]{36})\.[a-f0-9]{64}\.[a-f0-9]{64}\.next(?:\.cleanup)?)$/u.exec( + /^(?:policy\.([a-f0-9-]{36})\.yaml|\.policy\.([a-f0-9-]{36})\.[a-f0-9]{64}\.next(?:\.cleanup)?)$/u.exec( entry, ); const transactionId = match?.[1] ?? match?.[2]; @@ -1021,16 +962,8 @@ function stagePath(directory: string, receipt: HermesPortableLifecycleReceipt): ); } -function policyStagePath( - directory: string, - transactionId: string, - sourceSha256: string, - intendedSemanticSha256: string, -): string { - return path.join( - directory, - `.policy.${transactionId}.${sourceSha256}.${intendedSemanticSha256}.next`, - ); +function policyStagePath(directory: string, transactionId: string, sourceSha256: string): string { + return path.join(directory, `.policy.${transactionId}.${sourceSha256}.next`); } function cleanupPath(target: string): string { @@ -1183,12 +1116,7 @@ function readExactFile( } } -function policyAuthorityFromFile( - target: string, - file: ExactFile, - intendedSemanticSha256: string, -): HermesPortablePolicyAuthority { - if (!SHA256.test(intendedSemanticSha256)) fail("has an invalid intended policy digest"); +function policySourceFromFile(target: string, file: ExactFile): HermesPortablePolicySource { try { UTF8.decode(file.bytes); } catch { @@ -1197,7 +1125,6 @@ function policyAuthorityFromFile( return { sourcePath: target, sourceSha256: receiptHash(file.bytes), - intendedSemanticSha256, sourceIdentity: { dev: String(file.identity.dev), ino: String(file.identity.ino), @@ -1210,16 +1137,16 @@ function policyAuthorityFromFile( }; } -function samePolicyIdentity(authority: HermesPortablePolicyAuthority, file: ExactFile): boolean { +function samePolicyIdentity(source: HermesPortablePolicySource, file: ExactFile): boolean { return ( - authority.sourceSha256 === receiptHash(file.bytes) && - authority.sourceIdentity.dev === String(file.identity.dev) && - authority.sourceIdentity.ino === String(file.identity.ino) && - authority.sourceIdentity.size === String(file.identity.size) && - authority.sourceIdentity.mode === RECEIPT_MODE && - authority.sourceIdentity.uid === currentUid() && - authority.sourceIdentity.mtimeNs === String(file.identity.mtimeNs) && - authority.sourceIdentity.ctimeNs === String(file.identity.ctimeNs) + source.sourceSha256 === receiptHash(file.bytes) && + source.sourceIdentity.dev === String(file.identity.dev) && + source.sourceIdentity.ino === String(file.identity.ino) && + source.sourceIdentity.size === String(file.identity.size) && + source.sourceIdentity.mode === RECEIPT_MODE && + source.sourceIdentity.uid === currentUid() && + source.sourceIdentity.mtimeNs === String(file.identity.mtimeNs) && + source.sourceIdentity.ctimeNs === String(file.identity.ctimeNs) ); } @@ -1276,12 +1203,10 @@ function assertHermesPortablePolicyPublicationSource( } } -export function assertHermesPortableDurablePolicyAuthority( - authority: HermesPortablePolicyAuthority, -): Buffer { - const file = readExactFile(authority.sourcePath, 1n, MAX_POLICY_BYTES); - if (!file || !samePolicyIdentity(authority, file)) { - fail("durable policy source disagrees with its receipt authority"); +export function assertHermesPortablePolicySource(source: HermesPortablePolicySource): Buffer { + const file = readExactFile(source.sourcePath, 1n, MAX_POLICY_BYTES); + if (!file || !samePolicyIdentity(source, file)) { + fail("durable policy source disagrees with its receipt"); } try { UTF8.decode(file.bytes); @@ -1291,26 +1216,96 @@ export function assertHermesPortableDurablePolicyAuthority( return file.bytes; } -export function requalifyHermesPortablePolicyAuthority(authority: HermesPortablePolicyAuthority): { - readonly authority: HermesPortablePolicyAuthority; +/** Retire the bounded create-policy copy and receipt history after OpenShell owns policy. */ +export function retireHermesPortableCreatePolicyState( + sandboxName: string, + transactionId: string, + stateDir: string, +): HermesPortableReceiptSnapshot & { + readonly receipt: HermesPortableConfiguredReceipt; + readonly successor: HermesPortableSuccessorSnapshot; +} { + if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { + fail(`create-policy retirement requires the sandbox lifecycle lock for '${sandboxName}'`); + } + const active = readHermesPortableLifecycleReceipt(sandboxName, stateDir); + if ( + !active || + active.receipt.phase !== "active" || + !active.successor || + active.receipt.transactionId !== transactionId + ) { + fail("create-policy retirement requires policy-free active operating authority"); + } + + const directory = validateDirectory(path.dirname(active.path)); + try { + revalidateDirectory(directory); + const pending = readPhase(directory.path, "pending"); + const configuring = readPhase(directory.path, "configuring"); + for (const snapshot of [pending, configuring]) { + if (snapshot && snapshot.receipt.transactionId !== transactionId) { + fail("create-policy retirement found another transaction generation"); + } + } + + const sourcePath = hermesPortablePolicySourcePath(sandboxName, transactionId, stateDir); + const source = readExactFile(sourcePath, 1n, MAX_POLICY_BYTES); + if (source) { + if (pending?.receipt.phase === "pending") { + assertHermesPortablePolicySource(pending.receipt.policy); + } + fs.unlinkSync(sourcePath); + } + for (const snapshot of [pending, configuring]) { + if (!snapshot) continue; + const current = readExactFile(snapshot.path, 1n, MAX_RECEIPT_BYTES); + if ( + !current || + current.identity.dev !== snapshot.identity.dev || + current.identity.ino !== snapshot.identity.ino || + !current.bytes.equals(snapshot.bytes) + ) { + fail("create-policy receipt history changed before retirement"); + } + fs.unlinkSync(snapshot.path); + } + fs.fsyncSync(directory.descriptor); + } finally { + fs.closeSync(directory.descriptor); + } + + const compacted = readHermesPortableLifecycleReceipt(sandboxName, stateDir); + if ( + !compacted || + compacted.receipt.phase !== "active" || + !compacted.successor || + compacted.receipt.transactionId !== transactionId + ) { + fail("policy-free active operating authority could not be requalified after retirement"); + } + return compacted as HermesPortableReceiptSnapshot & { + readonly receipt: HermesPortableConfiguredReceipt; + readonly successor: HermesPortableSuccessorSnapshot; + }; +} + +export function requalifyHermesPortablePolicySource(source: HermesPortablePolicySource): { + readonly source: HermesPortablePolicySource; readonly bytes: Buffer; } { - const file = readExactFile(authority.sourcePath, 1n, MAX_POLICY_BYTES); + const file = readExactFile(source.sourcePath, 1n, MAX_POLICY_BYTES); if ( !file || - authority.sourceSha256 !== receiptHash(file.bytes) || - authority.sourceIdentity.size !== String(file.identity.size) || - authority.sourceIdentity.mode !== RECEIPT_MODE || - authority.sourceIdentity.uid !== currentUid() + source.sourceSha256 !== receiptHash(file.bytes) || + source.sourceIdentity.size !== String(file.identity.size) || + source.sourceIdentity.mode !== RECEIPT_MODE || + source.sourceIdentity.uid !== currentUid() ) { - fail("durable policy source disagrees with its semantic authority"); + fail("durable policy source disagrees with its semantic digest"); } - const current = policyAuthorityFromFile( - authority.sourcePath, - file, - authority.intendedSemanticSha256, - ); - return { authority: current, bytes: file.bytes }; + const current = policySourceFromFile(source.sourcePath, file); + return { source: current, bytes: file.bytes }; } function writeStage( @@ -1575,10 +1570,9 @@ export function publishHermesPortableDurablePolicySource(input: { readonly sandboxName: string; readonly transactionId: string; readonly stateDir: string; - readonly intendedSemanticSha256: string; readonly source: HermesPortablePolicyPublicationSource; readonly hooks?: HermesPortableReceiptPublicationHooks; -}): HermesPortablePolicyAuthority { +}): HermesPortablePolicySource { const hooks = input.hooks ?? {}; const assertLifecycleLock = hooks.assertLifecycleLock ?? @@ -1590,7 +1584,7 @@ export function publishHermesPortableDurablePolicySource(input: { assertLifecycleLock(); assertHermesPortablePolicyPublicationSource(input.source); if (existingPath(portableDemoReceiptPath(input.sandboxName, input.stateDir))) { - fail(`will not reserve policy over OpenClaw authority for '${input.sandboxName}'`); + fail(`will not reserve policy over an OpenClaw-owned source for '${input.sandboxName}'`); } const directory = ensureReceiptDirectory(input.sandboxName, input.stateDir); const target = hermesPortablePolicySourcePath( @@ -1598,12 +1592,7 @@ export function publishHermesPortableDurablePolicySource(input: { input.transactionId, input.stateDir, ); - const staged = policyStagePath( - directory.path, - input.transactionId, - input.source.sha256, - input.intendedSemanticSha256, - ); + const staged = policyStagePath(directory.path, input.transactionId, input.source.sha256); const cleanup = cleanupPath(staged); try { revalidateDirectory(directory); @@ -1624,7 +1613,7 @@ export function publishHermesPortableDurablePolicySource(input: { pendingPublicationTransactionId(entry) !== input.transactionId, ); if (unexpected.length > 0) { - fail(`directory contains other policy authority for '${input.sandboxName}'`); + fail(`directory contains other policy source for '${input.sandboxName}'`); } retireInterruptedEmptyStage( target, @@ -1654,12 +1643,13 @@ export function publishHermesPortableDurablePolicySource(input: { ); const reconciled = readExactFile(target, 1n, MAX_POLICY_BYTES); if (reconciled) { - if (!reconciled.bytes.equals(input.source.bytes)) fail("durable policy has other authority"); + if (!reconciled.bytes.equals(input.source.bytes)) + fail("durable policy has different content"); assertHermesPortablePolicyPublicationSource(input.source); fs.fsyncSync(directory.descriptor); - return policyAuthorityFromFile(target, reconciled, input.intendedSemanticSha256); + return policySourceFromFile(target, reconciled); } - if (disposition === "complete") fail("completed policy publication has no readable authority"); + if (disposition === "complete") fail("completed policy publication has no readable source"); if (disposition === "absent") writeStage(staged, input.source.bytes, hooks); revalidateDirectory(directory); assertHermesPortablePolicyPublicationSource(input.source); @@ -1672,7 +1662,7 @@ export function publishHermesPortableDurablePolicySource(input: { if (!isErrnoException(error) || error.code !== "EEXIST") throw error; const raced = readExactFile(target, 1n, MAX_POLICY_BYTES); if (!raced || !raced.bytes.equals(input.source.bytes)) { - fail("durable policy publication raced other authority"); + fail("durable policy publication raced another writer"); } } hooks.afterCanonicalLink?.(); @@ -1687,7 +1677,7 @@ export function publishHermesPortableDurablePolicySource(input: { if (!published || !published.bytes.equals(input.source.bytes)) { fail("durable policy publication did not preserve exact bytes"); } - return policyAuthorityFromFile(target, published, input.intendedSemanticSha256); + return policySourceFromFile(target, published); } finally { fs.closeSync(directory.descriptor); } @@ -1725,7 +1715,7 @@ export function recoverableHermesPortablePolicyTransactionId( transactionIds.some((transactionId) => transactionId === null) || new Set(transactionIds).size !== 1 ) { - fail(`directory has ambiguous pre-receipt policy authority for '${sandboxName}'`); + fail(`directory has ambiguous pre-receipt policy source for '${sandboxName}'`); } revalidateDirectory(directory); return transactionIds[0]!; @@ -1736,16 +1726,16 @@ export function recoverableHermesPortablePolicyTransactionId( function validateReceiptPolicySource( directoryPath: string, - receipt: HermesPortableLifecycleReceipt, + receipt: HermesPortablePendingReceipt, semanticOnly = false, -): HermesPortablePolicyAuthority { +): HermesPortablePolicySource { const expected = path.join(directoryPath, policySourceBasename(receipt.transactionId)); if (receipt.policy.sourcePath !== expected) fail("policy source path is outside receipt custody"); if (!semanticOnly) { - assertHermesPortableDurablePolicyAuthority(receipt.policy); + assertHermesPortablePolicySource(receipt.policy); return receipt.policy; } - return requalifyHermesPortablePolicyAuthority(receipt.policy).authority; + return requalifyHermesPortablePolicySource(receipt.policy).source; } function readPhase( @@ -1790,14 +1780,13 @@ function sameTransaction( ): boolean { const transactionAuthority = (receipt: HermesPortableLifecycleReceipt) => { if (receipt.phase === "pending") { - const { phase: _phase, ...common } = receipt; + const { phase: _phase, policy: _policy, ...common } = receipt; return common; } const { phase: _phase, container: _container, previousPhaseSha256: _previous, - verifiedLivePolicySemanticSha256: _verified, ...common } = receipt; return common; @@ -1898,22 +1887,28 @@ function readHermesPortableLifecycleReceiptInternal( const pending = readPhase(directoryPath, "pending", allowPublicationRecovery); const configuring = readPhase(directoryPath, "configuring", allowPublicationRecovery); const active = readPhase(directoryPath, "active", allowPublicationRecovery); - if (!pending) { - if (entries.length > 0) { + const successor = hasSuccessor + ? readSuccessor(directoryPath, allowSuccessorPublicationRecovery) + : null; + if (!pending && !active) { + if (entries.length > 0) fail(`directory contains incomplete or unknown publication evidence for '${sandboxName}'`); - } revalidateDirectory(directory); return null; } + if (!pending && (!active || !successor)) { + fail(`directory contains incomplete or unknown publication evidence for '${sandboxName}'`); + } + const transactionId = pending?.receipt.transactionId ?? active!.receipt.transactionId; const allowedEntries = new Set([ "active.json", "authority.json", "configuring.json", "pending.json", - policySourceBasename(pending.receipt.transactionId), + policySourceBasename(transactionId), ]); const highestPhase = active ? "active" : configuring ? "configuring" : "pending"; - if (allowPublicationRecovery) { + if (allowPublicationRecovery && pending) { validateRecoverablePhaseArtifacts(directoryPath, entries, pending, highestPhase); } if ( @@ -1926,44 +1921,61 @@ function readHermesPortableLifecycleReceiptInternal( ) { fail(`directory contains incomplete or unknown publication evidence for '${sandboxName}'`); } - const currentPolicy = validateReceiptPolicySource( - directoryPath, - pending.receipt, - hasSuccessor || semanticPolicyRequalification, - ); + if (pending && !configuring && !active) { + validateReceiptPolicySource( + directoryPath, + pending.receipt as HermesPortablePendingReceipt, + hasSuccessor || semanticPolicyRequalification, + ); + } revalidateDirectory(directory); - if (!configuring && active) fail("phase chain is missing configuring authority"); - if (pending.receipt.sandboxName !== sandboxName) - fail("sandbox identity does not match its path"); + for (const snapshot of [pending, configuring, active]) { + if (snapshot && snapshot.receipt.sandboxName !== sandboxName) { + fail("sandbox identity does not match its path"); + } + } if (configuring) { - if ( - configuring.receipt.phase !== "configuring" || - configuring.receipt.previousPhaseSha256 !== pending.sha256 || - !sameTransaction(pending.receipt, configuring.receipt) - ) { - fail("configuring phase does not extend pending authority"); + if (configuring.receipt.phase !== "configuring") { + fail("configuring phase contains invalid authority"); + } + if (pending) { + if ( + configuring.receipt.previousPhaseSha256 !== pending.sha256 || + !sameTransaction(pending.receipt, configuring.receipt) + ) { + fail("configuring phase does not extend pending authority"); + } + } else if (!active || !successor) { + fail("configuring phase has no pending authority"); } } if (active) { - if (!configuring) fail("active phase has no configuring authority"); - const configuringReceipt = configuring.receipt; const activeReceipt = active.receipt; - if (configuringReceipt.phase !== "configuring" || activeReceipt.phase !== "active") { + if (activeReceipt.phase !== "active") { fail("active phase files contain invalid phase authority"); } - if ( - activeReceipt.previousPhaseSha256 !== configuring.sha256 || - !sameTransaction(configuringReceipt, activeReceipt) || - activeReceipt.container.containerId !== configuringReceipt.container.containerId || - activeReceipt.container.sandboxId !== configuringReceipt.container.sandboxId || - activeReceipt.container.imageId !== configuringReceipt.container.imageId || - activeReceipt.verifiedLivePolicySemanticSha256 !== - configuringReceipt.verifiedLivePolicySemanticSha256 - ) { - fail("active phase does not extend configuring authority"); + if (configuring) { + const configuringReceipt = configuring.receipt; + if (configuringReceipt.phase !== "configuring") { + fail("configuring phase contains invalid authority"); + } + if ( + activeReceipt.previousPhaseSha256 !== configuring.sha256 || + !sameTransaction(configuringReceipt, activeReceipt) || + activeReceipt.container.containerId !== configuringReceipt.container.containerId || + activeReceipt.container.sandboxId !== configuringReceipt.container.sandboxId || + activeReceipt.container.imageId !== configuringReceipt.container.imageId + ) { + fail("active phase does not extend configuring authority"); + } + } else if (!successor) { + fail("active phase has no configuring authority"); + } else if (pending && !sameTransaction(pending.receipt, activeReceipt)) { + fail("active phase disagrees with pending transaction history"); } } const highest = active ?? configuring ?? pending; + if (!highest) fail("lifecycle receipt has no complete authority"); const successorArtifacts = entries .map((entry) => successorPublicationIdentity(entry)) .filter((entry): entry is NonNullable => entry !== null); @@ -1975,14 +1987,13 @@ function readHermesPortableLifecycleReceiptInternal( ({ predecessorActiveSha256 }) => predecessorActiveSha256 !== active.sha256, )) ) { - fail("schema-6 publication recovery evidence disagrees with active authority"); + fail("schema-8 publication recovery evidence disagrees with active authority"); } if (hasSuccessor) { - if (!active || highest.receipt.phase !== "active") { - fail("schema-6 successor has no active schema-5 predecessor"); + if (!active) { + fail("schema-8 successor has no active schema-7 predecessor"); } - const successor = readSuccessor(directoryPath, allowSuccessorPublicationRecovery); - if (!successor) fail("schema-6 successor authority disappeared"); + if (!successor) fail("schema-8 successor authority disappeared"); const expected = createHermesPortableSuccessorReceipt( active as HermesPortableReceiptSnapshot & { readonly receipt: HermesPortableConfiguredReceipt; @@ -1992,11 +2003,10 @@ function readHermesPortableLifecycleReceiptInternal( successor.receipt.predecessorActiveSha256 !== active.sha256 || !isDeepStrictEqual(successor.receipt, expected) ) { - fail("schema-6 successor disagrees with its schema-5 predecessor"); + fail("schema-8 successor disagrees with its schema-7 predecessor"); } return { ...active, - receipt: { ...active.receipt, policy: currentPolicy }, successor, ...(successorArtifacts.length > 0 ? { successorPublicationPending: true as const } : {}), }; @@ -2004,7 +2014,6 @@ function readHermesPortableLifecycleReceiptInternal( if (semanticPolicyRequalification && active) { return { ...active, - receipt: { ...active.receipt, policy: currentPolicy }, ...(successorArtifacts.length > 0 ? { successorPublicationPending: true as const } : {}), }; } @@ -2024,13 +2033,13 @@ export function readHermesPortableLifecycleReceipt( return readHermesPortableLifecycleReceiptInternal(sandboxName, stateDir, false); } -/** Read exact schema-5 bytes while permitting only operation-local policy identity drift. */ +/** Read exact schema-7 bytes while permitting only operation-local policy identity drift. */ export function readHermesPortableLifecycleReceiptForRequalification( sandboxName: string, stateDir: string, ): HermesPortableReceiptSnapshot | null { if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { - fail(`schema-6 requalification requires the sandbox lifecycle lock for '${sandboxName}'`); + fail(`schema-8 requalification requires the sandbox lifecycle lock for '${sandboxName}'`); } return readHermesPortableLifecycleReceiptInternal(sandboxName, stateDir, false, true, true); } @@ -2043,7 +2052,7 @@ export function readHermesPortableLifecycleReceiptForClassification( return readHermesPortableLifecycleReceiptInternal(sandboxName, stateDir, false, false, true); } -/** Route a probe toward the host fence without interpreting receipt authority. */ +/** Route a probe toward the host fence without interpreting receipt identity. */ export function hasHermesPortableReceiptCandidate(sandboxName: string, stateDir: string): boolean { try { fs.lstatSync(hermesPortableReceiptDirectory(sandboxName, stateDir)); @@ -2054,7 +2063,7 @@ export function hasHermesPortableReceiptCandidate(sandboxName: string, stateDir: } } -/** Select stable receipt authority while its same-transaction publisher resumes under the lock. */ +/** Select stable receipt identity while its same-transaction publisher resumes under the lock. */ export function inspectPortableAgentReceiptAuthorityForPublicationRecovery( sandboxName: string, stateDir: string, @@ -2077,7 +2086,7 @@ export function inspectPortableAgentReceiptAuthorityForRequalification( stateDir: string, ): PortableAgentReceiptAuthority { if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { - fail(`schema-6 requalification requires the sandbox lifecycle lock for '${sandboxName}'`); + fail(`schema-8 requalification requires the sandbox lifecycle lock for '${sandboxName}'`); } const legacyPath = portableDemoReceiptPath(sandboxName, stateDir); const openclaw = existingPath(legacyPath); @@ -2148,7 +2157,7 @@ export function publishHermesPortableLifecycleReceipt( const successor = readSuccessor(directory.path); if (successor) { if (receipt.phase !== "active") { - fail("schema-6 successor conflicts with incomplete schema-5 publication"); + fail("schema-8 successor conflicts with incomplete schema-7 publication"); } const expected = createHermesPortableSuccessorReceipt({ receipt, @@ -2158,7 +2167,7 @@ export function publishHermesPortableLifecycleReceipt( identity: successor.identity, }); if (!isDeepStrictEqual(successor.receipt, expected)) { - fail("schema-6 successor disagrees with the active schema-5 publication"); + fail("schema-8 successor disagrees with the active schema-7 publication"); } } const prior = @@ -2203,7 +2212,7 @@ export function publishHermesPortableLifecycleReceipt( } catch (error) { if (!isErrnoException(error) || error.code !== "EEXIST") throw error; const raced = readPhase(directory.path, receipt.phase); - if (!raced || !raced.bytes.equals(bytes)) fail("phase publication raced other authority"); + if (!raced || !raced.bytes.equals(bytes)) fail("phase publication raced another writer"); } hooks.afterCanonicalLink?.(); assertLifecycleLock(); @@ -2218,7 +2227,7 @@ export function publishHermesPortableLifecycleReceipt( } } -/** Publish the deterministic schema-6 operating authority without replacing schema-5 history. */ +/** Publish policy-free operating authority before retiring policy-bearing create history. */ export function publishHermesPortableSuccessorReceipt( sandboxName: string, stateDir: string, @@ -2232,12 +2241,12 @@ export function publishHermesPortableSuccessorReceipt( hooks.assertLifecycleLock ?? (() => { if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { - fail(`schema-6 publication requires the sandbox lifecycle lock for '${sandboxName}'`); + fail(`schema-8 publication requires the sandbox lifecycle lock for '${sandboxName}'`); } }); if (requalification) { if (requalification.expected.receipt.sandboxName !== sandboxName) { - fail("schema-6 requalification authority names another sandbox"); + fail("schema-8 requalification authority names another sandbox"); } assertCurrentPortableHostFenceHeld(requalification.expected.receipt.runtimeAuthority.homeDir); } @@ -2247,7 +2256,7 @@ export function publishHermesPortableSuccessorReceipt( }; assertPublicationAuthority(); if (existingPath(portableDemoReceiptPath(sandboxName, stateDir))) { - fail(`will not publish schema-6 authority over OpenClaw authority for '${sandboxName}'`); + fail(`will not publish schema-8 authority over OpenClaw authority for '${sandboxName}'`); } const active = readHermesPortableLifecycleReceiptInternal( sandboxName, @@ -2257,7 +2266,7 @@ export function publishHermesPortableSuccessorReceipt( requalification !== undefined, ); if (!active || active.receipt.phase !== "active") { - fail("schema-6 publication requires complete active schema-5 authority"); + fail("schema-8 publication requires complete active schema-7 authority"); } if ( requalification && @@ -2266,10 +2275,9 @@ export function publishHermesPortableSuccessorReceipt( active.identity.ino !== requalification.expected.identity.ino || active.sha256 !== requalification.expected.sha256 || !active.bytes.equals(requalification.expected.bytes) || - !isDeepStrictEqual(active.receipt.policy, requalification.expected.receipt.policy) || active.successorPublicationPending !== requalification.expected.successorPublicationPending) ) { - fail("schema-6 requalification authority changed before publication"); + fail("schema-8 requalification authority changed before publication"); } const receipt = createHermesPortableSuccessorReceipt( active as HermesPortableReceiptSnapshot & { @@ -2295,7 +2303,7 @@ export function publishHermesPortableSuccessorReceipt( ]); const unexpected = fs.readdirSync(directory.path).filter((entry) => !allowedEntries.has(entry)); if (unexpected.length > 0) { - fail(`directory contains other schema-6 publication evidence for '${sandboxName}'`); + fail(`directory contains other schema-8 publication evidence for '${sandboxName}'`); } retireInterruptedEmptyStage(target, staged, cleanup, directory, assertPublicationAuthority); retireInterruptedExactPrefixStage( @@ -2310,11 +2318,11 @@ export function publishHermesPortableSuccessorReceipt( const disposition = reconcilePublicationArtifacts(target, staged, cleanup, bytes, hooks); const reconciled = readSuccessor(directory.path); if (reconciled) { - if (!reconciled.bytes.equals(bytes)) fail("schema-6 successor has other authority"); + if (!reconciled.bytes.equals(bytes)) fail("schema-8 successor has other authority"); fs.fsyncSync(directory.descriptor); const current = readHermesPortableLifecycleReceipt(sandboxName, stateDir); if (!current?.successor || current.receipt.phase !== "active") { - fail("schema-6 successor could not be requalified after reconciliation"); + fail("schema-8 successor could not be requalified after reconciliation"); } assertPublicationAuthority(); return current as typeof current & { @@ -2322,7 +2330,7 @@ export function publishHermesPortableSuccessorReceipt( readonly successor: HermesPortableSuccessorSnapshot; }; } - if (disposition === "complete") fail("completed schema-6 publication is unreadable"); + if (disposition === "complete") fail("completed schema-8 publication is unreadable"); if (disposition === "absent") writeStage(staged, bytes, hooks); revalidateDirectory(directory); assertPublicationAuthority(); @@ -2333,7 +2341,7 @@ export function publishHermesPortableSuccessorReceipt( } catch (error) { if (!isErrnoException(error) || error.code !== "EEXIST") throw error; const raced = readSuccessor(directory.path); - if (!raced || !raced.bytes.equals(bytes)) fail("schema-6 publication raced other authority"); + if (!raced || !raced.bytes.equals(bytes)) fail("schema-8 publication raced another writer"); } hooks.afterCanonicalLink?.(); assertPublicationAuthority(); @@ -2344,7 +2352,7 @@ export function publishHermesPortableSuccessorReceipt( fs.fsyncSync(directory.descriptor); const published = readHermesPortableLifecycleReceipt(sandboxName, stateDir); if (!published?.successor || published.receipt.phase !== "active") { - fail("schema-6 successor disappeared after publication"); + fail("schema-8 successor disappeared after publication"); } assertPublicationAuthority(); return published as typeof published & { @@ -2367,7 +2375,7 @@ function existingPath(target: string): boolean { } } -/** Select receipt authority by durable agent identity and reject duplicate ownership. */ +/** Select receipt identity by durable agent identity and reject duplicate ownership. */ export function inspectPortableAgentReceiptAuthority( sandboxName: string, stateDir: string, diff --git a/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts b/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts index a76a6485eb3..64aed8c93e1 100644 --- a/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts @@ -142,7 +142,7 @@ describe("portable agent lifecycle dispatch", () => { mocks.readRegistry.mockReturnValue(null); }); - it("directs copied active schema-5 authority to probe instead of migrating on launch (#10423)", () => { + it("directs copied active schema-7 authority to probe instead of migrating on launch (#10423)", () => { mocks.isLifecycleLockHeld.mockReturnValue(true); mocks.inspectClassification.mockReturnValue({ kind: "hermes", @@ -200,7 +200,7 @@ describe("portable agent lifecycle dispatch", () => { }); it.each(["configuring", "active"] as const)( - "returns the matching schema-5 %s receipt and registry authority (#9203)", + "returns the matching schema-7 %s receipt and registry authority (#9203)", (phase) => { mocks.inspect.mockReturnValue(hermes(phase)); const entry = hermesRegistryEntry(); @@ -213,13 +213,13 @@ describe("portable agent lifecycle dispatch", () => { }, ); - it("uses operation-local schema-6 authority for a direct command (#10423)", () => { + it("uses operation-local schema-8 authority for a direct command (#10423)", () => { const historical = hermes("active").snapshot.receipt; const current = { ...historical, socketAuthority: { dev: "current" } }; const assertCurrent = vi.fn(); mocks.inspect.mockReturnValue({ kind: "hermes", - snapshot: { receipt: historical, successor: { receipt: { schemaVersion: 6 } } }, + snapshot: { receipt: historical, successor: { receipt: { schemaVersion: 8 } } }, }); mocks.qualifyOperatingAuthority.mockReturnValue({ receipt: current, assertCurrent }); @@ -278,7 +278,7 @@ describe("portable agent lifecycle dispatch", () => { { gatewayName: "other-gateway" }, { lifecycleGeneration: "generation-2" }, { lifecycleLiveIdentityFingerprint: "other-fingerprint" }, - ])("rejects schema-5 receipt and registry disagreement %# (#9203)", (overrides) => { + ])("rejects schema-7 receipt and registry disagreement %# (#9203)", (overrides) => { mocks.inspect.mockReturnValue(hermes("active")); mocks.readRegistry.mockReturnValue(hermesRegistryEntry(overrides)); @@ -287,7 +287,7 @@ describe("portable agent lifecycle dispatch", () => { ); }); - it("rejects a registry row while the schema-5 receipt is pending (#9203)", () => { + it("rejects a registry row while the schema-7 receipt is pending (#9203)", () => { mocks.inspect.mockReturnValue(hermes("pending")); mocks.readRegistry.mockReturnValue(hermesRegistryEntry()); @@ -328,7 +328,7 @@ describe("portable agent lifecycle dispatch", () => { ).toThrow("changed during verification"); }); - it("binds schema-5 command children to the receipt runtime namespace (#9203)", () => { + it("binds schema-7 command children to the receipt runtime namespace (#9203)", () => { mocks.inspect.mockReturnValue(hermes("active")); expect( buildHermesPortableCommandEnvironment("alpha", { diff --git a/src/lib/onboard/experimental/portable-agent-lifecycle.ts b/src/lib/onboard/experimental/portable-agent-lifecycle.ts index 7e7ef684b99..b4bca378f96 100644 --- a/src/lib/onboard/experimental/portable-agent-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-agent-lifecycle.ts @@ -392,7 +392,7 @@ export function qualifyHermesPortableOperatingCommandAuthority( return qualifyOperatingCommandAuthority(authority, env); } -/** Requalify a pending/configuring receipt only for its schema-5 onboarding child. */ +/** Requalify a pending/configuring receipt only for its schema-7 onboarding child. */ export function buildHermesPortableOnboardingCommandAuthority( sandboxName: string, gatewayName: string, diff --git a/src/lib/onboard/gateway-teardown-authority.test.ts b/src/lib/onboard/gateway-teardown-authority.test.ts index 9a2364c6976..aed07c25b08 100644 --- a/src/lib/onboard/gateway-teardown-authority.test.ts +++ b/src/lib/onboard/gateway-teardown-authority.test.ts @@ -111,13 +111,12 @@ describe("resolveGatewayTeardownAuthority", () => { ).toBe("externally-supervised"); }); - it("uses persisted gateway authority even when the unrelated policy authority is malformed (#9833)", () => { + it("uses persisted gateway authority even when the unrelated policy requirements is malformed (#9833)", () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-teardown-authority-")); const currentDeclaration = declaration(); const recordedOwner = owner(currentDeclaration); const session = { ...checkpointSession(recordedOwner), - policyAuthority: "global", }; writeTargetSession(homeDir, JSON.stringify(session)); @@ -138,12 +137,11 @@ describe("resolveGatewayTeardownAuthority", () => { } }); - it("does not adopt a different current owner when policy authority is malformed (#9833)", () => { + it("does not adopt a different current owner when policy requirements is malformed (#9833)", () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-teardown-authority-")); const recordedOwner = owner(declaration("systemd-system")); const session = { ...checkpointSession(recordedOwner), - policyAuthority: "global", }; writeTargetSession(homeDir, JSON.stringify(session)); diff --git a/src/lib/onboard/hermes-dashboard.test.ts b/src/lib/onboard/hermes-dashboard.test.ts index 84f39a3c83b..778c8a33c61 100644 --- a/src/lib/onboard/hermes-dashboard.test.ts +++ b/src/lib/onboard/hermes-dashboard.test.ts @@ -210,11 +210,11 @@ describe("onboard Hermes dashboard helpers", () => { it("stops Hermes dashboard forwarding when authority changes between retries (#9833)", () => { const starts: string[] = []; - const revalidatePolicyAuthority = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); const ensureForward = vi.fn( ( @@ -244,21 +244,21 @@ describe("onboard Hermes dashboard helpers", () => { }, }); - expect(() => ensure("my-hermes", false, revalidatePolicyAuthority)).toThrow( - "policy authority changed", + expect(() => ensure("my-hermes", false, verifyLivePolicyRequirements)).toThrow( + "policy requirements changed", ); expect(starts).toEqual(["attempt 1"]); - expect(revalidatePolicyAuthority).toHaveBeenCalledTimes(2); + expect(verifyLivePolicyRequirements).toHaveBeenCalledTimes(2); }); it("stops Hermes dashboard rollback when authority changes between commands (#9833)", () => { const runOpenshell = vi.fn(); - const revalidatePolicyAuthority = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); const forwarding = createHermesDashboardOnboardForwarding({ agentName: "hermes", @@ -274,8 +274,8 @@ describe("onboard Hermes dashboard helpers", () => { const state = forwarding.resolveStateForPort(18789); expect(() => - forwarding.ensureForState(state, "my-hermes", true, revalidatePolicyAuthority), - ).toThrow("policy authority changed"); + forwarding.ensureForState(state, "my-hermes", true, verifyLivePolicyRequirements), + ).toThrow("policy requirements changed"); expect(runOpenshell).toHaveBeenCalledTimes(1); expect(runOpenshell).toHaveBeenCalledWith(["forward", "stop", "8642", "my-hermes"], { @@ -312,10 +312,9 @@ describe("onboard Hermes dashboard helpers", () => { expect(runOpenshell).toHaveBeenCalledWith(["forward", "stop", "8642", "my-hermes"], { ignoreError: true, }); - expect(runOpenshell).toHaveBeenCalledWith( - ["forward", "stop", "18789", "my-hermes"], - { ignoreError: true }, - ); + expect(runOpenshell).toHaveBeenCalledWith(["forward", "stop", "18789", "my-hermes"], { + ignoreError: true, + }); expect(runOpenshell).not.toHaveBeenCalledWith( ["sandbox", "delete", "my-hermes"], expect.anything(), diff --git a/src/lib/onboard/hermes-dashboard.ts b/src/lib/onboard/hermes-dashboard.ts index 5c93cefb4c1..a726cbc5d83 100644 --- a/src/lib/onboard/hermes-dashboard.ts +++ b/src/lib/onboard/hermes-dashboard.ts @@ -19,12 +19,12 @@ export interface HermesDashboardOnboardState { } type RunOpenshell = (args: string[], options: { ignoreError: true }) => unknown; -type RevalidatePolicyAuthority = (operation: string) => void; +type RevalidatePolicyRequirements = (operation: string) => void; type EnsureForward = ( sandboxName: string, port: number, label: string, - revalidatePolicyAuthority?: RevalidatePolicyAuthority, + verifyLivePolicyRequirements?: RevalidatePolicyRequirements, ) => boolean; export function resolveHermesDashboardOnboardState({ @@ -145,21 +145,21 @@ export function ensureHermesDashboardForwardIfEnabled({ sandboxName, ensureForward, note, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, }: { state: HermesDashboardOnboardState; sandboxName: string; ensureForward: EnsureForward; note: (message: string) => void; - revalidatePolicyAuthority?: RevalidatePolicyAuthority; + verifyLivePolicyRequirements?: RevalidatePolicyRequirements; }): boolean { if (!state.enabled || !state.config) return true; if ( - !ensureForward(sandboxName, state.config.port, "Hermes dashboard", revalidatePolicyAuthority) + !ensureForward(sandboxName, state.config.port, "Hermes dashboard", verifyLivePolicyRequirements) ) { return false; } - revalidatePolicyAuthority?.(`report Hermes dashboard forward for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements?.(`report Hermes dashboard forward for sandbox '${sandboxName}'`); note(` ✓ Hermes dashboard forwarded at http://127.0.0.1:${state.config.port}/`); return true; } @@ -181,30 +181,30 @@ export function createHermesDashboardForwardEnsurer({ note: (message: string) => void; rollbackSandbox: ( sandboxName: string, - revalidatePolicyAuthority?: RevalidatePolicyAuthority, + verifyLivePolicyRequirements?: RevalidatePolicyRequirements, ) => void; fail: (message: string) => never; }): ( sandboxName: string, rollback?: boolean, - revalidatePolicyAuthority?: RevalidatePolicyAuthority, + verifyLivePolicyRequirements?: RevalidatePolicyRequirements, ) => void { return ( sandboxName: string, rollback = false, - revalidatePolicyAuthority?: RevalidatePolicyAuthority, + verifyLivePolicyRequirements?: RevalidatePolicyRequirements, ): void => { const ok = ensureHermesDashboardForwardIfEnabled({ state, sandboxName, ensureForward, note, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, }); if (ok) return; if (rollback) { - if (revalidatePolicyAuthority) { - rollbackSandbox(sandboxName, revalidatePolicyAuthority); + if (verifyLivePolicyRequirements) { + rollbackSandbox(sandboxName, verifyLivePolicyRequirements); } else { rollbackSandbox(sandboxName); } @@ -243,7 +243,7 @@ export function createHermesDashboardOnboardForwarding({ state: HermesDashboardOnboardState, sandboxName: string, rollback = false, - revalidatePolicyAuthority?: RevalidatePolicyAuthority, + verifyLivePolicyRequirements?: RevalidatePolicyRequirements, ) => createHermesDashboardForwardEnsurer({ state, @@ -266,7 +266,7 @@ export function createHermesDashboardOnboardForwarding({ } }, fail: failWithMessage, - })(sandboxName, rollback, revalidatePolicyAuthority); + })(sandboxName, rollback, verifyLivePolicyRequirements); return { resolveStateForPort, ensureForState }; } diff --git a/src/lib/onboard/inference-providers/routed-selection.ts b/src/lib/onboard/inference-providers/routed-selection.ts index 272fa5d2d30..cc31f4decc0 100644 --- a/src/lib/onboard/inference-providers/routed-selection.ts +++ b/src/lib/onboard/inference-providers/routed-selection.ts @@ -36,7 +36,7 @@ type RoutedSelectionDeps = { helpUrl?: string | null, validator?: ((value: string) => string | null) | null, allowEmpty?: boolean, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise; returningToProviderSelection(value: unknown): boolean; }; @@ -128,7 +128,7 @@ export async function handleRoutedSelection( null, null, false, - state.revalidatePolicyRequirements, + state.verifyLivePolicyRequirements, ); if (deps.credentialPrompt.returningToProviderSelection(result)) return "retry-selection"; if (typeof result !== "string" || !deps.credentials.normalizeCredentialValue(result)) { @@ -136,10 +136,10 @@ export async function handleRoutedSelection( return "retry-selection"; } } else if (configuredCredential) { - state.revalidatePolicyRequirements?.("save Model Router credential"); + state.verifyLivePolicyRequirements?.("save Model Router credential"); deps.credentials.saveCredential(credentialEnv, configuredCredential); } else if (bridgedCredential) { - state.revalidatePolicyRequirements?.("stage Model Router provider credential"); + state.verifyLivePolicyRequirements?.("stage Model Router provider credential"); deps.providerKeyBridge.stageRouterProviderKeyBridge(credentialEnv, bridgedCredential); } diff --git a/src/lib/onboard/inference-providers/setup-nim-policy-authority.test.ts b/src/lib/onboard/inference-providers/setup-nim-policy-authority.test.ts deleted file mode 100644 index 3bc02dd7538..00000000000 --- a/src/lib/onboard/inference-providers/setup-nim-policy-authority.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { PolicyAuthorityRefusalError } from "../../adapters/openshell/policy-authority"; -import { makeDeps } from "../__test-helpers__/setup-nim-flow"; -import { createSetupNim, type SetupNimFlowDeps } from "../setup-nim-flow"; - -function refusePolicyChange(): never { - throw new Error("external policy authority must supply the selected provider entry"); -} - -describe("provider selection policy authority", () => { - it("stops before a remote provider can register credentials (#9833)", async () => { - const handleRemoteProviderSelection = - vi.fn(); - const setupNim = createSetupNim( - makeDeps({ - isNonInteractive: () => true, - getNonInteractiveProvider: () => "build", - handleRemoteProviderSelection, - }), - ); - - await expect( - setupNim( - null, - null, - null, - true, - null, - "nemoclaw", - undefined, - undefined, - null, - refusePolicyChange, - ), - ).rejects.toThrow(/external policy authority must supply/u); - - expect(handleRemoteProviderSelection).not.toHaveBeenCalled(); - }); - - it("does not retry selection after a typed llama.cpp activation refusal (#9833)", async () => { - const selection = { - recipe: { - metadata: { id: "test.llama.recipe" }, - spec: { model: { servedName: "nvidia-nemotron-3-nano-30b-a3b" } }, - }, - } as never; - const installManagedLlamaCpp = vi.fn(async (_selection, options) => { - options.revalidatePolicyRequirements?.("activate the managed llama.cpp runtime"); - throw new PolicyAuthorityRefusalError( - "External policy authority must supply the managed llama.cpp entry.", - ); - }); - const setupNim = createSetupNim( - makeDeps({ - isNonInteractive: () => true, - getNonInteractiveProvider: () => "install-llama-cpp", - resolveManagedLlamaCppSelection: () => ({ kind: "selected", selection }), - installManagedLlamaCpp: installManagedLlamaCpp as never, - }), - ); - const revalidatePolicyRequirements = vi.fn(); - - await expect( - setupNim( - { platform: "spark" } as never, - "spark-agent", - null, - true, - null, - "nemoclaw", - undefined, - undefined, - null, - revalidatePolicyRequirements, - ), - ).rejects.toBeInstanceOf(PolicyAuthorityRefusalError); - - expect(installManagedLlamaCpp).toHaveBeenCalledOnce(); - expect(revalidatePolicyRequirements).toHaveBeenCalledWith( - expect.objectContaining({ provider: "llama-cpp-local" }), - "activate the managed llama.cpp runtime", - ); - }); -}); diff --git a/src/lib/onboard/inference-providers/types.ts b/src/lib/onboard/inference-providers/types.ts index 030b47861f0..7e0f2769e73 100644 --- a/src/lib/onboard/inference-providers/types.ts +++ b/src/lib/onboard/inference-providers/types.ts @@ -77,7 +77,7 @@ export type PromptValidationRecovery = ( classification: any, credentialEnv: any, helpUrl: any, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise<"credential" | "selection" | "retry" | "model">; export type ClassifyApplyFailure = (message: string) => any; diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 63f5f89dee8..35d6c523e69 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -170,7 +170,7 @@ describe("inference selection validation", () => { } }); - it("withholds OpenAI-like availability and capability caching when policy authority changes during the probe (#9833)", async () => { + it("withholds OpenAI-like availability and capability caching when policy requirements changes during the probe (#9833)", async () => { const capabilityCache = new OnboardInferenceCapabilityCache(); const helpers = createInferenceSelectionValidationHelpers({ isNonInteractive: () => false, @@ -196,12 +196,12 @@ describe("inference selection validation", () => { undefined, { capabilityCache, - revalidatePolicyRequirements: () => { - throw new Error("policy authority changed"); + verifyLivePolicyRequirements: () => { + throw new Error("policy requirements changed"); }, }, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(log.mock.calls.flat().join("\n")).not.toContain("available"); expect( capabilityCache.takeCompletedOpenAiChat({ @@ -214,7 +214,7 @@ describe("inference selection validation", () => { } }); - it("withholds Anthropic availability when policy authority changes during the probe (#9833)", async () => { + it("withholds Anthropic availability when policy requirements changes during the probe (#9833)", async () => { const helpers = createInferenceSelectionValidationHelpers({ isNonInteractive: () => false, agentProductName: () => "OpenClaw", @@ -238,10 +238,10 @@ describe("inference selection validation", () => { undefined, undefined, () => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }, ), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(log.mock.calls.flat().join("\n")).not.toContain("available"); } finally { log.mockRestore(); diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index d3c1e5cef1d..0a89de28f82 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -70,7 +70,7 @@ export interface OpenAiSelectionValidationOptions { retryChatCompletionsToolReadiness?: boolean; /** Provider identity used only for safe, provider-specific diagnostics. */ provider?: string; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; skipResponsesProbe?: boolean; probeStreaming?: boolean; @@ -99,7 +99,7 @@ export interface InferenceSelectionValidationDeps { recovery: ReturnType, credentialEnv?: string | null, helpUrl?: string | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise<"credential" | "selection" | "retry" | "model">; } @@ -120,7 +120,7 @@ export interface InferenceSelectionValidationHelpers { credentialEnv: string, retryMessage?: string, helpUrl?: string | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise; validateCustomOpenAiLikeSelection( label: string, @@ -129,7 +129,7 @@ export interface InferenceSelectionValidationHelpers { credentialEnv: string, helpUrl?: string | null, capabilityCache?: OnboardInferenceCapabilityCache, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise; validateCustomAnthropicSelection( label: string, @@ -139,7 +139,7 @@ export interface InferenceSelectionValidationHelpers { helpUrl?: string | null, options?: { intendedApi?: "anthropic-messages" | "openai-completions"; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }, ): Promise; } @@ -228,7 +228,7 @@ export function createInferenceSelectionValidationHelpers( endpointUrl: string, credentialEnv: string | null, helpUrl: string | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise< | { blocked: EndpointValidationResult } | { @@ -293,7 +293,7 @@ export function createInferenceSelectionValidationHelpers( getProbeRecovery(syntheticProbe), credentialEnv, helpUrl, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if (retry === "selection") { console.log(" Please choose a provider/model again."); @@ -334,7 +334,7 @@ export function createInferenceSelectionValidationHelpers( getProbeRecovery(probe), credentialEnv, helpUrl, - options.revalidatePolicyRequirements, + options.verifyLivePolicyRequirements, ); if (retry === "selection") { console.log(` ${retryMessage}`); @@ -342,7 +342,7 @@ export function createInferenceSelectionValidationHelpers( } return { ok: false, retry }; } - options.revalidatePolicyRequirements?.("report validated inference endpoint"); + options.verifyLivePolicyRequirements?.("report validated inference endpoint"); if (probe.note) { console.log(` ℹ ${probe.note}`); } else { @@ -370,7 +370,7 @@ export function createInferenceSelectionValidationHelpers( credentialEnv: string, retryMessage = "Please choose a provider/model again.", helpUrl: string | null = null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise { const apiKey = resolveCredential(credentialEnv); const probe = runAnthropicProbe(endpointUrl, model, apiKey); @@ -384,7 +384,7 @@ export function createInferenceSelectionValidationHelpers( getProbeRecovery(probe), credentialEnv, helpUrl, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if (retry === "selection") { console.log(` ${retryMessage}`); @@ -392,7 +392,7 @@ export function createInferenceSelectionValidationHelpers( } return { ok: false, retry }; } - revalidatePolicyRequirements?.("report validated inference endpoint"); + verifyLivePolicyRequirements?.("report validated inference endpoint"); console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`); return { ok: true, api: probe.api }; } @@ -404,14 +404,14 @@ export function createInferenceSelectionValidationHelpers( credentialEnv: string, helpUrl: string | null = null, capabilityCache?: OnboardInferenceCapabilityCache, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise { const preflight = await preflightCustomEndpointOrFail( label, endpointUrl, credentialEnv, helpUrl, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if ("blocked" in preflight) return preflight.blocked; const { pinnedAddresses, trustedPrivateCapability } = preflight; @@ -428,7 +428,7 @@ export function createInferenceSelectionValidationHelpers( trustedPrivateCapability, }); if (probe.ok) { - revalidatePolicyRequirements?.("report validated inference endpoint"); + verifyLivePolicyRequirements?.("report validated inference endpoint"); if (probe.note) { console.log(` ℹ ${probe.note}`); } else { @@ -461,7 +461,7 @@ export function createInferenceSelectionValidationHelpers( getProbeRecovery(probe, { allowModelRetry: true }), credentialEnv, helpUrl, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if (retry === "selection") { console.log(" Please choose a provider/model again."); @@ -478,7 +478,7 @@ export function createInferenceSelectionValidationHelpers( helpUrl: string | null = null, options: { intendedApi?: "anthropic-messages" | "openai-completions"; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; } = {}, ): Promise { const preflight = await preflightCustomEndpointOrFail( @@ -486,7 +486,7 @@ export function createInferenceSelectionValidationHelpers( endpointUrl, credentialEnv, helpUrl, - options.revalidatePolicyRequirements, + options.verifyLivePolicyRequirements, ); if ("blocked" in preflight) return preflight.blocked; const { pinnedAddresses, trustedPrivateCapability } = preflight; @@ -520,7 +520,7 @@ export function createInferenceSelectionValidationHelpers( trustedPrivateCapability, }); if (probe.ok) { - options.revalidatePolicyRequirements?.("report validated inference endpoint"); + options.verifyLivePolicyRequirements?.("report validated inference endpoint"); if (probe.note) { console.log(` ℹ ${probe.note}`); } else { @@ -548,7 +548,7 @@ export function createInferenceSelectionValidationHelpers( recovery, credentialEnv, helpUrl, - options.revalidatePolicyRequirements, + options.verifyLivePolicyRequirements, ); if (retry === "selection") { console.log(" Please choose a provider/model again."); diff --git a/src/lib/onboard/initial-policy-baseline-exclusion.test.ts b/src/lib/onboard/initial-policy-baseline-exclusion.test.ts deleted file mode 100644 index 5140c9471b7..00000000000 --- a/src/lib/onboard/initial-policy-baseline-exclusion.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// 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 } from "vitest"; -import YAML from "yaml"; -import { resolveAgentBaselinePolicy } from "../policy"; -import { - BaselineExclusionDriftError, - digestBaselineEntry, - getBaselineEntry, -} from "../policy/baseline-exclusion"; -import { prepareInitialSandboxCreatePolicy } from "./initial-policy"; - -const BASE_POLICY = `version: 1 -network_policies: - nous_research: - name: nous_research - endpoints: - - host: nousresearch.com - port: 443 - protocol: rest - rules: - - allow: { method: GET, path: "/**" } - managed_inference: - name: managed_inference - endpoints: - - host: inference.local - port: 443 - protocol: rest - rules: - - allow: { method: POST, path: "/v1/**" } -`; - -const tempDirs: string[] = []; - -function writeBasePolicy(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-exclusion-test-")); - tempDirs.push(dir); - const filePath = path.join(dir, "policy-additions.yaml"); - fs.writeFileSync(filePath, BASE_POLICY); - return filePath; -} - -function digestOf(key: string): string { - const entry = getBaselineEntry(BASE_POLICY, key); - expect(entry).not.toBeNull(); - return digestBaselineEntry(entry!); -} - -function exclusion(key: string, digest: string, agent = "openclaw") { - return { version: 1 as const, agent, key, digest }; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("prepareInitialSandboxCreatePolicy baseline exclusions (#7178)", () => { - it("drops an excluded entry from the generated policy", () => { - const basePath = writeBasePolicy(); - const result = prepareInitialSandboxCreatePolicy(basePath, [], { - agentName: "openclaw", - baselineExclusions: [exclusion("nous_research", digestOf("nous_research"))], - }); - const generated = YAML.parse(fs.readFileSync(result.policyPath, "utf-8")); - expect(Object.keys(generated.network_policies)).toEqual(["managed_inference"]); - result.cleanup?.(); - }); - - it("leaves the base policy untouched when no exclusions are requested", () => { - const basePath = writeBasePolicy(); - const result = prepareInitialSandboxCreatePolicy(basePath, [], {}); - expect(result.policyPath).toBe(basePath); - result.cleanup?.(); - }); - - it("fails closed when the recorded digest no longer matches the baseline", () => { - const basePath = writeBasePolicy(); - expect(() => - prepareInitialSandboxCreatePolicy(basePath, [], { - agentName: "openclaw", - baselineExclusions: [exclusion("nous_research", "stale-digest")], - }), - ).toThrowError(BaselineExclusionDriftError); - }); - - it("fails closed when the excluded entry was removed by a release", () => { - const basePath = writeBasePolicy(); - expect(() => - prepareInitialSandboxCreatePolicy(basePath, [], { - agentName: "openclaw", - baselineExclusions: [exclusion("removed_key", "any")], - }), - ).toThrowError(BaselineExclusionDriftError); - }); - - it("rejects the shipped Hermes pypi preset when pypi is excluded from its baseline (#7194)", () => { - const hermes = resolveAgentBaselinePolicy("hermes"); - expect(hermes).not.toBeNull(); - const entry = getBaselineEntry(hermes!.content, "pypi"); - expect(entry).not.toBeNull(); - - expect(() => - prepareInitialSandboxCreatePolicy(hermes!.policyPath, [], { - agentName: "hermes", - additionalPresets: ["pypi"], - baselineExclusions: [exclusion("pypi", digestBaselineEntry(entry!), "hermes")], - }), - ).toThrow(/network policy key 'pypi' is reserved by a baseline exclusion/); - }); -}); diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 52d9bbb899d..690ef2743b9 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -604,7 +604,6 @@ describe("initial sandbox policy real preset merge", () => { [], { agentName: "langchain-deepagents-code", - policyTier: "balanced", additionalPresets: ["observability-otlp-local"], }, ); @@ -669,7 +668,6 @@ describe("initial sandbox policy real preset merge", () => { const effective = readPreparedPolicy( prepareInitialSandboxCreatePolicy(baselinePath, [], { agentName: "openclaw", - policyTier: "restricted", }), ); @@ -689,7 +687,6 @@ describe("initial sandbox policy real preset merge", () => { [], { agentName: "openclaw", - policyTier: "balanced", additionalPresets: ["npm", "brew", "openclaw-pricing"], }, ), diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 957dbc80825..54a0ab4e048 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -166,7 +166,6 @@ describe("initial sandbox policy helpers", () => { const planned = planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { agentName: "hermes", - policyTier: "personal", additionalPresets: ["personal-open-internet", "slack"], }); @@ -185,7 +184,6 @@ describe("initial sandbox policy helpers", () => { const planned = planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { agentName: "hermes", - policyTier: "personal", additionalPresets: ["personal-open-internet"], }); @@ -213,7 +211,6 @@ describe("initial sandbox policy helpers", () => { expect(() => planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { agentName: "hermes", - policyTier: "personal", additionalPresets: ["personal-open-internet"], }), ).toThrow("not strict UTF-8"); @@ -233,20 +230,18 @@ describe("initial sandbox policy helpers", () => { expect(() => planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { agentName: "hermes", - policyTier: "personal", additionalPresets: ["personal-open-internet"], }), ).toThrow("must not include a UTF-8 byte-order mark"); }); - it("rejects replaced, linked, or writable schema-5 policy authority (#9203)", () => { + it("rejects replaced, linked, or writable schema-5 policy requirements (#9203)", () => { vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); const original = tmpPolicy("version: 1\nnetwork_policies: {}\n"); const replacement = path.join(path.dirname(original), "replacement.yaml"); const plan = (policyPath: string) => planHermesPortableInitialSandboxPolicy(policyPath, [], { agentName: "hermes", - policyTier: "personal", additionalPresets: ["personal-open-internet"], }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index e0ed0e8f031..a3f8d06dd92 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -9,10 +9,6 @@ import YAML from "yaml"; import { isObjectRecord } from "../core/json-types"; import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; import * as policies from "../policy"; -import { - applyBaselineExclusions, - type BaselineExclusionRequest, -} from "../policy/baseline-exclusion"; import { collectPlatformIdentity, type PlatformIdentity, @@ -326,7 +322,6 @@ type InitialPolicyOptions = { agentName?: string | null; sandboxName?: string; policyTier?: string | null; - baselineExclusions?: readonly BaselineExclusionRequest[]; }; type PolicyMaterializer = (content: string, prefix: string) => InitialSandboxPolicy; @@ -363,7 +358,7 @@ export function getNetworkPolicyNames(policyContent: string): Set | null } } -function getCredentialBindingProviders(policyContent: string): string[] { +export function getCredentialBindingProviders(policyContent: string): string[] { const parsed = YAML.parse(policyContent); if (!isObjectRecord(parsed) || !isObjectRecord(parsed.network_policies)) return []; @@ -530,21 +525,6 @@ function resolveInitialSandboxCreatePolicy( } } - // Replay operator baseline exclusions before presets merge on top. Fails - // closed via applyBaselineExclusions when a recorded approval no longer - // matches the current baseline, so a changed release forces re-review. - const baselineExclusions = options.baselineExclusions ?? []; - if (baselineExclusions.length > 0) { - const excluded = applyBaselineExclusions( - basePolicy, - baselineExclusions, - policyAgent ?? "openclaw", - ); - if (excluded.excludedKeys.length > 0) { - adoptPolicy(excluded.content, "nemoclaw-agent-policy"); - } - } - const basePolicyNames = getNetworkPolicyNames(basePolicy); if (basePolicyNames === null) { return result([]); @@ -570,7 +550,6 @@ function resolveInitialSandboxCreatePolicy( const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets, { agent: policyAgent, sandboxName: options.sandboxName, - excludedBaselineKeys: baselineExclusions.map((exclusion) => exclusion.key), credentialBoundMessagingChannels: activeMessagingChannels, }); if (mergedPolicy.missingPresets.length > 0) { @@ -631,23 +610,25 @@ export function readHermesPortableInitialPolicySource(basePolicyPath: string): s } const parentPath = path.dirname(basePolicyPath); const parentBefore = fs.lstatSync(parentPath, { bigint: true }); - const named = fs.lstatSync(basePolicyPath, { bigint: true }); if ( !parentBefore.isDirectory() || parentBefore.isSymbolicLink() || (parentBefore.uid !== 0n && parentBefore.uid !== BigInt(uid)) || - !hasSafeHermesPortablePolicySourceMode(parentBefore, uid, gid, 0o775n) || - !named.isFile() || - named.isSymbolicLink() + !hasSafeHermesPortablePolicySourceMode(parentBefore, uid, gid, 0o775n) ) { throw new Error("Hermes portable policy source authority is unsafe."); } - const descriptor = fs.openSync( - basePolicyPath, - fs.constants.O_RDONLY | - fs.constants.O_NOFOLLOW | - (typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0), - ); + let descriptor: number; + try { + descriptor = fs.openSync( + basePolicyPath, + fs.constants.O_RDONLY | + fs.constants.O_NOFOLLOW | + (typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0), + ); + } catch { + throw new Error("Hermes portable policy source authority is unsafe."); + } try { const before = fs.fstatSync(descriptor, { bigint: true }); if ( @@ -657,9 +638,7 @@ export function readHermesPortableInitialPolicySource(basePolicyPath: string): s (before.uid !== 0n && before.uid !== BigInt(uid)) || !hasSafeHermesPortablePolicySourceMode(before, uid, gid, 0o664n) || before.size < 1n || - before.size > 256n * 1024n || - named.dev !== before.dev || - named.ino !== before.ino + before.size > 256n * 1024n ) { throw new Error("Hermes portable policy source authority is unsafe."); } diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index 13d45e423c8..bad862b124b 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -64,13 +64,23 @@ The versioned checkpoint also records durable sandbox identity and completed web Resume skips an effect only after its live postcondition is revalidated. The machine still cannot resume inside gateway startup, an individual credential upsert, sandbox creation, policy application, or another handler-owned effect group. -## Onboarding policy authority +## Onboarding policy state -Onboarding binds policy authority after gateway setup and before provider, credential, service, registry, or sandbox changes. Empty global policy history establishes no observed owner. NemoClaw ownership begins only after an exact sandbox creation receipt binds the created identity and effective policy. An active global policy means an external owner manages it. Missing, malformed, unavailable, or contradictory OpenShell metadata stops onboarding. +OpenShell is the sole durable policy source. Onboarding may pass a requested +policy when it creates a sandbox, then verifies that the current OpenShell +policy contains every requirement for the selected agent, provider, messaging +channels, observability, GPU mode, and web search setup. -The session records the decision before later effects. A live sandbox must agree with both the saved session and registry entry. Onboarding rechecks that agreement before each policy-dependent change and after the created sandbox reaches Ready. +The onboarding session and sandbox registry do not store a policy owner, +receipt, hash, version, desired tier, or applied preset list. Later effects +re-read live OpenShell requirements. If post-create verification fails, +NemoClaw preserves the sandbox for identity-bound recovery rather than deleting +by mutable name. -NemoClaw-managed onboarding keeps the existing policy creation and attribution behavior. Externally managed onboarding verifies that the effective policy contains every requirement for the selected agent, provider, messaging channels, observability, GPU mode, and web search setup. It does not pass a policy file, export `OPENSHELL_SANDBOX_POLICY`, change policy, or record NemoClaw policy attribution. After a post-create authority failure, NemoClaw leaves the sandbox running because the supported delete command targets its mutable name. Operators must preserve the durable sandbox identity fingerprint from the failure output and provide it to the OpenShell administrator. They must not delete the sandbox by name, even after comparing its identity. Contact the administrator for an identity-bound recovery or removal procedure. +Hermes Portable keeps its create-policy file and pending/configuring receipts +only while sandbox creation is incomplete. After its policy-free operating +authority is durable, it removes that file and both policy-bearing receipt +phases; the remaining active runtime authority contains no policy fields. ## Effect-order flows @@ -125,16 +135,16 @@ runtime mutation | Journey and entry | Desired state, planning, and assembly | Visible and destructive boundaries | Checkpoint and secret boundary | Compensation, coverage, and gaps | |---|---|---|---|---| -| **New interactive or non-interactive onboard** — `onboard()` and `resolveOnboardEntryOptions` | Current flags, environment, and prompts. `MessagingWorkflowPlanner.buildPlan`, `prepareSandboxMessagingPreflight`, resource-profile selection, `resolveSandboxCreateIntent`, and `materializeSandboxCreatePlan` assemble policy, provider, package, resource, host-forward, and runtime-setup contributions. Non-interactive mode replaces prompts with defaults or hard aborts. | Consent/session/lock setup and preflight can persist local state, install OpenShell, or clean stale gateway artifacts before the gateway handler. Gateway reuse/recovery/start is the first provider-routing effect; inference-provider upserts follow. For OpenClaw, messaging selection and plan reconciliation complete before web-search or messaging provider registration. Each validated provider group is then created or updated and checkpointed before resource selection. A name with no live sandbox has no sandbox-destructive boundary; an existing target enters the recreate contract below. | Whole-step session plus machine snapshot. OpenClaw adds narrow checkpoints after each completed secret-free sandbox prompt group; sandbox registry registration is deferred until readiness and live validation. The session stores credential environment names, redacted endpoint metadata, legacy-value digests, and non-secret names of web-search and messaging providers registered for resume; real values remain process- or gateway-bound. | Readiness, post-create policy verification, dashboard forwarding, and cancellation failures preserve the live sandbox because NemoClaw refuses the available mutable-name deletion command when it could target a replacement. Exact provider-owned GPU cleanup can proceed through its owner receipt. Temporary policy and build-context cleanup remains best effort. Cancellation before sandbox creation can leave the session resumable. Cancellation after creation preserves the incomplete session, registry row, and an independent identity-bound recovery record. Shared inference providers remain gateway configuration and are not sandbox cleanup targets. Recovery removes only confirmed sandbox-scoped resources and rotates credentials only when inspection proves exposure or attachment to a retained resource; a recorded environment-variable name alone is not exposure evidence. Coverage: `transition-traces.test.ts`, `sandbox-create-intent-boundary.test.ts`, `sandbox-create-plan.test.ts`, and the focused cancellation, readiness, GPU cleanup, dashboard, and policy-authority tests. Gap: gateway upserts can outlive a failed or interrupted create. | +| **New interactive or non-interactive onboard** — `onboard()` and `resolveOnboardEntryOptions` | Current flags, environment, and prompts. `MessagingWorkflowPlanner.buildPlan`, `prepareSandboxMessagingPreflight`, resource-profile selection, `resolveSandboxCreateIntent`, and `materializeSandboxCreatePlan` assemble policy, provider, package, resource, host-forward, and runtime-setup contributions. Non-interactive mode replaces prompts with defaults or hard aborts. | Consent/session/lock setup and preflight can persist local state, install OpenShell, or clean stale gateway artifacts before the gateway handler. Gateway reuse/recovery/start is the first provider-routing effect; inference-provider upserts follow. For OpenClaw, messaging selection and plan reconciliation complete before web-search or messaging provider registration. Each validated provider group is then created or updated and checkpointed before resource selection. A name with no live sandbox has no sandbox-destructive boundary; an existing target enters the recreate contract below. | Whole-step session plus machine snapshot. OpenClaw adds narrow checkpoints after each completed secret-free sandbox prompt group; sandbox registry registration is deferred until readiness and live validation. The session stores credential environment names, redacted endpoint metadata, legacy-value digests, and non-secret names of web-search and messaging providers registered for resume; real values remain process- or gateway-bound. | Readiness, post-create policy verification, dashboard forwarding, and cancellation failures preserve the live sandbox because NemoClaw refuses the available mutable-name deletion command when it could target a replacement. Exact provider-owned GPU cleanup can proceed through its owner receipt. Temporary policy and build-context cleanup remains best effort. Cancellation before sandbox creation can leave the session resumable. Cancellation after creation preserves the incomplete session, registry row, and an independent identity-bound recovery record. Shared inference providers remain gateway configuration and are not sandbox cleanup targets. Recovery removes only confirmed sandbox-scoped resources and rotates credentials only when inspection proves exposure or attachment to a retained resource; a recorded environment-variable name alone is not exposure evidence. Coverage: `transition-traces.test.ts`, `sandbox-create-intent-boundary.test.ts`, `sandbox-create-plan.test.ts`, and the focused cancellation, readiness, GPU cleanup, dashboard, and live-policy-requirements tests. Gap: gateway upserts can outlive a failed or interrupted create. | | **`--fresh` onboard** — `resolveOnboardEntryOptions`, `prepareFreshSession`, `createBaseImageResolutionContext` | Current flags/environment/prompts replace resumable intent. `--fresh` disables auto-resume and forces base-image resolution; it does not prove that the selected sandbox name is unused. | The first destructive effect is local: the prior onboard session is cleared before a new session is saved. A matching live sandbox can later reuse or recreate through the normal sandbox decision; `--fresh` does not itself delete it. | The new session and machine snapshot replace the old resume checkpoint. Credential and effect boundaries then match new onboard or live recreate. | The discarded resume checkpoint is not restored on later failure. Covered by `entry-options.test.ts`, `session-bootstrap.test.ts`, and base-image resolution tests. | | **Resume, re-onboard, or recreate** — `onboard()`, `prepareOnboardSession`, `decideSandboxResume`, live-sandbox handling in `createSandbox` | For `--resume`, the recorded session is authoritative and conflicting current name/provider/model/image/tool-disclosure hints are rejected. A new re-onboard run takes current flags, environment, and prompts as intent while registry/gateway state provides drift evidence. The machine resolves a complete secret-free create intent, including policy, messaging/provider, GPU, resource, disabled-channel, and agent inputs, before repair/removal or live recreation. | Ordinary live recreation conditionally backs up before provider cleanup, **delete**, and image removal. The recreate journal preserves the source registry row after deletion. Replacement registration commits the new row after readiness and validation. A selected pre-upgrade backup suppresses a new one; an explicit override permits recreation without backup. Resume registry removal and `repair-and-recreate` occur only after complete intent validation. Temporary policy/build artifacts remain materialization effects after the delete boundary. | Resume continues the recorded session/machine snapshot; non-resume re-onboard writes a new session first. OpenClaw records completed sandbox name, web search, messaging, and resource choices with explicit progress markers, including explicit `null` choices, while the complete create intent stays process-local and is not persisted or emitted. Raw credential values remain outside the session. A missing process value can be rebound only when the same OpenClaw session recorded successfully registering that provider and its live provider name, provider type, and credential key still match; otherwise interactive resume requests it again and non-interactive resume exits with environment-variable guidance. Credentials are checked before mutation and again immediately before materialization. | A failed replacement keeps the source registry row. Restore failures warn and can still publish the replacement; managed-DCode live-selection failure leaves a running, unregistered sandbox with manual-delete guidance. Checkpoint replay reuses an exact live sandbox after an interrupted create and backfills missing create/register receipts. Cancel rollback is not armed and there is no rebuild-style receipt rollback. Coverage: transition traces, create-intent characterization, checkpoint replay and resume guards, and sandbox-handler crash recovery. Gaps: early backup asymmetry and no rebuild-style cross-effect rollback. | -| **Rebuild or installer-driven upgrade** — `rebuildSandbox` in `rebuild-pipeline.ts`; `upgradeSandboxes` | Registry state is authoritative. A matching session may fill guarded legacy gaps only when its selection agrees; an unrelated/global session is never used. Ambient provider/model selection is quarantined by `isolateAmbientRecreateEnv`, apart from narrowly scoped legacy recovery. Legacy and custom-image rebuilds retain and fingerprint a prepared build context. Managed-image rebuilds instead stage an immutable image and startup-profile handoff, skip Dockerfile image preflight, and revalidate provider-bound workload authority before each deletion boundary. | Consent persistence, target-gateway selection/recovery, and target-preflight registry updates can precede disposable image build/probes. Backup is the first durable recovery checkpoint when available. Shields unlock, MCP detach/scrub, and NIM stop are destructive in-place effects before the **sandbox delete** boundary. Legacy and custom-image paths recheck prepared context and mutation-edge conditions before delete. Managed-image paths revalidate the exact provider-bound handoff before delete. | Durable checkpoints are the backup/recovery manifest when one exists and the rewritten recreate session; stale recovery can reach deletion without a manifest, making that session its first new durable checkpoint. Rollback receipts/snapshots are process-local. Credential metadata comes from the target or guarded fallback; raw credentials/providers are checked against current process/gateway state, while prepared installer recovery may reconstruct a missing gateway provider from a validated host credential. | In-process rollback best-effort restores registry/MCP retry metadata, but process death after non-MCP delete can still lose it. The inner onboarding consumes the exact managed-workload handoff or selects the legacy resource profile after deletion. Covered by rebuild, managed-workload authority, image-preflight, DCode, and messaging tests. Gaps: health-before-delete and atomic swap. Closed issue #5801 records the original gap; #6835 fixed only the printed recovery path. | -| **Stock Docker-driver managed-image onboarding** — managed-workload selection in `onboard-orchestration.ts` | Ordinary onboarding through the OpenShell Docker driver validates one complete all-agent catalog, immutable release and platform contracts, and selected-provider capabilities before selecting OpenClaw, Hermes, or LangChain Deep Agents Code. Portable onboarding, non-managed agents, and explicit `--from` custom images retain their legacy or custom workload paths. | The managed path skips Dockerfile build materialization, creates provider-bound bootstrap authority for the immutable image and startup profile, launches that workload, and registers the managed-workload receipt only after readiness. | Catalog contracts, bootstrap authority, and workload receipts are secret-free and identity-bound. Raw provider credentials retain their existing process and gateway boundaries. | Preparation and provider failures stop before registration; provider-owned bootstrap rollback and durable recovery own partial activation. Catalog, bootstrap, managed-image activation, and protected-runtime tests cover the shipped Docker-driver path. Native Podman remains outside the production provider registry and supported surface. | +| **Rebuild or installer-driven upgrade** — `rebuildSandbox` in `rebuild-pipeline.ts`; `upgradeSandboxes` | Registry state is authoritative for the target configuration, while the current OpenShell sandbox is authoritative for policy and live workspace state. A matching session may fill guarded legacy gaps only when its selection agrees; an unrelated/global session is never used. Ambient provider/model selection is quarantined by `isolateAmbientRecreateEnv`. Legacy and custom-image rebuilds retain and fingerprint a prepared build context. Managed-image rebuilds instead stage an immutable image and startup-profile handoff, skip Dockerfile image preflight, and revalidate provider-bound workload authority before each deletion boundary. | Consent persistence, target-gateway selection/recovery, and target-preflight registry updates can precede disposable image build/probes. Backup plus live-policy capture is the first durable recovery checkpoint. A missing live sandbox stops with clean replacement guidance before Shields, MCP, NIM, registry, or sandbox mutation. Shields unlock, MCP detach/scrub, and NIM stop are destructive in-place effects before the **sandbox delete** boundary. Legacy and custom-image paths recheck prepared context and mutation-edge conditions before delete. Managed-image paths revalidate the exact provider-bound handoff before delete. | Durable checkpoints are the backup/recovery manifest and the rewritten recreate session. A transaction-bound marker inside the backup lets an accepted replacement resume restore and post-restore before the journal clears. Rollback receipts/snapshots are process-local. Credential metadata comes from the target or guarded fallback; raw credentials/providers are checked against current process/gateway state, while prepared installer recovery may reconstruct a missing gateway provider from a validated host credential. | In-process rollback best-effort restores registry/MCP retry metadata. The inner onboarding consumes the exact managed-workload handoff or selects the legacy resource profile after deletion. Covered by rebuild, managed-workload authority, image-preflight, DCode, messaging, and accepted-replacement recovery tests. Gaps: health-before-delete and atomic swap. | +| **Stock Docker-driver managed-image onboarding** — managed-workload selection in `onboard-orchestration.ts` | Ordinary onboarding through the OpenShell Docker driver validates one complete all-agent catalog, immutable release and platform contracts, and exact selected-provider capabilities before selecting OpenClaw, Hermes, or LangChain Deep Agents Code. Portable onboarding, non-managed agents, and explicit `--from` custom images retain their legacy or custom workload paths. | The managed path skips Dockerfile build materialization, creates provider-bound bootstrap authority for the immutable image and startup profile, launches that exact workload, and registers the managed-workload receipt only after readiness. | Catalog contracts, bootstrap authority, and workload receipts are secret-free and identity-bound. Raw provider credentials retain their existing process and gateway boundaries. | Preparation and provider failures stop before registration; provider-owned bootstrap rollback and durable recovery own partial activation. Catalog, bootstrap, managed-image activation, and protected-runtime tests cover the shipped Docker-driver path. Native Podman remains outside the production provider registry and supported surface. | | **Managed snapshot clone handoff and provider transaction (internal and dormant)** — `prepareManagedWorkloadCloneHandoff`; `prepareManagedCloneProviderTransaction` | The current source registry row owns mutable operator intent; the selected snapshot owns immutable managed-workload and provider-runtime history. Handoff preparation proves the selected runtime provider and its `clone` capability, exact current registry generation and live-identity fingerprint, snapshot/source workload equivalence, snapshot runtime generation, and the state layer's selected-manifest/payload digest. It rebinds the secret-free startup profile, messaging intent, dashboard identity, and provider-owned contributions for OpenClaw, Hermes, or DCode without a central Podman-specific switch. Provider preparation then resolves active application bindings plus provider-contributed bindings, treating a live exact provider as reusable only when the destination registry independently proves that same logical binding. | The handoff and provider plan are inert. The internal materializer can create only bindings proven absent at preflight; it never updates or deletes an existing destination-owned provider. Immediately before each create it revalidates the source and optional destination registry rows plus the exact `SnapshotRestoreAuthority`. Production snapshot restore does not invoke this transaction and continues to reject cross-sandbox managed-image restore through `rejectManagedSnapshotCloneUntilRebind`; no user-visible clone support is advertised. | Both plans are deeply frozen and secret-free. A successful create produces an exact process-local ownership receipt; a non-zero create reconciled to an exact provider remains ambiguous and unowned. The receipt ledger remembers completed cleanup so a repeated cleanup cannot delete a later same-name provider. Raw credentials exist only in the explicit apply environment and one OpenShell child environment. | Failure rolls back only providers confirmed created by the exact in-process receipt, preserves collisions and ambiguous creates, reports incomplete cleanup for retry, and never rewrites a reused provider. `src/lib/onboard/managed-workload-clone-handoff.test.ts`, `src/lib/onboard/managed-startup-clone-rebinder.test.ts`, and `src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts` cover the all-agent, Docker/MXC-style provider, canonical-name, and fail-closed boundaries; provider transaction tests cover race, force-replace, disappearing-credential, rollback, and idempotent cleanup. This PR intentionally covers only the dormant contract. Epic [#7744](https://github.com/NVIDIA/NemoClaw/issues/7744) tracks destination creation/bootstrap, filesystem mutation-edge invocation, Hermes broker activation, durable recovery, protected E2E, and user-visible activation. | -| **Channel add/remove/start/stop** — `addSandboxChannel`, `removeSandboxChannel`, `sandboxChannelsSetEnabled` in `policy-channel.ts` | Add compiles and merges a manifest-derived channel delta with `MessagingWorkflowPlanner`. Start, stop, and remove transform the registry plan and rehydrate executable render/build/runtime/forward details from current manifests. | Token-backed add can mutate gateway credentials before policy and plan persistence; QR/in-sandbox-auth add skips that credential upsert. Start persists the enabled plan before policy; stop persists the disabled plan before the rebuild prompt. Remove clears QR-backed durable state when applicable, detaches gateway/bridge state, removes policy, then persists the plan. A queued rebuild has a separate delete boundary. | The compact registry messaging plan is authoritative; render/build/runtime/state/health entries and nested host-forward details are rehydrated rather than persisted. Session policy-preset sync is best-effort, and channel mutations do not rewrite `Session.messagingPlan`. Raw tokens stay in process/gateway bindings. | `rollbackChannelAdd`, re-disable after failed start, and fail-closed QR-state cleanup provide partial compensation. Covered by `policy-channel*.test.ts`, `workflow-planner.test.ts`, and channel integration tests. Gaps: channel add has a separate `--force` conflict policy; add/remove effects can precede plan persistence, and persistence failures are not fully rolled back. | +| **Channel add/remove/start/stop** — `addSandboxChannel`, `removeSandboxChannel`, `sandboxChannelsSetEnabled` in `policy-channel.ts` | Add compiles and merges a manifest-derived channel delta with `MessagingWorkflowPlanner`. Start, stop, and remove transform the registry plan and rehydrate executable policy/render/build/runtime/forward details from current manifests. | Token-backed add can mutate gateway credentials before policy and plan persistence; QR/in-sandbox-auth add skips that credential upsert. Start persists the enabled plan before policy; stop persists the disabled plan before the rebuild prompt. Remove clears QR-backed durable state when applicable, detaches gateway/bridge state, removes policy, then persists the plan. A queued rebuild has a separate delete boundary. | The compact registry messaging plan stores channel and credential intent but never policy references. Policy contributions are transient command input regenerated from current manifests and applied to the current OpenShell policy. Render/build/runtime/state/health entries and nested host-forward details are also rehydrated rather than persisted. Channel mutations do not rewrite `Session.messagingPlan`. Raw tokens stay in process/gateway bindings. | `rollbackChannelAdd`, re-disable after failed start, and fail-closed QR-state cleanup provide partial compensation. Covered by `policy-channel*.test.ts`, `workflow-planner.test.ts`, and channel integration tests. Gaps: channel add has a separate `--force` conflict policy; add/remove effects can precede plan persistence, and persistence failures are not fully rolled back. | | **Provider, model, or credential-binding change** — `runInferenceSet` | CLI intent plus registry/session metadata. Target resolution and OpenShell preparation occur before locking. The target is re-resolved in the mutating phase under the sandbox lifecycle and timer-bound shields locks; that phase validates provider/model syntax, selected agent, shields state, and local reachability before the first write. | First mutation is the gateway route, then a minimal registry write, API-family/config resolution, registry refresh, best-effort config/hash sync, matching-session update, and audit. An OpenClaw API-family change can then restart the managed gateway after the shields lock is released but while the outer sandbox lock remains held. No sandbox deletion. | Registry and matching session store logical provider/model/credential-environment metadata. Audit records the action, sandbox, and reason rather than credentials; raw values remain gateway-bound. | Forward-only; no rollback. `rebuild` is the repair path for degraded state. Covered by `inference-set*.test.ts`. Gap: several stores can diverge after a mid-sequence failure. | | **Credential rotation** — `configRotateToken` in `src/lib/sandbox/config.ts`; `rotateSandboxToken` in `src/lib/sandbox/config-rotate-token.ts` | A session with `credentialEnv` selects the provider and binding. A non-null different `sandboxName` is rejected, but a legacy/null session name is accepted for the requested sandbox. The new value comes from a named environment variable, stdin, or a secret prompt; it is trimmed, then rejected when empty or still containing internal whitespace. | An OpenAI provider profile is validated before credential staging. When that profile is missing, its import is the first external mutation. `saveCredential` then stages the value in the current process. OpenShell provider update follows, with provider create as a fallback; audit is last. Other provider types begin with `saveCredential`. No sandbox deletion. | The logical binding is unchanged, so session and registry are not rewritten. The raw value exists only in process memory/environment and the gateway provider; audit records action/sandbox/reason without the value. | Profile validation or import failure stops credential staging and provider mutation. No rollback follows a successful profile import or provider update; an audit failure can report failure after the credential is already active. Covered by `test/security/config-rotate-token-provider-profile.test.ts` and the rotate-token case in `test/security/config-set-nested-ssrf.test.ts`. Gap: a null-name legacy session is not strongly bound to the requested sandbox. | -| **Config, policy, resource, port-forward, and runtime setup contributions** — `configSet`; `prepareInitialSandboxCreatePolicy`; `selectResourceProfileForSandbox`; manifest compiler/runtime appliers; dashboard and channel forward helpers | Config uses validated dotpaths and SSRF-safe URL rewriting. Create/rebuild contributions are assembled by `sandbox-create-plan.ts` and `MessagingWorkflowPlanner`: policy presets/keys, resource flags, package/build steps, `hostForward`, runtime node preloads, env aliases, and secret scans. | Config’s first effect is a compare-and-swap sandbox write. Build-time contributions inherit the enclosing create/recreate boundary. Forward helpers can stop an existing forward and start its replacement in place after readiness, without recreating the sandbox. | Durable owners are compact registry messaging/policy/inference metadata, current manifests used for plan rehydration, onboard session, sandbox config/hash, gateway provider state, and shields audit. An interrupted onboarding session records the selected resource values or an explicit OpenShell-default choice; the resolved create intent remains process-local. Logical bindings are serializable; raw provider values are not. | CAS rejects stale config writes; OpenClaw/Hermes commit config and integrity hashes together, while other agents may refresh a path hash afterward. Audit and optional restart are post-commit and forward-only. Forward recovery can re-establish declared forwards. Gaps: no cross-contribution effect transaction/checkpoint. | +| **Config, policy, resource, port-forward, and runtime setup contributions** — `configSet`; `prepareInitialSandboxCreatePolicy`; `selectResourceProfileForSandbox`; manifest compiler/runtime appliers; dashboard and channel forward helpers | Config uses validated dotpaths and SSRF-safe URL rewriting. Create/rebuild contributions are assembled by `sandbox-create-plan.ts` and `MessagingWorkflowPlanner`: transient policy presets/keys, resource flags, package/build steps, `hostForward`, runtime node preloads, env aliases, and secret scans. | Config’s first effect is a compare-and-swap sandbox write. Build-time contributions inherit the enclosing create/recreate boundary. Policy commands read-modify-write the current OpenShell policy. Forward helpers can stop an existing forward and start its replacement in place after readiness, without recreating the sandbox. | Durable owners are compact non-policy registry intent, current manifests used for plan rehydration, onboard session, sandbox config/hash, gateway provider state, and shields audit. OpenShell alone stores the durable sandbox policy. An interrupted onboarding session records the selected resource values or an explicit OpenShell-default choice; the resolved create intent remains process-local. Logical bindings are serializable; raw provider values are not. | CAS rejects stale config writes; OpenClaw/Hermes commit config and integrity hashes together, while other agents may refresh a path hash afterward. Audit and optional restart are post-commit and forward-only. Forward recovery can re-establish declared forwards. Gaps: no cross-contribution effect transaction/checkpoint. | ## Durable resumed recreate journal @@ -241,7 +251,7 @@ The schema and sanitation authority is `Session` plus `normalizeSession`/`filter | Progress and recovery | `lastStepStarted`, `lastCompletedStep`, `failure`, `steps`, `machine`, `sandboxPromptProgress`, `stagedCredentialProviders`, `checkpoint` | Step helpers record step-progress bookkeeping and context updates accepted by `filterSafeUpdates`. `OnboardRuntime` owns machine transitions, terminal state, and machine events. Explicit session recovery and the process-exit failure backstop are separate recovery boundaries. The OpenClaw sandbox handler owns prompt-group completion markers. `stagedCredentialProviders` contains only names registered before sandbox setup so OpenClaw resume can require both durable ownership and a live binding. A recreate journal handed to this run by the driver that owns the replacement — matching sandbox name and target-intent fingerprint, and past the delete boundary at `deleted` — is the equivalent ownership proof for a replacement that reset the session and can no longer read a host credential, and it stays paired with the same live binding check. A journal merely resident in the session is not that proof, because nothing binds it to this run: one survives a failed attempt, and one is opened straight at `deleted` when the sandbox is already missing. Provider-effect replay requires the receipt provider set to match the providers selected by the current web search configuration or messaging plan. Each persisted and live provider name, provider type, and credential key must match before the handler skips registration. After a successful replay, the handler replaces obsolete bindings owned by that effect group before sandbox creation and preserves bindings owned by the other provider effect group. A marker is trusted only when its matching persisted value is present and valid, including an explicit `null` where supported. `checkpoint` is the dedicated versioned resume contract: a secret-free tri-state decision record plus durable sandbox identity, effect-group receipts, and logical web-search and messaging provider bindings, serialized alongside the session under its own `schemaVersion` with fail-closed handling of an unknown future version. The primary inference provider binding remains owned and revalidated by the provider and inference phases instead of entering this checkpoint ledger. | | Target identity | `agent`, `sandboxName`, `metadata.gatewayName`, `metadata.fromDockerfile` | Onboard selection, sandbox handler/registration, and rebuild session preparation. A completed sandbox step or valid `sandboxPromptProgress.sandboxName` marker is the trust gate for a recorded name. | | Inference intent | `provider`, `model`, `endpointUrl`, `credentialEnv`, `preferredInferenceApi`, `compatibleEndpointReasoning`, `nimContainer`, `webSearchConfig` | Provider/inference handlers and `runInferenceSet`. Known credential state is an environment-variable name or presence metadata, never the value. `redactUrl` masks userinfo and fragments, redacts values under sensitive parameter names, and redacts canonical token-shaped values even under benign parameter names. | -| Agent and policy intent | `hermesAuthMethod`, `toolDisclosure`, `hermesToolGateways`, `policyPresets`, `policyAuthority` | Agent setup and policy handling. `policyAuthority` records the strict OpenShell metadata decision before policy-dependent onboarding effects. External authority clears local preset attribution. Channel commands update matching-session `policyPresets` only best-effort. Nullable fields conflate unset, declined, and cleared where the CLI makes those distinctions. | +| Agent and policy intent | `hermesAuthMethod`, `toolDisclosure`, `hermesToolGateways` | Agent setup retains feature choices only. Policy requirements are read from and applied to OpenShell; the session does not persist ownership, receipts, or preset attribution. | | Messaging intent | `messagingPlan`, `telegramConfig`, `wechatConfig` | `../messaging/plan-authority.ts` selects the registry messaging plan for an existing sandbox. Consumers with a known sandbox target resolve registry authority before they read a staged environment plan. Disabled-channel resolution also skips session loading when registry state is authoritative. A valid staged plan can still resolve a different target during implicit configuration lookup. For a new or pending target, a staged plan takes precedence over a matching session plan. `telegramConfig` and `wechatConfig` provide legacy configuration fallback. Raw credential values remain outside the session. | | Resource choice | `resourceProfile` | The sandbox handler records concrete CPU/RAM values or `null` for an explicit OpenShell-default choice after the prompt completes. Environment overrides still take precedence on the recovery run. | | Runtime metadata | `routerPid`, `routerCredentialHash`, `gpuPassthrough` | Router and sandbox setup/recovery. PID is a live-process hint; credential hash is a digest; GPU is a concrete boolean. | @@ -257,7 +267,7 @@ The registry is separately owned by `src/lib/state/registry.ts`; backup and reco 4. **Backup/restore policy:** rebuild and ordinary live recreate back up, while not-ready resume repair deletes before the generic backup; installer restore and channel mutation checkpoint different state again. Recommended owner: one backup/restore policy module. 5. **Registry lifecycle:** create registers post-ready; same-name replacement preserves the source row until replacement registration commits; rebuild records removals and restores retry metadata through `rebuild-registry-rollback.ts`. Recommended owner: `sandbox-registration.ts` plus the existing durable pre-create identity. 6. **Replacement validation:** legacy and custom-image rebuilds retain and fingerprint a prepared build context; managed-image rebuilds retain an immutable workload/profile handoff and revalidate provider-bound authority before deletion; normal live DCode rebuild adds route and managed-context proofs; re-onboard still stages legacy replacement work after delete. Health-before-delete and atomic swap remain unresolved. Closed issue #5801 records the original gap; #6835 fixed only the printed recovery path. -7. **Policy reconciliation:** registration records create-time presets, `handlePoliciesState` later persists the reconciled live set, and channel mutations synchronize their own plan/session preset state. Recommended owner: policy preset persistence/sync modules. +7. **Policy reconciliation:** create-time and channel contributions are transient command plans. `handlePoliciesState` and channel mutations read-modify-write the current OpenShell policy and persist no preset set. Recommended owner: the shared OpenShell policy boundary. ## Bug-to-contract-gap map diff --git a/src/lib/onboard/local-model-profile/onboarder.test.ts b/src/lib/onboard/local-model-profile/onboarder.test.ts index 5de1b778e67..66874f4caf3 100644 --- a/src/lib/onboard/local-model-profile/onboarder.test.ts +++ b/src/lib/onboard/local-model-profile/onboarder.test.ts @@ -159,13 +159,13 @@ describe("dedicated local model profile onboarder", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining("resumed vLLM model conflicts")); }); - it("refuses vLLM install intent persistence when policy authority changes (#9833)", async () => { + it("refuses vLLM install intent persistence when policy requirements changes (#9833)", async () => { const selection = state(); const checkpointVllmInstallModel = vi.fn(); const installEffect = vi.fn(); const handleVllmSelection = vi.fn(async () => "selected" as const); const observedRoutes: Array> = []; - selection.revalidatePolicyRequirements = (operation) => { + selection.verifyLivePolicyRequirements = (operation) => { observedRoutes.push({ operation, provider: selection.provider, @@ -174,7 +174,7 @@ describe("dedicated local model profile onboarder", () => { credentialEnv: selection.credentialEnv, preferredInferenceApi: selection.preferredInferenceApi, }); - throw new Error("external policy authority must supply local inference"); + throw new Error("live policy requirements changed before local inference"); }; const installVllm = vi.fn(async (_profile: VllmProfile, options) => { options.checkpointInstallIntent?.("nvidia/Qwen3.6-35B-A3B-NVFP4"); @@ -206,7 +206,7 @@ describe("dedicated local model profile onboarder", () => { }, selection, ), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(checkpointVllmInstallModel).not.toHaveBeenCalled(); expect(installEffect).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/local-model-profile/onboarder.ts b/src/lib/onboard/local-model-profile/onboarder.ts index d544d4ca87a..8a14e6e24ac 100644 --- a/src/lib/onboard/local-model-profile/onboarder.ts +++ b/src/lib/onboard/local-model-profile/onboarder.ts @@ -116,14 +116,14 @@ export function createLocalModelProfileOnboarder(deps: LocalModelProfileOnboarde ? { checkpointInstallIntent: (modelId: string) => { seedVllmInstallRoute(modelId); - state.revalidatePolicyRequirements?.("record managed vLLM install intent"); + state.verifyLivePolicyRequirements?.("record managed vLLM install intent"); checkpointInstallIntent(modelId); }, } : {}), beforeInstall: (modelId) => { seedVllmInstallRoute(modelId); - state.revalidatePolicyRequirements?.("install managed vLLM runtime"); + state.verifyLivePolicyRequirements?.("install managed vLLM runtime"); }, }); if (!result.ok) return "retry-selection"; diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index a76c7ec8f86..67be35c0b0a 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -114,7 +114,6 @@ function createPhases( preferredInferenceApi: "chat", gatewayName: "nemoclaw", gpuEnabled: false, - policies: [], }); const endpointProvenance = { getSandboxRegistryEntry, @@ -223,8 +222,6 @@ function createPhases( }) as (code: number) => never, deleteEnv: vi.fn(), ...overrides.providerDeps, - preflightPolicyRequirements: - overrides.providerDeps?.preflightPolicyRequirements ?? (() => undefined), }, }); const sandbox = createSandboxOnboardFlowPhase({ @@ -294,7 +291,6 @@ function createPhases( directGpu: false, additionalPresets: [], policyTier: null, - baselineExclusions: [], }, }, gpuCreateArgs: [], @@ -321,8 +317,6 @@ function createPhases( throw new Error(`exit ${code}`); }) as (code: number) => never, ...overrides.sandboxDeps, - preflightPolicyRequirements: - overrides.sandboxDeps?.preflightPolicyRequirements ?? (() => undefined), checkGatewayRouteCompatibility: overrides.sandboxDeps?.checkGatewayRouteCompatibility ?? (() => ({ ok: true })), withGatewayRouteMutationLock: @@ -622,9 +616,9 @@ describe("core onboard flow phases", () => { assignments: ["SLACK_HOME_CHANNEL=C0123"], }, ]; - const rebuildPolicyPresets = ["github"]; + const rebuildPolicySourcePath = "/tmp/current-policy.yaml"; const { providerInference: providerPhase, sandbox: sandboxPhase } = createPhases({ - sandboxOptions: { rebuildPreservedEnv, rebuildPolicyPresets }, + sandboxOptions: { rebuildPreservedEnv, rebuildPolicySourcePath }, sandboxDeps: { createSandbox }, }); @@ -633,7 +627,7 @@ describe("core onboard flow phases", () => { expect((createSandbox.mock.calls[0] as unknown[] | undefined)?.[15]).toMatchObject({ rebuildPreservedEnv, - rebuildPolicyPresets, + rebuildPolicySourcePath, }); }); @@ -650,7 +644,7 @@ describe("core onboard flow phases", () => { }; const runVerifiedEffects = args[16] as | ((context: { - revalidatePolicyRequirements: (operation: string) => void; + verifyLivePolicyRequirements: (operation: string) => void; }) => Promise) | undefined; expect(createIntent).toMatchObject({ @@ -1111,7 +1105,6 @@ describe("core onboard flow phases", () => { preferredInferenceApi: null, gatewayName: "nemoclaw", gpuEnabled: false, - policies: [], }), }, }); @@ -1151,7 +1144,6 @@ describe("core onboard flow phases", () => { allowToolsIncompatible: false, endpointSource: null, reservationSessionId: session.sessionId, - revalidatePolicyRequirements: expect.any(Function), }, ); expect(result.context.hermesToolGateways).toEqual(["nous-web"]); @@ -1208,7 +1200,6 @@ describe("core onboard flow phases", () => { preferredInferenceApi: "openai-completions", gatewayName: "nemoclaw", gpuEnabled: false, - policies: [], })); const { providerInference: providerPhase, sandbox: sandboxPhase } = createPhases({ providerDeps: { diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index 556ef4e016f..96c17db2363 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -75,14 +75,13 @@ export interface SandboxOnboardFlowPhaseOptions< hermesPortableLifecycle?: boolean; apfInterceptorRequested?: boolean; authoritativeResumeConfig?: boolean; - authoritativePolicyTier?: string | null; recreateJournalTargetIntentFingerprint?: string | null; resumeAgentChanged: boolean; requestedObservabilityEnabled?: boolean | null; requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; rebuildPreservedEnv?: readonly import("../../state/preserved-env").PreservedEnvFile[]; - rebuildPolicyPresets?: readonly string[]; + rebuildPolicySourcePath?: string; hostMounts?: readonly import("../../state/registry/types").SandboxHostMount[]; endpointProvenance: EndpointProvenanceOptions; recreateSandbox: (requested?: boolean) => boolean; @@ -334,7 +333,6 @@ export function createSandboxOnboardFlowPhase< hermesPortableLifecycle: options.hermesPortableLifecycle === true, apfInterceptorRequested: options.apfInterceptorRequested === true, authoritativeResumeConfig: options.authoritativeResumeConfig, - authoritativePolicyTier: options.authoritativePolicyTier, deferSandboxEffectsUntilPolicyVerification: options.apfInterceptorRequested === true, recreateJournalTargetIntentFingerprint: options.recreateJournalTargetIntentFingerprint, @@ -343,7 +341,7 @@ export function createSandboxOnboardFlowPhase< requestedObservabilityEnabled: options.requestedObservabilityEnabled, requestedDcodeAutoApprovalMode: options.requestedDcodeAutoApprovalMode, rebuildPreservedEnv: options.rebuildPreservedEnv, - rebuildPolicyPresets: options.rebuildPolicyPresets, + rebuildPolicySourcePath: options.rebuildPolicySourcePath, hostMounts: options.hostMounts, recreateSandbox: options.recreateSandbox, session: context.session, diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts index 1aed568b574..4149ec2a38c 100644 --- a/src/lib/onboard/machine/events.ts +++ b/src/lib/onboard/machine/events.ts @@ -133,7 +133,6 @@ export function buildOnboardMachineContext(session: Session): OnboardMachineCont ...(reasoningEffort ? { reasoningEffort } : {}), hermesAuthMethod: hermesAuthMethod(session.hermesAuthMethod), hermesToolGateways: stringArray(session.hermesToolGateways), - policyPresets: stringArray(session.policyPresets), messagingChannels: getActiveChannelsFromPlan(session.messagingPlan), gpuPassthrough: booleanValue(session.gpuPassthrough), }; diff --git a/src/lib/onboard/machine/final-flow-phases.runtime.test.ts b/src/lib/onboard/machine/final-flow-phases.runtime.test.ts index 73170042ccd..fb088379d2f 100644 --- a/src/lib/onboard/machine/final-flow-phases.runtime.test.ts +++ b/src/lib/onboard/machine/final-flow-phases.runtime.test.ts @@ -35,54 +35,57 @@ describe("final onboard flow runtime boundary", () => { it.each([ { label: "fresh", resume: false }, { label: "resumed", resume: true }, - ])("uses the strict final runner for $label OpenClaw sessions at the branch state", async ({ - resume, - }) => { - const order: string[] = []; - const harness = createRuntimeHarness(sessionAt("openclaw")); - const recorders = harness.boundary.recorders(); - const phases = createPhases("openclaw", order, { - loadSession: harness.getSession, - recordStepSkipped: recorders.recordStepSkipped, - recordStateSkipped: recorders.recordStateSkipped, - startRecordedStep: recorders.startRecordedStep, - recordStepComplete: recorders.recordStepComplete, - }); - await runFinalOnboardFlowSlice({ - context: context({ resume, session: harness.getSession() }), - runtime: harness.boundary.getRuntime(), - phases, - recordRepairEvent: recorders.recordRepairEvent, - afterPoliciesReady: () => { - order.push("disarm"); - }, - }); + ])( + "uses the strict final runner for $label OpenClaw sessions at the branch state", + async ({ resume }) => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt("openclaw")); + const recorders = harness.boundary.recorders(); + const phases = createPhases("openclaw", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + }); + await runFinalOnboardFlowSlice({ + context: context({ resume, session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + recordRepairEvent: recorders.recordRepairEvent, + afterPoliciesReady: () => { + order.push("disarm"); + }, + }); - expect(order).toEqual([ - "openclaw", - "policies", - "disarm", - "set-default", - "agent-forward", - "verify", - ]); - expect(harness.getSession()).toMatchObject({ - status: "complete", - sandboxName: "my-sandbox", - provider: "nim", - model: "nvidia/test", - machine: { state: "complete" }, - }); - expect( - harness.events.filter((event) => event.type === "state.entered").map((event) => event.state), - ).toEqual(["policies", "finalizing", "post_verify", "complete"]); - expect( - harness.events - .filter((event) => event.type === "state.skipped") - .map((event) => `${event.type}:${event.state}`), - ).toEqual(["state.skipped:agent_setup"]); - expect(harness.events.some((event) => event.type.startsWith("state.repair."))).toBe(false); - }); + expect(order).toEqual([ + "openclaw", + "policies", + "disarm", + "set-default", + "agent-forward", + "verify", + ]); + expect(harness.getSession()).toMatchObject({ + status: "complete", + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { state: "complete" }, + }); + expect( + harness.events + .filter((event) => event.type === "state.entered") + .map((event) => event.state), + ).toEqual(["policies", "finalizing", "post_verify", "complete"]); + expect( + harness.events + .filter((event) => event.type === "state.skipped") + .map((event) => `${event.type}:${event.state}`), + ).toEqual(["state.skipped:agent_setup"]); + expect(harness.events.some((event) => event.type.startsWith("state.repair."))).toBe(false); + }, + ); it.each([ { initialState: "policies" as const, branchState: "openclaw" as const, resume: true }, @@ -90,86 +93,85 @@ describe("final onboard flow runtime boundary", () => { { initialState: "post_verify" as const, branchState: "openclaw" as const, resume: true }, { initialState: "finalizing" as const, branchState: "openclaw" as const, resume: false }, { initialState: "post_verify" as const, branchState: "agent_setup" as const, resume: true }, - ])("repairs prerequisites before strict $initialState entry for $branchState", async ({ - initialState, - branchState, - resume, - }) => { - const order: string[] = []; - const harness = createRuntimeHarness(sessionAt(initialState)); - const recorders = harness.boundary.recorders(); - const phases = createPhases(branchState, order, { - loadSession: harness.getSession, - recordStepSkipped: recorders.recordStepSkipped, - recordStateSkipped: recorders.recordStateSkipped, - startRecordedStep: recorders.startRecordedStep, - recordStepComplete: recorders.recordStepComplete, - }); - const recordRepairEvent = vi.fn(recorders.recordRepairEvent); + ])( + "repairs prerequisites before strict $initialState entry for $branchState", + async ({ initialState, branchState, resume }) => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt(initialState)); + const recorders = harness.boundary.recorders(); + const phases = createPhases(branchState, order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + }); + const recordRepairEvent = vi.fn(recorders.recordRepairEvent); - await runFinalOnboardFlowSlice({ - context: context({ - agent: branchState === "agent_setup" ? { name: "hermes" } : null, - resume, - session: harness.getSession(), - }), - runtime: harness.boundary.getRuntime(), - phases, - recordRepairEvent, - afterPoliciesReady: () => { - order.push("disarm"); - }, - }); + await runFinalOnboardFlowSlice({ + context: context({ + agent: branchState === "agent_setup" ? { name: "hermes" } : null, + resume, + session: harness.getSession(), + }), + runtime: harness.boundary.getRuntime(), + phases, + recordRepairEvent, + afterPoliciesReady: () => { + order.push("disarm"); + }, + }); - expect(order).toEqual([ - ...(branchState === "openclaw" ? ["openclaw"] : ["agent-setup", "agent-forward"]), - "policies", - "disarm", - "set-default", - "agent-forward", - "verify", - ]); - expect(harness.getSession()).toMatchObject({ - status: "complete", - sandboxName: "my-sandbox", - provider: "nim", - model: "nvidia/test", - machine: { state: "complete" }, - }); + expect(order).toEqual([ + ...(branchState === "openclaw" ? ["openclaw"] : ["agent-setup", "agent-forward"]), + "policies", + "disarm", + "set-default", + "agent-forward", + "verify", + ]); + expect(harness.getSession()).toMatchObject({ + status: "complete", + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { state: "complete" }, + }); - const prerequisiteStates = [branchState, "policies", "finalizing"].slice( - 0, - [branchState, "policies", "finalizing", "post_verify"].indexOf(initialState), - ); - expect(recordRepairEvent.mock.calls).toEqual( - prerequisiteStates.flatMap((state) => [ - [ - "state.repair.started", - { - state, - metadata: { repair: "final-flow-prerequisite", entryState: initialState }, - }, - ], - [ - "state.repair.completed", - { - state, - metadata: { repair: "final-flow-prerequisite", entryState: initialState }, - }, - ], - ]), - ); - expect(harness.events.some((event) => event.type === "state.result.invalidated")).toBe(false); - expect( - harness.events.filter((event) => event.type === "state.exited").map((event) => event.state), - ).toEqual( - { - policies: ["policies", "finalizing"], - finalizing: ["finalizing"], - post_verify: [], - }[initialState], - ); - }); + const prerequisiteStates = [branchState, "policies", "finalizing"].slice( + 0, + [branchState, "policies", "finalizing", "post_verify"].indexOf(initialState), + ); + expect(recordRepairEvent.mock.calls).toEqual( + prerequisiteStates.flatMap((state) => [ + [ + "state.repair.started", + { + state, + metadata: { repair: "final-flow-prerequisite", entryState: initialState }, + }, + ], + [ + "state.repair.completed", + { + state, + metadata: { repair: "final-flow-prerequisite", entryState: initialState }, + }, + ], + ]), + ); + expect(harness.events.some((event) => event.type === "state.result.invalidated")).toBe(false); + expect( + harness.events.filter((event) => event.type === "state.exited").map((event) => event.state), + ).toEqual( + { + finalizing: ["finalizing"], + policies: ["policies", "finalizing"], + post_verify: [], + }[initialState], + ); + }, + ); it.each([ { state: "sandbox" as const, branchState: "openclaw" as const }, @@ -177,76 +179,77 @@ describe("final onboard flow runtime boundary", () => { { state: "failed" as const, branchState: "openclaw" as const }, { state: "agent_setup" as const, branchState: "openclaw" as const }, { state: "openclaw" as const, branchState: "agent_setup" as const }, - ])("rejects $state before effects for a $branchState final flow", async ({ - state, - branchState, - }) => { - const order: string[] = []; - const harness = createRuntimeHarness(sessionAt(state)); - const recordRepairEvent = vi.fn(harness.boundary.recordRepairEvent.bind(harness.boundary)); + ])( + "rejects $state before effects for a $branchState final flow", + async ({ state, branchState }) => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt(state)); + const recordRepairEvent = vi.fn(harness.boundary.recordRepairEvent.bind(harness.boundary)); - await expect( - runFinalOnboardFlowSlice({ - context: context({ session: harness.getSession() }), - runtime: harness.boundary.getRuntime(), - phases: createPhases(branchState, order), - recordRepairEvent, - }), - ).rejects.toBeInstanceOf(UnexpectedOnboardFlowSliceStateError); + await expect( + runFinalOnboardFlowSlice({ + context: context({ session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases: createPhases(branchState, order), + recordRepairEvent, + }), + ).rejects.toBeInstanceOf(UnexpectedOnboardFlowSliceStateError); - expect(order).toEqual([]); - expect(recordRepairEvent).not.toHaveBeenCalled(); - }); + expect(order).toEqual([]); + expect(recordRepairEvent).not.toHaveBeenCalled(); + }, + ); it.each([ { label: "fresh", resume: false }, { label: "resumed", resume: true }, - ])("uses the strict final runner for $label agent sessions at the branch state", async ({ - resume, - }) => { - const order: string[] = []; - const harness = createRuntimeHarness(sessionAt("agent_setup")); - const recorders = harness.boundary.recorders(); - const phases = createPhases("agent_setup", order, { - loadSession: harness.getSession, - recordStepSkipped: recorders.recordStepSkipped, - recordStateSkipped: recorders.recordStateSkipped, - startRecordedStep: recorders.startRecordedStep, - recordStepComplete: recorders.recordStepComplete, - }); - await runFinalOnboardFlowSlice({ - context: context({ agent: { name: "hermes" }, resume, session: harness.getSession() }), - runtime: harness.boundary.getRuntime(), - phases, - recordRepairEvent: recorders.recordRepairEvent, - afterPoliciesReady: () => { - order.push("disarm"); - }, - }); + ])( + "uses the strict final runner for $label agent sessions at the branch state", + async ({ resume }) => { + const order: string[] = []; + const harness = createRuntimeHarness(sessionAt("agent_setup")); + const recorders = harness.boundary.recorders(); + const phases = createPhases("agent_setup", order, { + loadSession: harness.getSession, + recordStepSkipped: recorders.recordStepSkipped, + recordStateSkipped: recorders.recordStateSkipped, + startRecordedStep: recorders.startRecordedStep, + recordStepComplete: recorders.recordStepComplete, + }); + await runFinalOnboardFlowSlice({ + context: context({ agent: { name: "hermes" }, resume, session: harness.getSession() }), + runtime: harness.boundary.getRuntime(), + phases, + recordRepairEvent: recorders.recordRepairEvent, + afterPoliciesReady: () => { + order.push("disarm"); + }, + }); - expect(order).toEqual([ - "agent-setup", - "agent-forward", - "policies", - "disarm", - "set-default", - "agent-forward", - "verify", - ]); - expect(harness.getSession()).toMatchObject({ - status: "complete", - sandboxName: "my-sandbox", - provider: "nim", - model: "nvidia/test", - machine: { state: "complete" }, - }); - expect( - harness.events - .filter((event) => event.type === "state.skipped") - .map((event) => `${event.type}:${event.state}`), - ).toEqual(["state.skipped:openclaw"]); - expect(harness.events.some((event) => event.type.startsWith("state.repair."))).toBe(false); - }); + expect(order).toEqual([ + "agent-setup", + "agent-forward", + "policies", + "disarm", + "set-default", + "agent-forward", + "verify", + ]); + expect(harness.getSession()).toMatchObject({ + status: "complete", + sandboxName: "my-sandbox", + provider: "nim", + model: "nvidia/test", + machine: { state: "complete" }, + }); + expect( + harness.events + .filter((event) => event.type === "state.skipped") + .map((event) => `${event.type}:${event.state}`), + ).toEqual(["state.skipped:openclaw"]); + expect(harness.events.some((event) => event.type.startsWith("state.repair."))).toBe(false); + }, + ); it("enters post verification with the updated live final context", async () => { const order: string[] = []; diff --git a/src/lib/onboard/machine/final-flow-phases.ts b/src/lib/onboard/machine/final-flow-phases.ts index 11b00f49f23..bcf314a8589 100644 --- a/src/lib/onboard/machine/final-flow-phases.ts +++ b/src/lib/onboard/machine/final-flow-phases.ts @@ -31,8 +31,6 @@ export interface FinalOnboardFlowPhaseOptions< VerificationResult = unknown, > { branchState: "agent_setup" | "openclaw"; - authoritativePolicyTier?: string | null; - revalidatePolicyRequirements?(context: Context, operation: string): void; agentSetupDeps: AgentSetupStateOptions["deps"]; policiesDeps: PoliciesStateOptions["deps"]; finalization: { @@ -63,9 +61,6 @@ export function createFinalOnboardFlowPhases< ...options.finalizationDeps, persistDashboardPort: options.agentSetupDeps.persistDashboardPort, }; - const revalidationFor = (context: Context) => - options.revalidatePolicyRequirements?.bind(null, context) ?? - finalizationDeps.revalidatePolicyRequirements; const createBranchPhase = options.branchState === "agent_setup" ? createAgentSetupPhase : createOpenclawSetupPhase; const branchSetupPhase = createBranchPhase(async (context) => { @@ -80,7 +75,6 @@ export function createFinalOnboardFlowPhases< session: context.session, hermesAuthMethod: context.hermesAuthMethod, hermesToolGateways: context.hermesToolGateways, - revalidatePolicyRequirements: revalidationFor(context), deps: options.agentSetupDeps, }); return { @@ -93,7 +87,6 @@ export function createFinalOnboardFlowPhases< assertSandboxCreatedContext(context, "policies"); const policiesResult = await handlePoliciesState({ resume: context.resume, - authoritativePolicyTier: options.authoritativePolicyTier, sandboxName: context.sandboxName, provider: context.provider, hostLocalInferenceRouteOnly: context.hostLocalInferenceRouteOnly === true, @@ -108,7 +101,6 @@ export function createFinalOnboardFlowPhases< webSearchSupported: context.webSearchSupported, hermesToolGateways: context.hermesToolGateways, agent: context.agent, - revalidatePolicyRequirements: revalidationFor(context), deps: options.policiesDeps, }); return { @@ -123,7 +115,6 @@ export function createFinalOnboardFlowPhases< const finalizationPhase = createFinalizationPhase(async (context) => { assertSandboxCreatedContext(context, "finalization"); const webSearchEnabled = options.finalization.webSearchEnabled(context.webSearchConfig); - const revalidatePolicyRequirements = revalidationFor(context); const finalizationResult = await handleFinalizationState({ sandboxName: context.sandboxName, model: context.model, @@ -141,7 +132,7 @@ export function createFinalOnboardFlowPhases< : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", recreateJournalHandoff: context.recreateJournalHandoff, - deps: { ...finalizationDeps, revalidatePolicyRequirements }, + deps: finalizationDeps, }); return { result: finalizationResult.stateResult }; }); @@ -166,10 +157,7 @@ export function createFinalOnboardFlowPhases< : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", recreateJournalHandoff: context.recreateJournalHandoff, - deps: { - ...finalizationDeps, - revalidatePolicyRequirements: revalidationFor(context), - }, + deps: finalizationDeps, }); return { result: postVerifyResult.stateResult }; }); diff --git a/src/lib/onboard/machine/finalization-deps.test.ts b/src/lib/onboard/machine/finalization-deps.test.ts index 2d4d3f314d1..6e9c740615c 100644 --- a/src/lib/onboard/machine/finalization-deps.test.ts +++ b/src/lib/onboard/machine/finalization-deps.test.ts @@ -139,44 +139,6 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); - it("stops before the request producer when authority changes during pairing observation (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; - const scope = ordinaryPairingDeps({ - observePairing: vi.fn(() => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); - }; - return PAIRING_ONLY; - }), - revalidatePolicyRequirements: vi.fn(() => revalidatePolicyRequirements()), - }); - - await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).rejects.toThrow( - "policy authority changed", - ); - - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); - }); - - it("does not publish settled pairing when authority changes during observation (#9833)", async () => { - let revalidatePolicyRequirements = () => undefined; - const scope = ordinaryPairingDeps({ - observePairing: vi.fn(() => { - revalidatePolicyRequirements = () => { - throw new Error("policy authority changed"); - }; - return SETTLED; - }), - revalidatePolicyRequirements: vi.fn(() => revalidatePolicyRequirements()), - }); - - await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).rejects.toThrow( - "policy authority changed", - ); - - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); - }); - it("holds lifecycle then gateway-route ownership across the full settlement (#9844)", async () => { const events: string[] = []; const scope = ordinaryPairingDeps({ diff --git a/src/lib/onboard/machine/finalization-deps.ts b/src/lib/onboard/machine/finalization-deps.ts index 53192b8bbaf..dd4cb214e3d 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { - OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS, OPENCLAW_ONBOARDING_PAIRING_POLL_MS, OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS, OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, @@ -87,7 +86,6 @@ interface OrdinaryOpenClawPairingSettlementDeps { readWatcherStatus(name: string, gatewayName: string): AutoPairWatcherStatus | null; withSandboxLock: SandboxLifecycleLock; withGatewayLock: GatewayRouteLock; - revalidatePolicyRequirements?(operation: string): void; now(): number; sleep(milliseconds: number): Promise; } @@ -255,9 +253,6 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "pairing-unavailable" }; } if (initial?.state === "settled") { - deps.revalidatePolicyRequirements?.( - `publish settled OpenClaw pairing for sandbox '${name}'`, - ); return { kind: "settled" }; } @@ -281,18 +276,12 @@ export async function settleOrdinaryOpenClawPairing( initial = pairingAppearance.value; } if (initial.state === "settled") { - deps.revalidatePolicyRequirements?.( - `publish settled OpenClaw pairing for sandbox '${name}'`, - ); return { kind: "settled" }; } const deviceIdentitySha256 = initial.deviceIdentitySha256; let warmupResult: SandboxScopeWarmupResult | null = null; if (initial.state === "pairing-only") { - deps.revalidatePolicyRequirements?.( - `run OpenClaw pairing warm-up for sandbox '${name}'`, - ); try { warmupResult = await deps.runWarmup(name, target.gatewayName); } catch { @@ -314,9 +303,6 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "runtime-identity-invalid" }; } if (final.kind === "observed") { - deps.revalidatePolicyRequirements?.( - `publish settled OpenClaw pairing for sandbox '${name}'`, - ); return { kind: "settled" }; } @@ -374,15 +360,8 @@ export const finalizationHandlerDeps = { const processRecovery = finalizationHandlerRuntime.loadProcessRecovery(); processRecovery.checkAndRecoverSandboxProcesses(name, options); }, - settleOrdinaryOpenClawPairing( - name: string, - revalidatePolicyRequirements?: (operation: string) => void, - ): Promise { - const deps = defaultPairingSettlementDeps(); - return settleOrdinaryOpenClawPairing( - name, - revalidatePolicyRequirements ? { ...deps, revalidatePolicyRequirements } : deps, - ); + settleOrdinaryOpenClawPairing(name: string): Promise { + return settleOrdinaryOpenClawPairing(name, defaultPairingSettlementDeps()); }, ordinaryOpenClawPairingIncompleteMessage, readRegistryAgent(name: string): string | null { @@ -397,10 +376,7 @@ export const finalizationHandlerDeps = { }, settlePortablePairing( name: string, - options: { - readonly portableRequired: true; - readonly revalidatePolicyRequirements?: (operation: string) => void; - }, + options: { readonly portableRequired: true }, ): ReturnType< (typeof import("../../actions/sandbox/launch-readiness"))["settlePortableOpenClawPairing"] > { diff --git a/src/lib/onboard/machine/handlers/agent-setup.test.ts b/src/lib/onboard/machine/handlers/agent-setup.test.ts index 52438fa0646..6526cdad61e 100644 --- a/src/lib/onboard/machine/handlers/agent-setup.test.ts +++ b/src/lib/onboard/machine/handlers/agent-setup.test.ts @@ -12,10 +12,7 @@ function createDeps(overrides: Partial["deps"]> = let session = createSession(); const calls = { handleAgentSetup: vi.fn(async () => undefined), - context: vi.fn((revalidatePolicyRequirements?: (operation: string) => void) => ({ - ctx: true, - revalidatePolicyRequirements, - })), + context: vi.fn(() => ({ ctx: true })), ensureDashboard: vi.fn(() => 18789), persistDashboardPort: vi.fn(), skipped: vi.fn(async (stepName: string) => { @@ -74,25 +71,6 @@ function baseOptions( } describe("handleAgentSetupState", () => { - it("refuses agent setup before its first effect when policy authority drifts (#9833)", async () => { - const { deps, calls } = createDeps(); - const revalidatePolicyRequirements = vi.fn(() => { - throw new Error("policy authority changed"); - }); - - await expect( - handleAgentSetupState({ - ...baseOptions(deps, { name: "hermes", displayName: "Hermes" }), - revalidatePolicyRequirements, - }), - ).rejects.toThrow("policy authority changed"); - - expect(calls.handleAgentSetup).not.toHaveBeenCalled(); - expect(calls.ensureDashboard).not.toHaveBeenCalled(); - expect(calls.persistDashboardPort).not.toHaveBeenCalled(); - expect(calls.skipped).not.toHaveBeenCalled(); - }); - it("delegates non-OpenClaw agent setup and skips openclaw", async () => { const { deps, calls } = createDeps(); const agent = { name: "hermes", displayName: "Hermes" }; @@ -111,9 +89,9 @@ describe("handleAgentSetupState", () => { agent, true, session, - { ctx: true, revalidatePolicyRequirements: undefined }, + { ctx: true }, ); - expect(calls.ensureDashboard).toHaveBeenCalledWith("my-assistant", agent, undefined); + expect(calls.ensureDashboard).toHaveBeenCalledWith("my-assistant", agent); expect(calls.skipped).toHaveBeenCalledWith("openclaw"); expect(calls.setupOpenclaw).not.toHaveBeenCalled(); expect(result.session?.steps.openclaw.status).toBe("skipped"); @@ -126,59 +104,6 @@ describe("handleAgentSetupState", () => { }); }); - it("passes policy revalidation into non-OpenClaw agent setup (#9833)", async () => { - const { deps, calls } = createDeps(); - const revalidatePolicyRequirements = vi.fn(); - - await handleAgentSetupState({ - ...baseOptions(deps, { name: "hermes", displayName: "Hermes" }), - revalidatePolicyRequirements, - }); - - expect(calls.context).toHaveBeenCalledWith(revalidatePolicyRequirements); - expect(calls.handleAgentSetup).toHaveBeenCalledWith( - "my-assistant", - "model", - "provider", - { name: "hermes", displayName: "Hermes" }, - false, - expect.anything(), - { ctx: true, revalidatePolicyRequirements }, - ); - }); - - it("stops dashboard forwarding when authority changes during the first forward (#9833)", async () => { - const refuseDashboardForward = () => { - throw new Error("policy authority changed"); - }; - const policyChecks = new Map([["start optional dashboard forward", refuseDashboardForward]]); - const revalidatePolicyRequirements = vi.fn<(operation: string) => void>((operation) => - policyChecks.get(operation)?.(), - ); - const ensureAgentDashboardForward = vi.fn( - async (_sandboxName: string, _agent: Agent, revalidate?: (operation: string) => void) => { - revalidate?.("start optional dashboard forward"); - return 18791; - }, - ); - const { deps, calls } = createDeps({ ensureAgentDashboardForward }); - - await expect( - handleAgentSetupState({ - ...baseOptions(deps, { name: "hermes", displayName: "Hermes" }), - revalidatePolicyRequirements, - }), - ).rejects.toThrow("policy authority changed"); - - expect(ensureAgentDashboardForward).toHaveBeenCalledWith( - "my-assistant", - { name: "hermes", displayName: "Hermes" }, - revalidatePolicyRequirements, - ); - expect(calls.persistDashboardPort).not.toHaveBeenCalled(); - expect(calls.skipped).not.toHaveBeenCalled(); - }); - it("persists the bumped dashboard port returned by the forward (#8214)", async () => { const { deps, calls } = createDeps({}); calls.ensureDashboard.mockReturnValue(18791); @@ -186,7 +111,7 @@ describe("handleAgentSetupState", () => { await handleAgentSetupState({ ...baseOptions(deps, agent), resume: true }); - expect(calls.ensureDashboard).toHaveBeenCalledWith("my-assistant", agent, undefined); + expect(calls.ensureDashboard).toHaveBeenCalledWith("my-assistant", agent); expect(calls.persistDashboardPort).toHaveBeenCalledWith("my-assistant", 18791); }); diff --git a/src/lib/onboard/machine/handlers/agent-setup.ts b/src/lib/onboard/machine/handlers/agent-setup.ts index 1e9ef772322..903dfed2f82 100644 --- a/src/lib/onboard/machine/handlers/agent-setup.ts +++ b/src/lib/onboard/machine/handlers/agent-setup.ts @@ -27,12 +27,8 @@ export interface AgentSetupStateOptions { session: Session | null, context: unknown, ): Promise; - agentSetupContext(revalidatePolicyRequirements?: (operation: string) => void): unknown; - ensureAgentDashboardForward( - sandboxName: string, - agent: Agent, - revalidatePolicyRequirements?: (operation: string) => void, - ): Promise | number; + agentSetupContext(): unknown; + ensureAgentDashboardForward(sandboxName: string, agent: Agent): Promise | number; persistDashboardPort(sandboxName: string, dashboardPort: number): void; recordStepSkipped(stepName: string): Promise; isOpenclawReady(sandboxName: string): boolean; @@ -83,7 +79,6 @@ export async function handleAgentSetupState({ deps, }: AgentSetupStateOptions): Promise { if (agent) { - revalidatePolicyRequirements?.(`configure the selected agent in sandbox '${sandboxName}'`); await deps.handleAgentSetup( sandboxName, model, @@ -91,26 +86,17 @@ export async function handleAgentSetupState({ agent, resume, session, - deps.agentSetupContext(revalidatePolicyRequirements), + deps.agentSetupContext(), ); // ensureAgentDashboardForward returns the port the dashboard forward was // actually established on, which may be bumped when the default is already // taken by another sandbox. Persist it to the registry so `dashboard-url` // reports the live port instead of the default. Discarding the return here // regressed multi-sandbox onboarding in the machine handler path (#8214). - revalidatePolicyRequirements?.(`configure the agent dashboard for sandbox '${sandboxName}'`); - const dashboardPort = await deps.ensureAgentDashboardForward( - sandboxName, - agent, - revalidatePolicyRequirements, - ); + const dashboardPort = await deps.ensureAgentDashboardForward(sandboxName, agent); if (dashboardPort > 0) { - revalidatePolicyRequirements?.( - `record the agent dashboard port for sandbox '${sandboxName}'`, - ); deps.persistDashboardPort(sandboxName, dashboardPort); } - revalidatePolicyRequirements?.(`record agent setup for sandbox '${sandboxName}'`); session = await deps.recordStepSkipped("openclaw"); return { session, stateResult: advanceTo("policies", { metadata: { state: "agent_setup" } }) }; } @@ -128,13 +114,11 @@ export async function handleAgentSetupState({ ); revalidatePolicyRequirements?.(`record resumed OpenClaw setup for sandbox '${sandboxName}'`); await deps.recordStateSkipped("openclaw", { reason: "resume", sandboxName }); - revalidatePolicyRequirements?.(`complete resumed OpenClaw setup for sandbox '${sandboxName}'`); await deps.recordStepComplete( "openclaw", deps.toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); } else { - revalidatePolicyRequirements?.(`start OpenClaw setup for sandbox '${sandboxName}'`); await deps.startRecordedStep("openclaw", { sandboxName, provider, model }); revalidatePolicyRequirements?.(`configure OpenClaw in sandbox '${sandboxName}'`); await deps.setupOpenclaw( @@ -150,7 +134,6 @@ export async function handleAgentSetupState({ deps.toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); } - revalidatePolicyRequirements?.(`record agent setup for sandbox '${sandboxName}'`); session = await deps.recordStepSkipped("agent_setup"); return { session, stateResult: advanceTo("policies", { metadata: { state: "openclaw" } }) }; } diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts index 6774636c927..07ae616c09f 100644 --- a/src/lib/onboard/machine/handlers/finalization.test.ts +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -115,42 +115,6 @@ async function runFinalizationHandlers( } describe("finalization handlers", () => { - it("refuses finalization before its first mutation when policy authority drifts (#9833)", async () => { - const revalidatePolicyRequirements = vi.fn(() => { - throw new Error("policy authority changed"); - }); - const { deps, calls } = createDeps({ revalidatePolicyRequirements }); - - await expect(handleFinalizationPhase(baseOptions(deps))).rejects.toThrow( - "policy authority changed", - ); - - expect(calls.setDefaultSandbox).not.toHaveBeenCalled(); - expect(calls.removeLegacy).not.toHaveBeenCalled(); - expect(calls.cleanupHost).not.toHaveBeenCalled(); - expect(calls.recoverProcesses).not.toHaveBeenCalled(); - }); - - it("refuses post-verification before pairing or success publication after drift (#9833)", async () => { - const revalidatePolicyRequirements = vi.fn(() => { - throw new Error("policy authority changed"); - }); - const { deps, calls } = createDeps({ revalidatePolicyRequirements }); - - await expect( - handlePostVerifyState({ - ...baseOptions(deps), - agent: { name: "openclaw" }, - portableProfileSelected: true, - }), - ).rejects.toThrow("policy authority changed"); - - expect(calls.settlePortablePairing).not.toHaveBeenCalled(); - expect(calls.verify).not.toHaveBeenCalled(); - expect(calls.dashboard).not.toHaveBeenCalled(); - expect(calls.reportReadiness).not.toHaveBeenCalled(); - }); - it("advances to post verification before deployment verification runs", async () => { const { deps, calls } = createDeps(); @@ -240,30 +204,6 @@ describe("finalization handlers", () => { }); }); - it("passes the bound policy check through pairing settlement (#9833)", async () => { - const revalidatePolicyRequirements = vi.fn(); - const portable = createDeps({ revalidatePolicyRequirements }); - - await runFinalizationHandlers({ - ...baseOptions(portable.deps), - agent: { name: "openclaw" }, - portableProfileSelected: true, - }); - - expect(portable.calls.settlePortablePairing).toHaveBeenCalledExactlyOnceWith("my-assistant", { - portableRequired: true, - revalidatePolicyRequirements, - }); - - const ordinary = createDeps({ revalidatePolicyRequirements }); - await runFinalizationHandlers(baseOptions(ordinary.deps)); - - expect(ordinary.calls.settleOrdinaryPairing).toHaveBeenCalledExactlyOnceWith( - "my-assistant", - revalidatePolicyRequirements, - ); - }); - it("fails selected Portable OpenClaw closed before ordinary writers when registry identity is invalid (#9207)", async () => { const { deps, calls } = createDeps({ settlePortablePairing: vi.fn(async () => ({ @@ -407,44 +347,6 @@ describe("finalization handlers", () => { expect(persistDashboardPort).toHaveBeenCalledWith("my-assistant", 18792); }); - it("withholds dashboard-port persistence when authority drifts after forwarding (#9833)", async () => { - const persistDashboardPort = vi.fn(); - const refuseDashboardPersistence = () => { - throw new Error("policy authority changed"); - }; - const policyChecks = new Map([ - ["persist the dashboard port for sandbox 'my-assistant'", refuseDashboardPersistence], - ]); - const revalidatePolicyRequirements = vi.fn<(operation: string) => void>((operation) => - policyChecks.get(operation)?.(), - ); - const ensureAgentDashboardForward = vi.fn( - (_sandboxName, _agent, revalidate?: (operation: string) => void) => { - revalidate?.("start dashboard forward"); - return 18792; - }, - ); - const { deps } = createDeps({ - ensureAgentDashboardForward, - persistDashboardPort, - revalidatePolicyRequirements, - }); - - await expect( - handleFinalizationPhase({ - ...baseOptions(deps), - agent: { name: "hermes" }, - }), - ).rejects.toThrow("policy authority changed"); - - expect(ensureAgentDashboardForward).toHaveBeenCalledWith( - "my-assistant", - { name: "hermes" }, - revalidatePolicyRequirements, - ); - expect(persistDashboardPort).not.toHaveBeenCalled(); - }); - it("does not persist a zero dashboard port after final recovery (#8214)", async () => { const persistDashboardPort = vi.fn(); const { deps } = createDeps({ @@ -560,31 +462,6 @@ describe("finalization handlers", () => { expect(calls.setDefaultSandbox).toHaveBeenCalledWith("my-assistant"); }); - it("withholds verified deployment output when authority drifts during the probe (#9833)", async () => { - const refuseStatusPublication = () => { - throw new Error("policy authority changed"); - }; - const policyChecks = new Map([ - ["publish deployment status for sandbox 'my-assistant'", refuseStatusPublication], - ]); - const revalidatePolicyRequirements = vi.fn<(operation: string) => void>((operation) => - policyChecks.get(operation)?.(), - ); - const { deps, calls } = createDeps({ revalidatePolicyRequirements }); - - await expect(runFinalizationHandlers(baseOptions(deps))).rejects.toThrow( - "policy authority changed", - ); - - expect(calls.verify).toHaveBeenCalledOnce(); - expect(calls.log).not.toHaveBeenCalled(); - expect(calls.dashboard).not.toHaveBeenCalled(); - expect(calls.reportReadiness).not.toHaveBeenCalled(); - expect(revalidatePolicyRequirements).not.toHaveBeenCalledWith( - "complete onboarding for sandbox 'my-assistant'", - ); - }); - it("removes legacy credentials only when all staged values migrated", async () => { const { deps, calls } = createDeps(); diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index ed012e74aa3..79102b78855 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -30,12 +30,7 @@ export interface FinalizationStateOptions void, - ): Promise | number; - revalidatePolicyRequirements?(operation: string): void; + ensureAgentDashboardForward(sandboxName: string, agent: Agent): Promise | number; persistDashboardPort(sandboxName: string, dashboardPort: number): void; /** * Mark this sandbox as the default. Called here (not at sandbox creation) so @@ -51,7 +46,6 @@ export interface FinalizationStateOptions void, ): Promise; ordinaryOpenClawPairingIncompleteMessage( sandboxName: string, @@ -62,7 +56,6 @@ export interface FinalizationStateOptions void; }, ): Promise; portablePairingIncompleteMessage( @@ -205,18 +198,14 @@ export async function handleFinalizationState deps.revalidatePolicyRequirements?.(operation); - // Reaching finalization means the policy-preset step was confirmed, so it is // now safe to register this sandbox as the default (#4614). - revalidate(`set sandbox '${sandboxName}' as the default`); deps.setDefaultSandbox(sandboxName); const allStagedMigrated = stagedLegacyKeys.length > 0 && stagedLegacyKeys.every((key) => migratedLegacyKeys.has(key)); const unmigratedLegacyKeys = stagedLegacyKeys.filter((key) => !migratedLegacyKeys.has(key)); if (allStagedMigrated) { - revalidate(`remove migrated legacy credentials for sandbox '${sandboxName}'`); deps.removeLegacyCredentialsFile(); } else if (stagedLegacyKeys.length > 0) { deps.error( @@ -228,37 +217,23 @@ export async function handleFinalizationState 0) { - deps.revalidatePolicyRequirements?.( - `persist the dashboard port for sandbox '${sandboxName}'`, - ); deps.persistDashboardPort(sandboxName, dashboardPort); } } - revalidate(`complete finalization for sandbox '${sandboxName}'`); return { stateResult: advanceTo("post_verify", { metadata: { state: "finalizing" } }), unmigratedLegacyKeys, @@ -294,23 +269,12 @@ export async function handlePostVerifyState deps.revalidatePolicyRequirements?.(operation); - let verificationDiagnostics: string[] = []; let deploymentHealthy = true; if (portableAgent !== "ordinary") { - revalidate(`settle portable pairing for sandbox '${sandboxName}'`); const pairing = portableAgent === "strict-openclaw" - ? await deps.settlePortablePairing( - sandboxName, - deps.revalidatePolicyRequirements - ? { - portableRequired: true, - revalidatePolicyRequirements: deps.revalidatePolicyRequirements, - } - : { portableRequired: true }, - ) + ? await deps.settlePortablePairing(sandboxName, { portableRequired: true }) : ({ kind: "incomplete", reason: "portable-runtime-identity-invalid", @@ -320,7 +284,6 @@ export async function handlePostVerifyState 0) { - revalidate(`record the dashboard port for sandbox '${sandboxName}'`); deps.persistDashboardPort(sandboxName, dashboardPort); } } @@ -389,28 +339,23 @@ export async function handlePostVerifyState { - it("threads durable observability intent into policy reconciliation", async () => { - const session = createSession({ observabilityEnabled: true, policyAuthority: "nemoclaw-managed" }); - const prepareResume = vi.fn(() => ({ - policyPresets: [], - recordedPolicyPresetsNeedReconcile: false, - disabledMessagingPolicyPresetApplied: false, - suppressedAgentRequiredPresetsLive: false, - })); - const setupPolicies = vi.fn(async () => []); - const deps = { - loadSession: () => session, - getActiveSandbox: () => null, - mergePolicyMessagingChannels: () => [], - detectUnconfiguredMessagingChannels: () => [], - verifyCompatibleEndpointSandboxSmoke: vi.fn(), - preparePolicyPresetResumeSelection: prepareResume, - arePolicyPresetsApplied: () => false, - skippedStepMessage: vi.fn(), - recordStateSkipped: vi.fn(async () => session), - startRecordedStep: vi.fn(async () => undefined), - setupPoliciesWithSelection: setupPolicies, - updateSession: () => session, - recordStepComplete: vi.fn(async () => session), - toSessionUpdates: (updates: Record) => updates as SessionUpdates, - persistAppliedPolicyPresets: vi.fn(() => true), - } satisfies PoliciesStateOptions["deps"]; - - await handlePoliciesState({ - resume: false, - sandboxName: "my-assistant", - provider: "provider", - model: "model", - endpointUrl: "https://example.com/v1", - credentialEnv: "NVIDIA_INFERENCE_API_KEY", - selectedMessagingChannels: [], - webSearchConfig: null, - webSearchSupported: true, - hermesToolGateways: [], - agent: { name: "langchain-deepagents-code" }, - deps, - }); - - expect(prepareResume).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ observabilityEnabled: true }), - ); - expect(setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ observabilityEnabled: true }), - ); - }); - - it("keeps an authoritative rebuild tier through resume preparation and policy setup", async () => { - const session = createSession({ - observabilityEnabled: true, - policyAuthority: "nemoclaw-managed", +describe("policy observability requirements", () => { + it("threads durable observability intent into live policy reconciliation", async () => { + const prepare = vi.fn(() => ({ policyPresets: ["observability-otlp-local"], - }); - const prepareResume = vi.fn(() => ({ - policyPresets: [], - recordedPolicyPresetsNeedReconcile: true, + livePolicyPresetsNeedUpdate: true, disabledMessagingPolicyPresetApplied: false, suppressedAgentRequiredPresetsLive: false, })); - const setupPolicies = vi.fn(async () => []); - const deps = { - loadSession: () => session, - getActiveSandbox: () => ({ policyTier: null }), - mergePolicyMessagingChannels: () => [], - detectUnconfiguredMessagingChannels: () => [], - verifyCompatibleEndpointSandboxSmoke: vi.fn(), - preparePolicyPresetResumeSelection: prepareResume, - arePolicyPresetsApplied: () => false, - skippedStepMessage: vi.fn(), - recordStateSkipped: vi.fn(async () => session), - startRecordedStep: vi.fn(async () => undefined), - setupPoliciesWithSelection: setupPolicies, - updateSession: () => session, - recordStepComplete: vi.fn(async () => session), - toSessionUpdates: (updates: Record) => updates as SessionUpdates, - persistAppliedPolicyPresets: vi.fn(() => true), - } satisfies PoliciesStateOptions["deps"]; - - await handlePoliciesState({ - resume: true, - authoritativePolicyTier: "restricted", - sandboxName: "my-assistant", - provider: "provider", - model: "model", - endpointUrl: "https://example.com/v1", - credentialEnv: "NVIDIA_INFERENCE_API_KEY", - selectedMessagingChannels: [], - webSearchConfig: null, - webSearchSupported: true, - hermesToolGateways: [], - agent: { name: "langchain-deepagents-code" }, - deps, + const { deps, setSession, calls } = createPolicyHandlerDeps({ + preparePolicyPresetResumeSelection: prepare, }); + const session = calls.load(); + setSession({ ...session, observabilityEnabled: true }); - expect(prepareResume).toHaveBeenCalledWith( + await handlePoliciesState({ ...basePolicyHandlerOptions(deps), resume: true }); + + expect(prepare).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ tierName: "restricted" }), + expect.objectContaining({ observabilityEnabled: true }), ); - expect(setupPolicies).toHaveBeenCalledWith( + expect(calls.setupPolicies).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ tierName: "restricted", selectedPresets: [] }), + expect.objectContaining({ selectedPresets: ["observability-otlp-local"] }), ); }); }); diff --git a/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts b/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts deleted file mode 100644 index a66191541af..00000000000 --- a/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; -import { createSession } from "../../../state/onboard-session"; -import { handlePoliciesState } from "./policies"; -import { - basePolicyHandlerOptions as baseOptions, - createPolicyHandlerDeps as createDeps, -} from "./policies-test-fixture"; - -// Handler-level fallback for the runtime check the advisor calls out: the -// narrowest live assertion (read the actual OpenShell-applied preset list -// after restricted OpenClaw onboarding and confirm `openclaw-pricing` and -// `openclaw-diagnostics-otel-local` are absent) lives in the nightly -// `network-policy-vitest` scenario — that path requires real OpenShell plus -// an `nvapi-` inference key and is intentionally not run on every PR push. -// This contract test covers the handler-side reconciliation branch — that -// restricted resume forces `setupPoliciesWithSelection` to run rather than -// taking the resume-skip branch whenever -// `policyResumeSelection.suppressedAgentRequiredPresetsLive` is true — so the -// recorded-empty + live-suppressed-preset case cannot silently leave -// third-party egress active on restricted sandboxes. -// Removal condition: when the nightly live `network-policy-vitest` scenario -// asserts the actual applied preset list on restricted OpenClaw onboarding -// (both default and `NEMOCLAW_OPENCLAW_OTEL=1` cases), this handler-level -// contract test stays as the cheap reconciliation regression and the live -// scenario takes over as the source-of-truth runtime gate. -describe("handlePoliciesState — restricted resume reconciliation", () => { - it("forces setup reconciliation on restricted resume when suppressed presets are live", async () => { - const session = createSession({ policyPresets: [] }); - const prepareResume = vi.fn((_sandboxName, _options) => ({ - policyPresets: [], - recordedPolicyPresetsNeedReconcile: false, - disabledMessagingPolicyPresetApplied: false, - suppressedAgentRequiredPresetsLive: true, - })); - const { deps, calls, setSession } = createDeps({ - preparePolicyPresetResumeSelection: prepareResume, - arePolicyPresetsApplied: vi.fn(() => true), - getActiveSandbox: vi.fn(() => ({ - messaging: { plan: makeMessagingPlan() }, - policyTier: "restricted", - })), - }); - setSession(session); - - await handlePoliciesState({ ...baseOptions(deps), resume: true }); - - expect(prepareResume).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ tierName: "restricted" }), - ); - expect(calls.skipped).not.toHaveBeenCalled(); - expect(calls.recordSkip).not.toHaveBeenCalled(); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ selectedPresets: [] }), - ); - }); -}); diff --git a/src/lib/onboard/machine/handlers/policies-test-fixture.ts b/src/lib/onboard/machine/handlers/policies-test-fixture.ts index 59c604be9cd..a39cdaf7835 100644 --- a/src/lib/onboard/machine/handlers/policies-test-fixture.ts +++ b/src/lib/onboard/machine/handlers/policies-test-fixture.ts @@ -13,12 +13,11 @@ export type PolicyTestWebSearchConfig = { fetchEnabled: true }; export function createPolicyHandlerDeps( overrides: Partial["deps"]> = {}, ) { - let session = createSession({ policyAuthority: "nemoclaw-managed" }); + let session = createSession(); const calls = { load: vi.fn(() => session), activeSandbox: vi.fn(() => ({ messaging: { plan: makeMessagingPlan({ channels: ["telegram"] }) }, - policyAuthority: "nemoclaw-managed" as const, })), mergeChannels: vi.fn( (selected: string[], recorded: string[], active: string[] | null | undefined) => @@ -38,12 +37,8 @@ export function createPolicyHandlerDeps( >["deps"]["preparePolicyPresetResumeSelection"] >[1], ) => ({ - policyPresets: (options.recordedPolicyPresets ?? []).filter( - (name) => name !== "unsupported", - ), - recordedPolicyPresetsNeedReconcile: (options.recordedPolicyPresets ?? []).includes( - "unsupported", - ), + policyPresets: [], + livePolicyPresetsNeedUpdate: false, disabledMessagingPolicyPresetApplied: false, suppressedAgentRequiredPresetsLive: false, }), @@ -58,7 +53,6 @@ export function createPolicyHandlerDeps( return session; }), complete: vi.fn(async () => session), - persistPolicies: vi.fn((_sandboxName: string, _appliedPolicyPresets: string[]) => true), }; return { calls, @@ -77,11 +71,9 @@ export function createPolicyHandlerDeps( updateSession: calls.updateSession, recordStepComplete: calls.complete, toSessionUpdates: (updates: Record) => updates as SessionUpdates, - persistAppliedPolicyPresets: calls.persistPolicies, ...overrides, }, setSession(next: Session) { - if (!next.policyAuthority) next.policyAuthority = "nemoclaw-managed"; session = next; }, getSession: () => session, diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index b0012477756..d4a20f0ace7 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -3,732 +3,74 @@ import { describe, expect, it, vi } from "vitest"; -import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; -import { createSession } from "../../../state/onboard-session"; -import { mergePolicyMessagingChannels } from "../../messaging-policy-presets"; +import { createPolicyHandlerDeps, basePolicyHandlerOptions } from "./policies-test-fixture"; import { handlePoliciesState } from "./policies"; -import { - basePolicyHandlerOptions as baseOptions, - createPolicyHandlerDeps, -} from "./policies-test-fixture"; -function createDeps(overrides: Parameters[0] = {}) { - return createPolicyHandlerDeps({ - mergePolicyMessagingChannels: vi.fn(mergePolicyMessagingChannels), - ...overrides, - }); -} - -describe("handlePoliciesState", () => { - it("runs compatible endpoint smoke before policy selection", async () => { - const { deps, calls } = createDeps(); - - const result = await handlePoliciesState(baseOptions(deps)); - - expect(calls.smoke).toHaveBeenCalledWith( - expect.objectContaining({ - sandboxName: "my-assistant", - provider: "provider", - model: "model", - endpointUrl: "https://example.com/v1", - credentialEnv: "NVIDIA_INFERENCE_API_KEY", - messagingChannels: ["telegram"], - agent: null, - beforeSuccess: expect.any(Function), - }), - ); - expect(calls.startStep).toHaveBeenCalledWith("policies", { - sandboxName: "my-assistant", - provider: "provider", - model: "model", - policyPresets: [], - }); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ - selectedPresets: null, - enabledChannels: ["telegram"], - provider: "provider", - webSearchSupported: true, - }), - ); - expect(calls.complete).toHaveBeenCalledWith( - "policies", - expect.objectContaining({ policyPresets: ["npm"] }), - ); - expect(result.stateResult).toEqual({ - type: "transition", - next: "finalizing", - transitionKind: "advance", - updates: undefined, - metadata: { state: "policies", policyPresets: ["npm"] }, - }); - }); - - it("passes an empty messaging selection to the compatible endpoint smoke (#10405)", async () => { - const { deps, calls } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: null, - policyAuthority: "nemoclaw-managed" as const, - })), - }); - - await handlePoliciesState({ - ...baseOptions(deps), - provider: "compatible-endpoint", - selectedMessagingChannels: [], - }); - - expect(calls.smoke).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "compatible-endpoint", - messagingChannels: [], - agent: null, - }), - ); - expect(calls.complete).toHaveBeenCalledOnce(); - }); - - it("uses recorded messaging channels when no active selection exists", async () => { - const session = createSession({ messagingPlan: makeMessagingPlan({ channels: ["slack"] }) }); - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ messaging: null })), - }); - setSession(session); - - await handlePoliciesState(baseOptions(deps)); - - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ enabledChannels: ["slack"] }), - ); - }); - - it("drops a no-longer-configured channel from the enabled set so its preset is not re-applied", async () => { - const session = createSession({ messagingPlan: makeMessagingPlan({ channels: ["discord"] }) }); - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: { plan: makeMessagingPlan({ channels: ["discord"] }) }, - })), - detectUnconfiguredMessagingChannels: vi.fn(() => ["discord"]), - }); - setSession(session); - - await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: [] }); - - expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith( - ["discord", "discord"], - [], - null, - ); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ enabledChannels: [], disabledChannels: ["discord"] }), - ); - }); - - it("disables a channel whose preset is applied but which no plan still names (#9283)", async () => { - const { deps, calls } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: null, - policies: ["npm", "pypi", "discord"], - })), - detectUnconfiguredMessagingChannels: vi.fn((planChannels: readonly string[]) => [ - ...planChannels, - ]), - }); - - await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: [] }); - - expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith(["discord"], [], null); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ enabledChannels: [], disabledChannels: ["discord"] }), - ); - }); - - it("leaves a still-configured channel enabled when its preset is applied (#9283)", async () => { - const { deps, calls } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: null, - policies: ["npm", "discord"], - })), - }); - - await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: ["discord"] }); - - expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith( - ["discord"], - ["discord"], - null, - ); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ enabledChannels: ["discord"], disabledChannels: [] }), - ); - }); - - it("keeps a still-configured channel enabled", async () => { - const { deps, calls } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: { plan: makeMessagingPlan({ channels: ["discord"] }) }, - })), +describe("policy state handler", () => { + it("resumes from the live OpenShell preset selection", async () => { + const prepare = vi.fn(() => ({ + policyPresets: ["npm"], + livePolicyPresetsNeedUpdate: false, + disabledMessagingPolicyPresetApplied: false, + suppressedAgentRequiredPresetsLive: false, + })); + const { deps, calls } = createPolicyHandlerDeps({ + arePolicyPresetsApplied: vi.fn(() => true), + preparePolicyPresetResumeSelection: prepare, }); - - await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: ["discord"] }); - - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ enabledChannels: ["discord"], disabledChannels: [] }), - ); - }); - - it("reports a no-longer-configured channel to the resume check so resume reconciles instead of skipping", async () => { - const { deps, calls } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: { plan: makeMessagingPlan({ channels: ["discord"] }) }, - })), - detectUnconfiguredMessagingChannels: vi.fn(() => ["discord"]), + const result = await handlePoliciesState({ + ...basePolicyHandlerOptions(deps), + resume: true, }); - - await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: [] }); - - expect(calls.prepareResume).toHaveBeenCalledWith( + expect(prepare).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ disabledChannels: ["discord"], enabledChannels: [] }), + expect.not.objectContaining({ recordedPolicyPresets: expect.anything() }), ); - }); - - it("resumes policies when all recorded presets are already applied", async () => { - const session = createSession({ policyPresets: ["npm"] }); - const { deps, calls, setSession } = createDeps({ - arePolicyPresetsApplied: vi.fn(() => true), - }); - setSession(session); - - const result = await handlePoliciesState({ ...baseOptions(deps), resume: true }); - expect(calls.skipped).toHaveBeenCalledWith("policies", "npm"); - expect(calls.recordSkip).toHaveBeenCalledWith("policies", { - reason: "resume", - policyPresets: ["npm"], - }); expect(calls.setupPolicies).not.toHaveBeenCalled(); - expect(calls.complete).toHaveBeenCalledWith( - "policies", - expect.objectContaining({ policyPresets: ["npm"] }), - ); expect(result.appliedPolicyPresets).toEqual(["npm"]); - expect(result.stateResult).toMatchObject({ - next: "finalizing", - transitionKind: "advance", - metadata: { policyPresets: ["npm"] }, - }); - }); - - it("reconciles unsupported recorded presets before interactive setup", async () => { - const session = createSession({ policyPresets: ["npm", "unsupported"] }); - const { deps, calls, setSession } = createDeps(); - setSession(session); - - await handlePoliciesState(baseOptions(deps)); - - expect(calls.prepareResume).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ recordedPolicyPresets: ["npm", "unsupported"] }), - ); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ selectedPresets: ["npm"] }), - ); + expect(result.session).not.toHaveProperty("policyPresets"); }); - it("merges required Hermes tool gateway presets into recorded selections", async () => { - const session = createSession({ policyPresets: ["npm"] }); - const prepareResume = vi.fn((_sandboxName, options) => ({ - policyPresets: [...(options.recordedPolicyPresets ?? []), ...options.hermesToolGateways], - recordedPolicyPresetsNeedReconcile: false, - disabledMessagingPolicyPresetApplied: false, - suppressedAgentRequiredPresetsLive: false, - })); - const { deps, calls, setSession } = createDeps({ - preparePolicyPresetResumeSelection: prepareResume, + it("passes the observed selection to reconciliation on resume", async () => { + const { deps, calls } = createPolicyHandlerDeps({ + preparePolicyPresetResumeSelection: vi.fn(() => ({ + policyPresets: ["npm", "github"], + livePolicyPresetsNeedUpdate: true, + disabledMessagingPolicyPresetApplied: false, + suppressedAgentRequiredPresetsLive: false, + })), }); - setSession(session); - - await handlePoliciesState({ ...baseOptions(deps), hermesToolGateways: ["github"] }); - - expect(prepareResume).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ hermesToolGateways: ["github"] }), - ); + await handlePoliciesState({ ...basePolicyHandlerOptions(deps), resume: true }); expect(calls.setupPolicies).toHaveBeenCalledWith( "my-assistant", expect.objectContaining({ selectedPresets: ["npm", "github"] }), ); }); - it("forwards 'openclaw' to setupPoliciesWithSelection when agent is null (default OpenClaw)", async () => { - const { deps, calls } = createDeps(); - - await handlePoliciesState({ ...baseOptions(deps), agent: null }); - - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ agent: "openclaw" }), - ); - }); - - it("forwards 'hermes' to setupPoliciesWithSelection when agent.name is hermes", async () => { - const { deps, calls } = createDeps(); - - await handlePoliciesState({ ...baseOptions(deps), agent: { name: "hermes" } }); - + it("starts a fresh selection without a shadow preset list", async () => { + const { deps, calls } = createPolicyHandlerDeps(); + await handlePoliciesState(basePolicyHandlerOptions(deps)); expect(calls.setupPolicies).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ agent: "hermes" }), - ); - }); - - it("treats whitespace-only agent.name as default OpenClaw", async () => { - const { deps, calls } = createDeps(); - - await handlePoliciesState({ ...baseOptions(deps), agent: { name: " " } }); - - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ agent: "openclaw" }), - ); - }); - - it.each([ - [null, "openclaw"], - [{ name: "hermes" }, "hermes"], - [{ name: "langchain-deepagents-code" }, "langchain-deepagents-code"], - ] as const)( - "reconciles stale local-inference policy for %s while retaining the real provider attachment", - async (agent, expectedAgent) => { - const session = createSession({ policyPresets: ["local-inference", "npm"] }); - const { deps, calls, setSession } = createDeps({ - arePolicyPresetsApplied: vi.fn(() => true), - }); - setSession(session); - - await handlePoliciesState({ - ...baseOptions(deps), - resume: true, - provider: "vllm-local", - model: "qwen3.5-9b", - endpointUrl: "https://inference.local/v1", - credentialEnv: null, - hostLocalInferenceRouteOnly: true, - hostLocalInferenceSandboxProofAuthority: { - service: "vllm", - directHostPort: 8000, - directHealthPath: "/health", - toolCallingRequired: true, - }, - agent, - }); - - expect(calls.smoke).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "vllm-local", - endpointUrl: "https://inference.local/v1", - agent, - forceCanonicalRoute: true, - hostLocalInferenceProofAuthority: { - service: "vllm", - directHostPort: 8000, - directHealthPath: "/health", - toolCallingRequired: true, - }, - }), - ); - expect(calls.prepareResume).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ recordedPolicyPresets: ["npm"], agent: expectedAgent }), - ); - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ - selectedPresets: ["npm"], - provider: null, - excludedPresets: ["local-inference"], - agent: expectedAgent, - }), - ); - expect(calls.setupPolicies.mock.invocationCallOrder[0]).toBeLessThan( - calls.smoke.mock.invocationCallOrder[0], - ); - expect(calls.skipped).not.toHaveBeenCalled(); - }, - ); - - it("forces route-only reconciliation when only the live sandbox has stale local-inference", async () => { - const { deps, calls } = createDeps({ - arePolicyPresetsApplied: vi.fn((_sandboxName: string, selectedPresets: string[]) => - selectedPresets.includes("local-inference"), - ), - }); - - await handlePoliciesState({ - ...baseOptions(deps), - resume: true, - provider: "ollama-local", - model: "qwen3.5-9b", - endpointUrl: "https://inference.local/v1", - credentialEnv: null, - hostLocalInferenceRouteOnly: true, - hostLocalInferenceSandboxProofAuthority: { - service: "ollama", - directHostPort: 11434, - directHealthPath: "/api/tags", - toolCallingRequired: true, - }, - }); - - expect(calls.setupPolicies).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ - selectedPresets: null, - provider: null, - excludedPresets: ["local-inference"], - }), - ); - expect(calls.skipped).not.toHaveBeenCalled(); - }); - - it.each([null, { name: "hermes" }, { name: "langchain-deepagents-code" }] as const)( - "keeps the inference step recoverable when the post-policy route proof fails for %s", - async (agent) => { - const smokeFailure = new Error("exact model route proof failed"); - const { deps, calls } = createDeps({ - verifyCompatibleEndpointSandboxSmoke: vi.fn(() => { - throw smokeFailure; - }), - }); - - await expect( - handlePoliciesState({ - ...baseOptions(deps), - provider: "vllm-local", - model: "qwen3.5-9b", - endpointUrl: "https://inference.local/v1", - credentialEnv: null, - hostLocalInferenceRouteOnly: true, - hostLocalInferenceSandboxProofAuthority: { - service: "vllm", - directHostPort: 8000, - directHealthPath: "/health", - toolCallingRequired: true, - }, - agent, - }), - ).rejects.toBe(smokeFailure); - - expect(calls.setupPolicies).toHaveBeenCalledOnce(); - expect(calls.complete).not.toHaveBeenCalled(); - expect(calls.recordSkip).not.toHaveBeenCalled(); - }, - ); - - // Regression for #4621: the sandbox is registered with only create-time/boot - // presets, so the effective interactive selection must be written back to the - // registry. Otherwise recreate/re-onboard reads a stale list and reapplies - // removed tier defaults. - // The mocks below mirror the real setupPoliciesWithSelection contract: every - // path that reconciles the live gateway calls onSelection with the effective - // set; the skip path returns [] without calling it. - type SetupOptions = { - selectedPresets: string[] | null; - onSelection: (presets: string[]) => void; - }; - - it("persists the effective interactive selection to the registry (#4621)", async () => { - // Operator picked Balanced, removed the `npm` tier default, and added `github`. - const { deps, calls } = createDeps({ - setupPoliciesWithSelection: vi.fn(async (_name: string, options: SetupOptions) => { - options.onSelection(["dns", "github"]); - return ["dns", "github"]; - }), - }); - - const result = await handlePoliciesState(baseOptions(deps)); - - expect(calls.persistPolicies).toHaveBeenCalledWith("my-assistant", ["dns", "github"]); - // The removed Balanced default must not survive into what we persist... - const [, persisted] = calls.persistPolicies.mock.calls[0] as [string, string[]]; - expect(persisted).not.toContain("npm"); - // ...and the unrelated added preset must be preserved. - expect(persisted).toContain("github"); - expect(result.appliedPolicyPresets).toEqual(["dns", "github"]); - }); - - it("keeps the policy step resumable when finalized registry persistence fails (#4621)", async () => { - const setupPolicies = vi.fn(async (_name: string, options: SetupOptions) => { - options.onSelection(["npm"]); - return ["npm"]; - }); - const persistPolicies = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); - const { deps, calls } = createDeps({ - setupPoliciesWithSelection: setupPolicies, - persistAppliedPolicyPresets: persistPolicies, - }); - - await expect(handlePoliciesState(baseOptions(deps))).rejects.toThrow( - "Failed to persist finalized policy presets for sandbox 'my-assistant'.", + expect.objectContaining({ selectedPresets: null }), ); - expect(calls.complete).not.toHaveBeenCalled(); - - await expect( - handlePoliciesState({ ...baseOptions(deps), resume: true }), - ).resolves.toMatchObject({ appliedPolicyPresets: ["npm"] }); - expect(setupPolicies).toHaveBeenCalledTimes(2); - expect(persistPolicies).toHaveBeenCalledTimes(2); - expect(calls.complete).toHaveBeenCalledOnce(); - }); - - it("verifies external selections without recording NemoClaw preset ownership (#9833)", async () => { - const setupPolicies = vi.fn(async () => ["dns", "github"]); - const revalidatePolicyRequirements = vi.fn(); - const verifySandboxSmoke = vi.fn((options: { beforeSuccess?: () => void }) => - options.beforeSuccess?.(), - ); - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: null, - policyAuthority: "externally-managed" as const, - })), - setupPoliciesWithSelection: setupPolicies, - verifyCompatibleEndpointSandboxSmoke: verifySandboxSmoke, - }); - setSession(createSession({ policyAuthority: "externally-managed" })); - - const result = await handlePoliciesState({ - ...baseOptions(deps), - resume: true, - revalidatePolicyRequirements, - }); - - expect(setupPolicies).not.toHaveBeenCalled(); - expect(calls.prepareResume).not.toHaveBeenCalled(); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - expect(calls.updateSession).not.toHaveBeenCalled(); - expect(calls.recordSkip).toHaveBeenCalledWith("policies", { - reason: "externally_managed", - }); - expect(verifySandboxSmoke).toHaveBeenCalledWith( - expect.objectContaining({ beforeSuccess: expect.any(Function) }), - ); - expect(revalidatePolicyRequirements).toHaveBeenCalledTimes(4); expect(calls.complete).toHaveBeenCalledWith( "policies", - expect.objectContaining({ policyPresets: null }), - ); - expect(result.appliedPolicyPresets).toEqual([]); - expect(result.stateResult).toEqual( - expect.objectContaining({ metadata: { state: "policies" } }), + expect.not.objectContaining({ policyPresets: expect.anything() }), ); }); - it("refuses managed policy setup before policy mutation when authority drifts (#9833)", async () => { - const refusePolicyMutation = () => { - throw new Error("policy authority changed"); - }; - const policyChecks = new Map([ - ["apply policy presets to sandbox 'my-assistant'", refusePolicyMutation], - ]); - const revalidatePolicyRequirements = vi.fn((operation: string) => - policyChecks.get(operation)?.(), - ); - const { deps, calls } = createDeps(); - - await expect( - handlePoliciesState({ - ...baseOptions(deps), - revalidatePolicyRequirements, - }), - ).rejects.toThrow("policy authority changed"); - - expect(calls.setupPolicies).not.toHaveBeenCalled(); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - expect(calls.complete).not.toHaveBeenCalled(); - }); - - it("uses external session authority when the registry row is missing (#9833)", async () => { - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => null), - }); - setSession(createSession({ policyAuthority: "externally-managed" })); - - await handlePoliciesState(baseOptions(deps)); - - expect(calls.setupPolicies).not.toHaveBeenCalled(); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - expect(calls.complete).toHaveBeenCalledWith( - "policies", - expect.objectContaining({ policyPresets: null }), - ); - }); - - it("refuses legacy policy state when no authority is recorded (#9833)", async () => { - const { deps, calls } = createDeps({ - loadSession: () => createSession(), - getActiveSandbox: vi.fn(() => null), - }); - - await expect(handlePoliciesState(baseOptions(deps))).rejects.toThrow( - /policy authority is not recorded/u, - ); - - expect(calls.setupPolicies).not.toHaveBeenCalled(); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - }); - - it("verifies the canonical host-local route under external authority (#9833)", async () => { - const agent = { name: "openclaw" }; - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ - messaging: null, - policyAuthority: "externally-managed" as const, - })), - }); - setSession(createSession({ policyAuthority: "externally-managed" })); - + it("merges live messaging channels into policy requirements", async () => { + const { deps, calls } = createPolicyHandlerDeps(); await handlePoliciesState({ - ...baseOptions(deps), - provider: "vllm-local", - model: "qwen3.5-9b", - endpointUrl: "https://inference.local/v1", - credentialEnv: null, - hostLocalInferenceRouteOnly: true, - hostLocalInferenceSandboxProofAuthority: { - service: "vllm", - directHostPort: 8000, - directHealthPath: "/health", - toolCallingRequired: true, - }, - agent, - }); - - expect(calls.smoke).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "vllm-local", - endpointUrl: "https://inference.local/v1", - agent, - forceCanonicalRoute: true, - hostLocalInferenceProofAuthority: { - service: "vllm", - directHostPort: 8000, - directHealthPath: "/health", - toolCallingRequired: true, - }, - }), - ); - expect(calls.setupPolicies).not.toHaveBeenCalled(); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - }); - - it("re-onboard carries the persisted set forward without re-adding removed defaults (#4621)", async () => { - // A prior onboard recorded the custom "Balanced minus npm plus github" set. - // On re-onboard the recorded set is re-applied verbatim and persisted back — - // npm is never reintroduced. - const session = createSession({ policyPresets: ["dns", "github"] }); - const setupPolicies = vi.fn(async (_name: string, options: SetupOptions) => { - const presets = options.selectedPresets ?? []; - options.onSelection(presets); - return presets; - }); - const { deps, calls, setSession } = createDeps({ - setupPoliciesWithSelection: setupPolicies, + ...basePolicyHandlerOptions(deps), + selectedMessagingChannels: [], }); - setSession(session); - - const result = await handlePoliciesState(baseOptions(deps)); - - expect(setupPolicies).toHaveBeenCalledWith( + expect(calls.mergeChannels).toHaveBeenCalled(); + expect(calls.setupPolicies).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ selectedPresets: ["dns", "github"] }), + expect.objectContaining({ enabledChannels: ["telegram"] }), ); - expect(calls.persistPolicies).toHaveBeenCalledWith("my-assistant", ["dns", "github"]); - const [, persisted] = calls.persistPolicies.mock.calls[0] as [string, string[]]; - expect(persisted).not.toContain("npm"); - expect(result.appliedPolicyPresets).toEqual(["dns", "github"]); - }); - - it("retries inactive Hermes preset removal after synchronization fails", async () => { - const removalFailure = new Error("policy removal failed"); - const session = createSession({ policyPresets: ["npm", "slack"] }); - const setupPolicies = vi - .fn<(_name: string, options: SetupOptions) => Promise>() - .mockRejectedValueOnce(removalFailure) - .mockImplementationOnce(async (_name, options) => { - options.onSelection(["npm"]); - return ["npm"]; - }); - const { deps, calls, getSession, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ messaging: null, policies: ["npm", "slack"] })), - detectUnconfiguredMessagingChannels: vi.fn(() => ["slack"]), - preparePolicyPresetResumeSelection: vi.fn((_sandboxName, options) => ({ - policyPresets: ["npm"], - recordedPolicyPresetsNeedReconcile: (options.recordedPolicyPresets ?? []).includes("slack"), - disabledMessagingPolicyPresetApplied: false, - suppressedAgentRequiredPresetsLive: false, - })), - arePolicyPresetsApplied: vi.fn(() => true), - setupPoliciesWithSelection: setupPolicies, - }); - setSession(session); - const options = { - ...baseOptions(deps), - resume: true, - selectedMessagingChannels: [], - agent: { name: "hermes" }, - }; - - await expect(handlePoliciesState(options)).rejects.toBe(removalFailure); - expect(getSession().policyPresets).toEqual(["npm", "slack"]); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - - await expect(handlePoliciesState(options)).resolves.toMatchObject({ - appliedPolicyPresets: ["npm"], - }); - expect(setupPolicies).toHaveBeenCalledTimes(2); - expect(getSession().policyPresets).toEqual(["npm"]); - expect(calls.persistPolicies).toHaveBeenCalledWith("my-assistant", ["npm"]); - }); - - it("does not finalize the registry on the resume (already-applied) branch (#4621)", async () => { - // The resume branch only confirms recorded presets are a *subset* of what is - // applied (arePolicyPresetsApplied), not that the live set matches. An - // interrupted prior run may still have an extra applied preset whose removal - // never completed, so persisting/finalizing the narrowed recorded set here - // would wrongly claim that preset is gone. Leave the registry untouched. - const session = createSession({ policyPresets: ["dns", "github"] }); - const { deps, calls, setSession } = createDeps({ - arePolicyPresetsApplied: vi.fn(() => true), - }); - setSession(session); - - const result = await handlePoliciesState({ ...baseOptions(deps), resume: true }); - - expect(calls.setupPolicies).not.toHaveBeenCalled(); - expect(calls.persistPolicies).not.toHaveBeenCalled(); - expect(result.appliedPolicyPresets).toEqual(["dns", "github"]); - }); - - it("does not clobber the registry when policy presets are skipped (#4621)", async () => { - // NEMOCLAW_POLICY_MODE=skip/none/no returns [] without touching the live - // applied set (onSelection never fires). Persisting [] here would wipe the - // sandbox's real policies, so the write-back must be suppressed. - const { deps, calls } = createDeps({ - setupPoliciesWithSelection: vi.fn(async () => []), - }); - - const result = await handlePoliciesState(baseOptions(deps)); - - expect(calls.persistPolicies).not.toHaveBeenCalled(); - expect(result.appliedPolicyPresets).toEqual([]); }); }); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 1fcb49cdffd..5407116166f 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -2,18 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import type { SandboxMessagingPlan } from "../../../messaging/manifest"; -import { - assertRecordedPolicyAuthority, - PolicyAuthorityRefusalError, - type SandboxPolicyAuthority, -} from "../../../adapters/openshell/policy-authority"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; import { normalizeAgentNameForResumeState } from "../../agent-resume-state"; import { getActiveChannelsFromPlan, getDisabledChannelsFromPlan, } from "../../messaging-plan-session"; -import { messagingChannelsForPolicyPresets } from "../../messaging-policy-presets"; import type { HostLocalInferenceSandboxProofAuthority } from "../../runtime-provider/host-local-inference-routing"; import { advanceTo, type OnboardStateTransitionResult } from "../result"; @@ -24,23 +18,17 @@ export interface PolicyPresetEntry { export interface ActiveSandboxPolicyState { messaging?: { plan: SandboxMessagingPlan } | null; - policyAuthority?: SandboxPolicyAuthority; - policyTier?: string | null; - /** Preset names already applied to the sandbox, as recorded in the registry. */ - policies?: string[] | null; } export interface PolicyResumeSelection { policyPresets: string[]; - recordedPolicyPresetsNeedReconcile: boolean; + livePolicyPresetsNeedUpdate: boolean; disabledMessagingPolicyPresetApplied: boolean; suppressedAgentRequiredPresetsLive: boolean; } export interface PoliciesStateOptions { resume: boolean; - /** Internal rebuild tier that takes precedence over a not-yet-complete registry row. */ - authoritativePolicyTier?: string | null; sandboxName: string; provider: string; hostLocalInferenceRouteOnly?: boolean; @@ -54,7 +42,6 @@ export interface PoliciesStateOptions { webSearchSupported: boolean; hermesToolGateways: string[]; agent: Agent; - revalidatePolicyRequirements?: (operation: string) => void; deps: { loadSession(): Session | null; getActiveSandbox(sandboxName: string): ActiveSandboxPolicyState | null | undefined; @@ -84,7 +71,6 @@ export interface PoliciesStateOptions { preparePolicyPresetResumeSelection( sandboxName: string, options: { - recordedPolicyPresets: string[] | null; disabledChannels: string[] | null | undefined; enabledChannels: string[]; hermesToolGateways: string[]; @@ -104,7 +90,7 @@ export interface PoliciesStateOptions { ): Promise; startRecordedStep( stepName: string, - updates: { sandboxName: string; provider: string; model: string; policyPresets: string[] }, + updates: { sandboxName: string; provider: string; model: string }, ): Promise; setupPoliciesWithSelection( sandboxName: string, @@ -121,19 +107,10 @@ export interface PoliciesStateOptions { webSearchSupported: boolean; hermesToolGateways: string[]; onSelection: (policyPresets: string[]) => void; - revalidatePolicyRequirements?: (operation: string) => void; }, ): Promise; - updateSession(mutator: (session: Session) => Session | void): Session; recordStepComplete(stepName: string, updates: SessionUpdates): Promise; toSessionUpdates(updates: Record): SessionUpdates; - // Persist the operator's effective policy preset selection back to the - // sandbox registry. The sandbox is registered earlier with only the - // create-time/boot presets (messaging/Hermes setup), so without this - // write-back the registry keeps a stale `policies` list and recreate / - // re-onboard reintroduces removed tier defaults (e.g. a removed Balanced - // `npm`). See #4621. - persistAppliedPolicyPresets(sandboxName: string, appliedPolicyPresets: string[]): boolean; }; } @@ -147,7 +124,6 @@ export interface PoliciesStateResult { export async function handlePoliciesState({ resume, - authoritativePolicyTier, sandboxName, provider, hostLocalInferenceRouteOnly = false, @@ -161,37 +137,12 @@ export async function handlePoliciesState({ webSearchSupported, hermesToolGateways, agent, - revalidatePolicyRequirements, deps, }: PoliciesStateOptions): Promise { const latestSession = deps.loadSession(); const observabilityEnabled = latestSession?.observabilityEnabled === true; - const rawRecordedPolicyPresets = Array.isArray(latestSession?.policyPresets) - ? latestSession.policyPresets - : null; - const recordedPolicyPresets = hostLocalInferenceRouteOnly - ? (rawRecordedPolicyPresets?.filter((name) => name !== "local-inference") ?? null) - : rawRecordedPolicyPresets; const recordedMessagingChannels = getActiveChannelsFromPlan(latestSession?.messagingPlan); const activeSandbox = deps.getActiveSandbox(sandboxName); - const sessionPolicyAuthority = latestSession?.policyAuthority ?? null; - const registryPolicyAuthority = activeSandbox?.policyAuthority ?? null; - const authorityOperation = `continue policy setup for sandbox '${sandboxName}'`; - if (sessionPolicyAuthority && registryPolicyAuthority) { - assertRecordedPolicyAuthority( - sessionPolicyAuthority, - registryPolicyAuthority, - authorityOperation, - ); - } - const policyAuthority = registryPolicyAuthority ?? sessionPolicyAuthority; - if (!policyAuthority) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${authorityOperation}: policy authority is not recorded. Resume onboarding so NemoClaw can inspect and bind the live authority.`, - ); - } - const externallyManagedPolicy = policyAuthority === "externally-managed"; - const effectivePolicyTier = authoritativePolicyTier ?? activeSandbox?.policyTier ?? null; const activePlan = activeSandbox?.messaging?.plan; const activeMessagingChannels = getActiveChannelsFromPlan(activePlan); const planDisabledChannels = getDisabledChannelsFromPlan(activePlan); @@ -201,13 +152,8 @@ export async function handlePoliciesState({ // the existing disabled-channel pruning drop the preset from both the merged // selection and the previously-applied set. // - // The applied preset list is the third candidate source because it outlives - // the plans: a sandbox can carry a channel's egress in `policies` after every - // plan that named the channel is gone, and only a candidate here can retire - // it. - const appliedPresetMessagingChannels = messagingChannelsForPolicyPresets(activeSandbox?.policies); const unconfiguredMessagingChannels = deps.detectUnconfiguredMessagingChannels( - [...recordedMessagingChannels, ...activeMessagingChannels, ...appliedPresetMessagingChannels], + [...recordedMessagingChannels, ...activeMessagingChannels], selectedMessagingChannels, agent, ); @@ -234,42 +180,10 @@ export async function handlePoliciesState({ ...(hostLocalInferenceRouteOnly ? { hostLocalInferenceProofAuthority: hostLocalInferenceSandboxProofAuthority ?? undefined } : {}), - beforeSuccess: () => - revalidatePolicyRequirements?.( - `publish verified inference route for sandbox '${sandboxName}'`, - ), - }); - if (externallyManagedPolicy) { - revalidatePolicyRequirements?.( - `verify the externally managed policy for sandbox '${sandboxName}'`, - ); - verifySandboxInferenceRoute(); - revalidatePolicyRequirements?.(`record verified external policy for sandbox '${sandboxName}'`); - deps.skippedStepMessage("policies", "externally managed"); - await deps.recordStateSkipped("policies", { - reason: "externally_managed", }); - revalidatePolicyRequirements?.( - `complete externally managed policy setup for sandbox '${sandboxName}'`, - ); - const session = await deps.recordStepComplete( - "policies", - deps.toSessionUpdates({ sandboxName, provider, model, policyPresets: null }), - ); - return { - session, - recordedMessagingChannels, - selectedMessagingChannels: policyMessagingChannels, - appliedPolicyPresets: [], - stateResult: advanceTo("finalizing", { - metadata: { state: "policies" }, - }), - }; - } if (!hostLocalInferenceRouteOnly) verifySandboxInferenceRoute(); const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { - recordedPolicyPresets, disabledChannels, enabledChannels: policyMessagingChannels, hermesToolGateways, @@ -278,68 +192,43 @@ export async function handlePoliciesState({ webSearchConfig, webSearchConfigChanged, webSearchSupported, - tierName: effectivePolicyTier, + tierName: null, }); - const recordedPolicyPresetsForSupport = policyResumeSelection.policyPresets; + const livePolicyPresetsForSupport = policyResumeSelection.policyPresets; const staleLocalInferencePolicy = - hostLocalInferenceRouteOnly && - (rawRecordedPolicyPresets?.includes("local-inference") === true || - deps.arePolicyPresetsApplied(sandboxName, ["local-inference"])); + hostLocalInferenceRouteOnly && deps.arePolicyPresetsApplied(sandboxName, ["local-inference"]); const resumePolicies = resume && !staleLocalInferencePolicy && - !policyResumeSelection.recordedPolicyPresetsNeedReconcile && + !policyResumeSelection.livePolicyPresetsNeedUpdate && !policyResumeSelection.disabledMessagingPolicyPresetApplied && !policyResumeSelection.suppressedAgentRequiredPresetsLive && - deps.arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForSupport); + deps.arePolicyPresetsApplied(sandboxName, livePolicyPresetsForSupport); - let appliedPolicyPresets = recordedPolicyPresetsForSupport; + let appliedPolicyPresets = livePolicyPresetsForSupport; let session: Session | null; - // Whether the effective set was authoritatively reconciled onto the live - // gateway, so it is safe to persist and mark final. Only a setup path that - // runs syncPresetSelection (signalled by onSelection firing) qualifies: - // - the ordinary skip path (NEMOCLAW_POLICY_MODE=skip/none/no) returns [] - // without touching the live set, so persisting [] would wipe real - // policies. A skip with exclusions or a missing tier-defining preset - // instead reconciles and persists the retained live set; - // - the resume path only checks recorded presets are a *subset* of what's - // applied (arePolicyPresetsApplied), not that the live set matches — an - // interrupted prior run may still have extra applied presets (e.g. an - // `npm` whose removal never completed), so we must not record the - // narrowed set as the finalized truth. - // See #4621. - let reflectsLiveAppliedSet = false; if (resumePolicies) { if (hostLocalInferenceRouteOnly) verifySandboxInferenceRoute(); - revalidatePolicyRequirements?.(`record resumed policy setup for sandbox '${sandboxName}'`); - deps.skippedStepMessage("policies", recordedPolicyPresetsForSupport.join(", ")); + deps.skippedStepMessage("policies", livePolicyPresetsForSupport.join(", ")); await deps.recordStateSkipped("policies", { reason: "resume", - policyPresets: recordedPolicyPresetsForSupport, }); - revalidatePolicyRequirements?.(`complete resumed policy setup for sandbox '${sandboxName}'`); session = await deps.recordStepComplete( "policies", deps.toSessionUpdates({ sandboxName, provider, model, - policyPresets: recordedPolicyPresetsForSupport, }), ); } else { - revalidatePolicyRequirements?.(`start policy setup for sandbox '${sandboxName}'`); await deps.startRecordedStep("policies", { sandboxName, provider, model, - policyPresets: recordedPolicyPresetsForSupport, }); - revalidatePolicyRequirements?.(`apply policy presets to sandbox '${sandboxName}'`); appliedPolicyPresets = await deps.setupPoliciesWithSelection(sandboxName, { - selectedPresets: Array.isArray(recordedPolicyPresets) - ? recordedPolicyPresetsForSupport - : null, + selectedPresets: resume ? livePolicyPresetsForSupport : null, enabledChannels: policyMessagingChannels, disabledChannels, webSearchConfig, @@ -351,49 +240,18 @@ export async function handlePoliciesState({ // Hermes runs keep their own name. agent: normalizeAgentNameForResumeState((agent as { name?: string } | null)?.name), observabilityEnabled, - tierName: effectivePolicyTier, + tierName: null, webSearchSupported, hermesToolGateways, - revalidatePolicyRequirements, - onSelection: (policyPresets) => { - // onSelection fires only when a selection was reconciled to the live - // gateway (resume reapply, non-interactive custom/suggested, the - // interactive tier selector, or exclusion cleanup during skip). An - // ordinary skip without exclusions returns before calling it. - revalidatePolicyRequirements?.( - `record selected policy presets for sandbox '${sandboxName}'`, - ); - reflectsLiveAppliedSet = true; - deps.updateSession((current) => { - current.policyPresets = policyPresets; - return current; - }); - }, + onSelection: () => undefined, }); - // Reconcile the registry with the *effective* preset selection so a later - // recreate/re-onboard carries the operator's exact set forward instead of - // reapplying stale tier defaults. Done *before* recordStepComplete so an - // interruption can't leave a completed-resumable session without the - // finalized marker (--resume would then skip the persist permanently). - // Skipped only when no reconciliation occurred (including ordinary skip - // without exclusions or a missing tier requirement), which leaves the live - // applied set untouched and would otherwise be clobbered with []. See - // #4621. - if (reflectsLiveAppliedSet) { - revalidatePolicyRequirements?.(`persist policy presets for sandbox '${sandboxName}'`); - if (!deps.persistAppliedPolicyPresets(sandboxName, appliedPolicyPresets)) { - throw new Error(`Failed to persist finalized policy presets for sandbox '${sandboxName}'.`); - } - } if (hostLocalInferenceRouteOnly) verifySandboxInferenceRoute(); - revalidatePolicyRequirements?.(`complete policy setup for sandbox '${sandboxName}'`); session = await deps.recordStepComplete( "policies", deps.toSessionUpdates({ sandboxName, provider, model, - policyPresets: appliedPolicyPresets, }), ); } @@ -404,7 +262,7 @@ export async function handlePoliciesState({ selectedMessagingChannels: policyMessagingChannels, appliedPolicyPresets, stateResult: advanceTo("finalizing", { - metadata: { state: "policies", policyPresets: appliedPolicyPresets }, + metadata: { state: "policies" }, }), }; } diff --git a/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts b/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts index 8309afbbf5e..b53d9836223 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts @@ -446,7 +446,7 @@ describe("provider inference host-local startup selection", () => { _assertRouteCompatible, _canProbeRoute, _recoverySessionId, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ) => { const selection = { ...baseSelection, @@ -457,25 +457,17 @@ describe("provider inference host-local startup selection", () => { credentialEnv: null, preferredInferenceApi: "openai-completions", }; - expect(revalidatePolicyRequirements).toBeTypeOf("function"); - revalidatePolicyRequirements!(selection, "install managed local runtime"); + expect(verifyLivePolicyRequirements).toBeTypeOf("function"); + verifyLivePolicyRequirements!(selection, "install managed local runtime"); return selection; }, ); const resolver = vi.fn((input: HostLocalInferenceStartupSelectionInput) => hostLocalStartupSelection(input, service), ); - const preflightPolicyRequirements = vi.fn( - (requirements: { provider: string | null; hostLocalInferenceRouteOnly?: boolean }) => { - expect( - requirements.provider !== provider || requirements.hostLocalInferenceRouteOnly === true, - ).toBe(true); - }, - ); const { deps, calls } = createDeps({ setupNim, resolveHostLocalInferenceStartupSelection: resolver, - preflightPolicyRequirements, }); const session = createSession(); calls.complete.mockResolvedValue(session); @@ -517,13 +509,6 @@ describe("provider inference host-local startup selection", () => { : null, ); expect(calls.prepareLocalProviderForInference).not.toHaveBeenCalled(); - expect(preflightPolicyRequirements).toHaveBeenCalledWith( - expect.objectContaining({ - provider, - hostLocalInferenceRouteOnly: true, - operation: "record successful inference configuration", - }), - ); expect(result).toMatchObject({ endpointUrl: "https://inference.local/v1", endpointSource: "inference-set", diff --git a/src/lib/onboard/machine/handlers/provider-inference-managed-llama-resume.test.ts b/src/lib/onboard/machine/handlers/provider-inference-managed-llama-resume.test.ts index d96426a201d..82ed2aece20 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-managed-llama-resume.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-managed-llama-resume.test.ts @@ -46,7 +46,6 @@ describe("handleProviderInferenceState managed llama.cpp resume", () => { expect(recoverManagedLlamaCpp).toHaveBeenCalledWith( "llama-cpp-local", "spark-agent", - expect.any(Function), ); expect(recoverManagedLlamaCpp.mock.invocationCallOrder[0]).toBeLessThan( calls.recoverProvider.mock.invocationCallOrder[0]!, @@ -64,54 +63,4 @@ describe("handleProviderInferenceState managed llama.cpp resume", () => { }, ); - it("stops after managed runtime verification before resume recovery effects (#9833)", async () => { - const session = createSession({ - sandboxName: "spark-agent", - provider: "llama-cpp-local", - model: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-GGUF", - endpointUrl: "http://host.openshell.internal:8081/v1", - credentialEnv: "LLAMA_CPP_API_KEY", - preferredInferenceApi: "openai-completions", - }); - session.steps.provider_selection.status = "complete"; - const ensureManagedLlamaCppResumeReady = vi.fn( - async ( - _provider: string | null | undefined, - _sandboxName: string | null | undefined, - revalidatePolicyRequirements?: (operation: string) => void, - ) => { - await Promise.resolve(); - revalidatePolicyRequirements?.("activate the verified managed llama.cpp runtime"); - return true; - }, - ); - const refusal = () => { - throw new Error("external policy authority must supply the managed llama.cpp entry"); - }; - const actions = new Map void>([ - ["activate the verified managed llama.cpp runtime", refusal], - ]); - const preflightPolicyRequirements = vi.fn((input: { operation: string }) => - actions.get(input.operation)?.(), - ); - const { deps, calls } = createDeps({ - ensureManagedLlamaCppResumeReady, - preflightPolicyRequirements, - isInferenceRouteReady: vi.fn(() => true), - }); - - await expect( - handleProviderInferenceState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "spark-agent", - }), - ).rejects.toThrow(/external policy authority must supply/u); - - expect(ensureManagedLlamaCppResumeReady).toHaveBeenCalledOnce(); - expect(calls.recoverProvider).not.toHaveBeenCalled(); - expect(calls.skipped).not.toHaveBeenCalled(); - expect(calls.recordSkip).not.toHaveBeenCalled(); - expect(calls.setupInference).not.toHaveBeenCalled(); - }); }); diff --git a/src/lib/onboard/machine/handlers/provider-inference-policy-authority.test.ts b/src/lib/onboard/machine/handlers/provider-inference-policy-authority.test.ts deleted file mode 100644 index 1cb20e51ca9..00000000000 --- a/src/lib/onboard/machine/handlers/provider-inference-policy-authority.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { llamaCppHostLocalInferenceReceipt } from "../../../../../test/helpers/host-local-inference-receipt"; -import { createSession } from "../../../state/onboard-session"; -import type { - HostLocalInferenceStartupSelection, - HostLocalInferenceStartupSelectionInput, -} from "../../runtime-provider/host-local-inference-routing"; -import { - handleProviderInferenceState, - type ProviderInferenceStateOptions, -} from "./provider-inference"; -import { - baseOptions, - baseSelection, - createDeps, - type Agent, - type Gpu, - type Host, -} from "./provider-inference.test-support"; - -type TestProviderInferenceOptions = ProviderInferenceStateOptions; - -function refuseExternalPolicy(): never { - throw new Error("external policy authority must supply the selected route"); -} - -function publishedLlamaCppSelection( - input: HostLocalInferenceStartupSelectionInput, -): HostLocalInferenceStartupSelection { - return { - runtimeProviderId: "mxc", - request: { - application: input.application, - service: "llama-cpp", - adapter: { - gatewayPort: 8080, - runtimeOwnerSandboxName: "llama-owner", - model: input.model, - operation: {} as never, - receipt: llamaCppHostLocalInferenceReceipt("mxc"), - runtime: {} as never, - prepareStartup: vi.fn() as never, - }, - requireToolCalling: input.requireToolCalling ?? true, - publishedRoute: true, - }, - resolveRuntimeProvider: () => null, - prepareGatewayMutation: async () => ({ commit: () => {}, rollback: () => {} }), - }; -} - -describe("provider inference policy authority", () => { - it("stops before provider setup when policy requirements are not met (#9833)", async () => { - const preflightPolicyRequirements = vi.fn(refuseExternalPolicy); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - - await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(preflightPolicyRequirements).toHaveBeenCalledOnce(); - expect(calls.startStep).not.toHaveBeenCalled(); - expect(calls.setupNim).not.toHaveBeenCalled(); - expect(calls.setupInference).not.toHaveBeenCalled(); - }); - - it("rechecks after routed provider upsert before reserving the route (#9833)", async () => { - const session = createSession({ - sandboxName: "router-sandbox", - provider: "nvidia-router", - model: "router/model", - }); - session.steps.provider_selection.status = "complete"; - const preflightPolicyRequirements = vi.fn((requirements: { operation: string }) => - requirements.operation.startsWith("reserve routed inference route") - ? refuseExternalPolicy() - : undefined, - ); - const { deps, calls } = createDeps({ - isInferenceRouteReady: vi.fn(() => true), - preflightPolicyRequirements, - }); - - await expect( - handleProviderInferenceState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "router-sandbox", - }), - ).rejects.toThrow(/external policy authority must supply/u); - - expect(calls.reupsertRoutedProvider).toHaveBeenCalledOnce(); - expect(calls.reserveRoute).not.toHaveBeenCalled(); - expect(calls.updateSandbox).not.toHaveBeenCalled(); - }); - - it("rechecks before local inference provider preparation (#9833)", async () => { - const setupNim = vi.fn( - async ( - _gpu, - _sandboxName, - _agent, - _allowRecordedProviderRecovery, - _gatewayName, - _assertRouteCompatible, - _canProbeRoute, - _recoverySessionId, - revalidatePolicyRequirements, - ) => { - const selection = { - ...baseSelection, - provider: "ollama-local", - model: "llama3.1", - endpointUrl: "http://127.0.0.1:11434/v1", - credentialEnv: null, - }; - expect(revalidatePolicyRequirements).toBeTypeOf("function"); - revalidatePolicyRequirements!(selection, "install managed local runtime"); - return selection; - }, - ); - const preflightPolicyRequirements = vi.fn((requirements: { operation: string }) => - requirements.operation === "install managed local runtime" - ? refuseExternalPolicy() - : undefined, - ); - const { deps, calls } = createDeps({ setupNim, preflightPolicyRequirements }); - - await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(setupNim).toHaveBeenCalledOnce(); - expect(calls.prepareLocalProviderForInference).not.toHaveBeenCalled(); - expect(calls.setupInference).not.toHaveBeenCalled(); - expect(calls.updateSandbox).not.toHaveBeenCalled(); - }); - - it("withholds inference success after a deferred selection loses authority (#9833)", async () => { - const session = createSession({ - sandboxName: "alpha", - provider: "nvidia-prod", - model: "nvidia/model", - sandboxPromptProgress: { - sandboxName: true, - webSearch: false, - messaging: false, - resourceProfile: false, - }, - }); - session.steps.provider_selection.status = "failed"; - const preflightPolicyRequirements = vi.fn((requirements: { operation: string }) => - requirements.operation.startsWith("record successful deferred provider selection") - ? refuseExternalPolicy() - : undefined, - ); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - - await expect( - handleProviderInferenceState({ - ...baseOptions(deps, session), - sandboxName: "alpha", - }), - ).rejects.toThrow(/external policy authority must supply/u); - - expect(calls.setupInference).toHaveBeenCalledOnce(); - expect(calls.complete).not.toHaveBeenCalledWith("provider_selection", expect.any(Object)); - expect(calls.complete).not.toHaveBeenCalledWith("inference", expect.any(Object)); - }); - - it("withholds durable inference success when final policy authority changes (#9833)", async () => { - const preflightPolicyRequirements = vi.fn((requirements: { operation: string }) => - requirements.operation === "record successful inference configuration" - ? refuseExternalPolicy() - : undefined, - ); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - - await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(calls.setupInference).toHaveBeenCalledOnce(); - expect(calls.complete).not.toHaveBeenCalledWith("inference", expect.any(Object)); - }); - - it("withholds resumed provider reuse output when policy authority changes (#9833)", async () => { - const session = createSession({ - provider: "ollama-local", - model: "llama3.1", - credentialEnv: null, - }); - session.steps.provider_selection.status = "complete"; - const refuseReusePublication = () => { - throw new Error("policy authority changed"); - }; - const policyChecks = new Map([["record resumed provider selection", refuseReusePublication]]); - const { deps, calls } = createDeps({ - isInferenceRouteReady: vi.fn(() => true), - preflightPolicyRequirements: (input) => policyChecks.get(input.operation)?.(), - }); - - await expect( - handleProviderInferenceState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }), - ).rejects.toThrow("policy authority changed"); - - expect(calls.skipped).not.toHaveBeenCalled(); - expect(calls.log).not.toHaveBeenCalledWith(expect.stringContaining("Reusing sandbox name")); - expect(calls.recordSkip).not.toHaveBeenCalled(); - }); - - it("loads resumed route-only authority before the first policy preflight (#9833)", async () => { - const session = createSession({ - sandboxName: "my-assistant", - provider: "llama-cpp-local", - model: "persisted-served-alias", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: "openai-completions", - sandboxPromptProgress: { - sandboxName: true, - webSearch: false, - messaging: false, - resourceProfile: false, - }, - }); - session.steps.provider_selection.status = "complete"; - const preflightPolicyRequirements = vi.fn( - (input: { hostLocalInferenceRouteOnly?: boolean }) => { - expect(input.hostLocalInferenceRouteOnly).toBe(true); - }, - ); - const resolver = vi.fn(publishedLlamaCppSelection); - const { deps } = createDeps({ - preflightPolicyRequirements, - resolveHostLocalInferenceStartupSelection: resolver, - }); - - await expect( - handleProviderInferenceState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }), - ).resolves.toMatchObject({ hostLocalInferenceRouteOnly: true }); - - expect(preflightPolicyRequirements).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - operation: "select an inference provider", - hostLocalInferenceRouteOnly: true, - }), - ); - expect(resolver).toHaveBeenCalledOnce(); - }); - - it("does not treat a saved host-local endpoint as route-only proof (#9833)", async () => { - const session = createSession({ - sandboxName: "my-assistant", - provider: "llama-cpp-local", - model: "persisted-served-alias", - endpointUrl: "https://inference.local/v1", - credentialEnv: null, - preferredInferenceApi: "openai-completions", - sandboxPromptProgress: { - sandboxName: true, - webSearch: false, - messaging: false, - resourceProfile: false, - }, - }); - session.steps.provider_selection.status = "complete"; - const preflightPolicyRequirements = vi.fn( - (input: { hostLocalInferenceRouteOnly?: boolean }) => { - expect(input.hostLocalInferenceRouteOnly).toBe(false); - throw new Error("external policy authority must supply local-inference"); - }, - ); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - const options = baseOptions(deps, session); - - await expect( - handleProviderInferenceState({ - ...options, - initial: { ...options.initial, endpointSource: "inference-set" }, - resume: true, - sandboxName: "my-assistant", - }), - ).rejects.toThrow("external policy authority must supply local-inference"); - - expect(preflightPolicyRequirements).toHaveBeenCalledOnce(); - expect(calls.resolveHostLocalInferenceStartupSelection).toHaveBeenCalledOnce(); - expect(calls.setupInference).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts b/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts index 5182943e871..89a1680bb69 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts @@ -86,7 +86,6 @@ function createDeps() { const deps: Options["deps"] = { checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility, preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery, - preflightPolicyRequirements: vi.fn(), getSandboxRecoveryAuthority: (): "missing" => "missing", withGatewayRouteMutationLock: async (_gatewayName, operation) => await operation(), withModelRouterPortLifecycleLock: async (_port, operation) => await operation(), diff --git a/src/lib/onboard/machine/handlers/provider-inference.test-support.ts b/src/lib/onboard/machine/handlers/provider-inference.test-support.ts index d580ca20bc4..f0fe5a7f1b6 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test-support.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test-support.ts @@ -97,7 +97,6 @@ export function createDeps( requiredEndpointUrl: null, requiredInferenceApi: null, })), - preflightPolicyRequirements: vi.fn(), setupNim: vi.fn(async () => ({ ...baseSelection })), setupInference: vi.fn< ProviderInferenceStateOptions["deps"]["setupInference"] @@ -156,7 +155,6 @@ export function createDeps( deps: { checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility, preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery, - preflightPolicyRequirements: calls.preflightPolicyRequirements, getSandboxRecoveryAuthority: (): "missing" => "missing", withGatewayRouteMutationLock: async ( _gatewayName: string, diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index 9c3e9045450..697dc8e7b07 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -30,7 +30,6 @@ function setupOptions( allowToolsIncompatible: false, endpointSource: null, reservationSessionId: session.sessionId, - revalidatePolicyRequirements: expect.any(Function), ...overrides, }; } @@ -306,7 +305,6 @@ describe("handleProviderInferenceState", () => { "nemoclaw", "compatible-endpoint", "COMPATIBLE_API_KEY", - expect.any(Function), ); expect(calls.complete).toHaveBeenCalledWith( "provider_selection", @@ -453,7 +451,6 @@ describe("handleProviderInferenceState", () => { "nemoclaw", "ollama-local", null, - expect.any(Function), ); expect(calls.skipped).toHaveBeenCalledWith("provider_selection", "ollama-local / llama3.1"); expect(calls.recordSkip).toHaveBeenCalledWith("provider_selection", { diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index 882926f67b9..eec1d335bbf 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -84,8 +84,8 @@ export interface ProviderInferenceSetupOptions { reservationSessionId?: string; /** Recheck recorded-route ownership after acquiring route mutation locks. */ isRecordedProviderRecoveryAuthorized?: () => boolean; - /** Recheck the receipt-bound policy requirements at each inference mutation edge. */ - revalidatePolicyRequirements?: (operation: string) => void; + /** Recheck live OpenShell policy requirements at each inference mutation edge. */ + verifyLivePolicyRequirements?: (operation: string) => void; /** Operation-scoped provider request selected for this onboarding attempt. */ hostLocalInference?: HostLocalInferenceStartupSelection; /** Proxy token prepared after configuration review; avoids repeating host mutations in setup. */ @@ -161,19 +161,6 @@ export interface ProviderInferenceStateOptions { deps: { checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck; preflightGatewayRouteDiscovery: CurrentGatewayRouteDiscoveryPreflight; - preflightPolicyRequirements(input: { - gatewayName: string; - sandboxName: string | null; - agent: Agent; - selectedMessagingChannels: readonly string[]; - hermesToolGateways: readonly string[]; - gpuPassthrough: boolean; - provider: string | null; - hostLocalInferenceRouteOnly?: boolean; - webSearchConfig: WebSearchConfig | null; - observabilityEnabled: boolean; - operation: string; - }): void; getSandboxRecoveryAuthority( sandboxName: string, sessionId: string | null | undefined, @@ -196,7 +183,7 @@ export interface ProviderInferenceStateOptions { ) => GatewayRouteDiscoveryConstraints, canProbeRoute?: (provider: string) => boolean, recoverySessionId?: string | null, - revalidatePolicyRequirements?: ( + verifyLivePolicyRequirements?: ( route: ProviderInferenceProbeRoute, operation: string, ) => void, @@ -225,12 +212,12 @@ export interface ProviderInferenceStateOptions { gatewayName: string, provider: string | null | undefined, credentialEnv: string | null | undefined, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise<{ forceInferenceSetup: boolean; credentialEnv: string | null }>; ensureManagedLlamaCppResumeReady( provider: string | null | undefined, sandboxName: string | null | undefined, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise; isResumeProviderSurfaceReady( gatewayName: string, @@ -696,12 +683,10 @@ async function ensureLegacyManagedLlamaCppResumeReady( ensure: ( provider: string | null | undefined, sandboxName: string | null | undefined, - revalidatePolicyRequirements?: (operation: string) => void, ) => Promise, - revalidatePolicyRequirements?: (operation: string) => void, ): Promise { if (selection?.setupOptions.hostLocalInference) return; - await ensure(provider, sandboxName, revalidatePolicyRequirements); + await ensure(provider, sandboxName); } function endpointSourceForCurrentUrl( @@ -1022,7 +1007,6 @@ async function repairOrRecoverResumedHostLocalInference( provider: string, model: string, agent: unknown, - revalidatePolicyRequirements: (operation: string, requiredProvider?: string | null) => void, forceInferenceSetup: boolean, deps: ResumedHostLocalInferenceRepairDeps, ): Promise { @@ -1031,10 +1015,6 @@ async function repairOrRecoverResumedHostLocalInference( deps.log(" [resume] Recovering managed Ollama through its receipt-bound runtime."); return true; } - revalidatePolicyRequirements( - `repair local inference provider ${JSON.stringify(provider)}`, - provider, - ); await repairResumedLocalInference(provider, model, agent, { ...deps, repairLocalInferenceSystemdOverrideOrExit: (options) => @@ -1179,7 +1159,6 @@ export async function handleProviderInferenceState({ let compatibleEndpointReasoningEffort = initial.compatibleEndpointReasoningEffort; let nimContainer = initial.nimContainer; const webSearchConfig = initial.webSearchConfig; - const observabilityEnabled = session?.observabilityEnabled === true; let forceProviderSelection = initialForceProviderSelection; let allowToolsIncompatible = false; let skipHostInferenceSmoke = false; @@ -1292,31 +1271,6 @@ export async function handleProviderInferenceState({ const stateResults: OnboardStateTransitionResult[] = []; const retryStateResults: OnboardStateTransitionResult[] = []; - const revalidatePolicyRequirements = ( - operation: string, - requiredProvider: string | null = provider, - ): void => { - const routeKnownForProvider = - requiredProvider === provider - ? hostLocalInferenceRouteKnown - : prospectiveHostLocalPolicyRoute?.provider === requiredProvider; - deps.preflightPolicyRequirements({ - gatewayName, - sandboxName, - agent, - selectedMessagingChannels, - hermesToolGateways, - gpuPassthrough, - provider: requiredProvider, - hostLocalInferenceRouteOnly: routeKnownForProvider && hostLocalInferenceRouteOnly, - webSearchConfig, - observabilityEnabled, - operation, - }); - }; - - revalidatePolicyRequirements("select an inference provider"); - while (true) { // Drop a context window auto-detected by a prior compatible-endpoint pass // before every provider-selection path — fresh, resume, and repair — so a @@ -1379,22 +1333,16 @@ export async function handleProviderInferenceState({ // gateway-owned llama.cpp lifecycle before the selection shortcut can // skip setup. The dependency is a no-op for operator-attached llama.cpp // routes because those routes have no matching managed owner state. - revalidatePolicyRequirements( - `recover managed runtime for inference provider ${JSON.stringify(provider)}`, - ); await ensureLegacyManagedLlamaCppResumeReady( earlyManagedHostLocalLifecycleSelection, provider, sandboxName, deps.ensureManagedLlamaCppResumeReady, - (operation) => revalidatePolicyRequirements(operation, provider), ); - revalidatePolicyRequirements(`recover inference provider ${JSON.stringify(provider)}`); const recovery = await deps.ensureResumeProviderReady( gatewayName, provider, credentialEnv, - (operation) => revalidatePolicyRequirements(operation, provider), ); forceInferenceSetup ||= recovery.forceInferenceSetup; credentialEnv = recovery.credentialEnv; @@ -1454,7 +1402,6 @@ export async function handleProviderInferenceState({ forceInferenceSetup = true; deps.log(" [resume] Refreshing compatible-endpoint inference route for messaging."); } - revalidatePolicyRequirements("record resumed provider selection"); deps.skippedStepMessage("provider_selection", `${provider} / ${model}`); const selectedAgentName = (agent as { name?: string } | null)?.name; if ((!selectedAgentName || selectedAgentName === "openclaw") && reusableResumeSandboxName) { @@ -1480,7 +1427,6 @@ export async function handleProviderInferenceState({ resumedSelection.provider, resumedSelection.model, agent, - revalidatePolicyRequirements, forceInferenceSetup, deps, ); @@ -1489,7 +1435,6 @@ export async function handleProviderInferenceState({ // Station resume wrapper restores the exact provider/model as non-interactive env input, // so this re-runs the failed managed install without presenting selection prompts and // obtains a fresh checkpoint identity before the provider step is committed. - revalidatePolicyRequirements("record provider selection start"); await deps.startRecordedStep("provider_selection"); const recoverRecordedProvider = providerRecovery.shouldRecover(); const selection = await withProviderSelectionTrace( @@ -1518,10 +1463,7 @@ export async function handleProviderInferenceState({ return preflight.ok || isAdvisoryGatewayRouteConflict(preflight.result); }, providerRecovery.sessionId, - (route, operation) => { - resolveProspectiveHostLocalPolicyRoute(route); - revalidatePolicyRequirements(operation, route.provider ?? null); - }, + (route) => resolveProspectiveHostLocalPolicyRoute(route), ), ); model = selection.model; @@ -1601,7 +1543,6 @@ export async function handleProviderInferenceState({ preferredInferenceApi, }); } - revalidatePolicyRequirements(`configure inference provider ${JSON.stringify(provider)}`); if ( shouldRecordProviderSelection && (authoritativeResumeConfig || effectiveResume) && @@ -1712,7 +1653,6 @@ export async function handleProviderInferenceState({ const inferenceOptions = { gatewayName, allowToolsIncompatible, - revalidatePolicyRequirements, ...(skipHostInferenceSmoke ? { skipHostInferenceSmoke } : {}), ...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } @@ -1732,11 +1672,8 @@ export async function handleProviderInferenceState({ selectedProvider, selectedModel, credentialEnv, - () => { - revalidatePolicyRequirements( - `configure inference provider ${JSON.stringify(provider)}`, - ); - return deps.setupInference( + () => + deps.setupInference( confirmedSandboxName, selectedModel, selectedProvider, @@ -1745,8 +1682,7 @@ export async function handleProviderInferenceState({ hermesAuthMethod, hermesToolGateways, inferenceOptions, - ); - }, + ), ); } finally { clearStagedCredentialEnv(deps, credentialEnv); @@ -1760,7 +1696,6 @@ export async function handleProviderInferenceState({ forceProviderSelection = true; continue; } - revalidatePolicyRequirements("record successful resumed inference configuration"); session = await deps.recordStepComplete( "inference", deps.toSessionUpdates({ @@ -1773,7 +1708,6 @@ export async function handleProviderInferenceState({ hermesToolGateways, }), ); - revalidatePolicyRequirements("finish successful resumed inference configuration"); break; } const sandboxStepComplete = session?.steps?.sandbox?.status === "complete"; @@ -1801,9 +1735,6 @@ export async function handleProviderInferenceState({ credentialEnv, preferredInferenceApi, }); - revalidatePolicyRequirements( - `reconcile model router for inference provider ${JSON.stringify(provider)}`, - ); try { await deps.reconcileModelRouter(); } catch (err) { @@ -1812,20 +1743,12 @@ export async function handleProviderInferenceState({ ); deps.exitProcess(1); } - revalidatePolicyRequirements( - `update routed inference provider ${JSON.stringify(provider)}`, - ); const reupserted = deps.reupsertRoutedProvider( gatewayName, selectedProvider, endpointUrl, credentialEnv, ); - if (reupserted.ok && resumeReservationName) { - revalidatePolicyRequirements( - `reserve routed inference route for sandbox ${JSON.stringify(resumeReservationName)}`, - ); - } const reservationEndpointSource = endpointSourceForCurrentUrl( endpointSource, reupserted.endpointUrl, @@ -1871,9 +1794,6 @@ export async function handleProviderInferenceState({ credentialEnv, preferredInferenceApi, }); - revalidatePolicyRequirements( - `reserve inference route for sandbox ${JSON.stringify(resumeReservationName)}`, - ); return deps.reserveSandboxInferenceRoute(resumeReservationName, { provider: selectedProvider, model: selectedModel, @@ -1890,14 +1810,12 @@ export async function handleProviderInferenceState({ deps.exitProcess(1); } } - revalidatePolicyRequirements("record reused inference setup"); deps.skippedStepMessage("inference", `${provider} / ${model}`); await deps.recordStateSkipped("inference", { reason: "resume", provider, model, }); - revalidatePolicyRequirements("record successful reused inference configuration"); if (nimContainer && sandboxName) deps.registryUpdateSandbox(sandboxName, { nimContainer }); session = await deps.recordStepComplete( "inference", @@ -1911,7 +1829,6 @@ export async function handleProviderInferenceState({ hermesToolGateways, }), ); - revalidatePolicyRequirements("finish successful reused inference configuration"); break; } @@ -1968,7 +1885,6 @@ export async function handleProviderInferenceState({ !effectiveResume && !deferProviderSelectionUntilInference ) { - revalidatePolicyRequirements("record reviewed provider selection"); session = await deps.recordStepComplete( "provider_selection", deps.toSessionUpdates({ @@ -1990,7 +1906,6 @@ export async function handleProviderInferenceState({ // secret-free route. Do not start or persist the legacy host Ollama // proxy alongside it; that would leave cross-engine residue before the // provider-owned transaction can prove and commit its authority. - revalidatePolicyRequirements(`prepare local inference provider ${JSON.stringify(provider)}`); const preparedOllamaProxyToken = await prepareSelectedLocalProvider( activeHostLocalInferenceSetupOptions.hostLocalInference, provider, @@ -1999,7 +1914,6 @@ export async function handleProviderInferenceState({ const inferenceOptions = { gatewayName, allowToolsIncompatible, - revalidatePolicyRequirements, ...(preparedOllamaProxyToken ? { preparedOllamaProxyToken } : {}), ...(skipHostInferenceSmoke ? { skipHostInferenceSmoke } : {}), ...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } : {}), @@ -2017,16 +1931,14 @@ export async function handleProviderInferenceState({ ), ...activeHostLocalInferenceSetupOptions, }; - revalidatePolicyRequirements("record inference setup start"); await deps.startRecordedStep("inference", { provider, model }); inferenceResult = await withInferenceTrace( confirmedSandboxName, selectedProvider, selectedModel, credentialEnv, - () => { - revalidatePolicyRequirements(`configure inference provider ${JSON.stringify(provider)}`); - return deps.setupInference( + () => + deps.setupInference( confirmedSandboxName, selectedModel, selectedProvider, @@ -2035,8 +1947,7 @@ export async function handleProviderInferenceState({ hermesAuthMethod, hermesToolGateways, inferenceOptions, - ); - }, + ), ); } finally { clearStagedCredentialEnv(deps, credentialEnv); @@ -2059,13 +1970,11 @@ export async function handleProviderInferenceState({ endpointUrl = hostLocalRoute.endpointUrl; endpointSource = hostLocalRoute.endpointSource; onboardEndpointUrl = hostLocalRoute.onboardEndpointUrl; - revalidatePolicyRequirements("record inference runtime metadata"); if (nimContainer && sandboxName) deps.registryUpdateSandbox(sandboxName, { nimContainer }); if (deferProviderSelectionUntilInference) { // Provider selection remains in progress until its inference route has // configured successfully. This retains the selected provider/model for // interruption recovery without claiming a usable route prematurely. - revalidatePolicyRequirements("record successful deferred provider selection"); session = await deps.recordStepComplete( "provider_selection", deps.toSessionUpdates({ @@ -2085,7 +1994,6 @@ export async function handleProviderInferenceState({ }), ); } - revalidatePolicyRequirements("record successful inference configuration"); session = await deps.recordStepComplete( "inference", deps.toSessionUpdates({ diff --git a/src/lib/onboard/machine/handlers/sandbox-apf.test.ts b/src/lib/onboard/machine/handlers/sandbox-apf.test.ts index 523f0ded467..44d2f06a590 100644 --- a/src/lib/onboard/machine/handlers/sandbox-apf.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-apf.test.ts @@ -67,7 +67,7 @@ describe("APF sandbox create selection", () => { const sessionUpdatesBeforeVerifiedEffects = calls.updateSession.mock.calls.length; await (activateVerifiedEffects as unknown as (context: unknown) => Promise)({ - revalidatePolicyRequirements: () => undefined, + verifyLivePolicyRequirements: () => undefined, }); expect(calls.updateSession.mock.calls.length).toBeGreaterThan( sessionUpdatesBeforeVerifiedEffects, diff --git a/src/lib/onboard/machine/handlers/sandbox-baseline-exclusion-lock.test.ts b/src/lib/onboard/machine/handlers/sandbox-baseline-exclusion-lock.test.ts deleted file mode 100644 index 6ce13c0d034..00000000000 --- a/src/lib/onboard/machine/handlers/sandbox-baseline-exclusion-lock.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import * as registry from "../../../state/registry"; -import { handleSandboxState } from "./sandbox"; -import { baseOptions, createDeps } from "./sandbox-test-fixtures"; - -vi.mock("../../messaging-channel-setup", () => ({ - detectMessagingChannelsFromEnv: vi.fn(() => []), -})); - -describe("sandbox create baseline exclusion locking (#7194)", () => { - it("resolves the complete create intent only after acquiring the sandbox mutation lock", async () => { - let lockHeld = false; - const { deps, calls } = createDeps({ - withSandboxMutationLock: async (_sandboxName, action) => { - expect(lockHeld).toBe(false); - lockHeld = true; - try { - return await action(); - } finally { - lockHeld = false; - } - }, - }); - const resolveCreateIntent = calls.resolveCreateIntent.getMockImplementation(); - const createSandbox = calls.createSandbox.getMockImplementation(); - calls.resolveCreateIntent.mockImplementation(async (...args) => { - expect(lockHeld).toBe(true); - return await resolveCreateIntent!(...args); - }); - calls.createSandbox.mockImplementation(async (...args) => { - expect(lockHeld).toBe(true); - return await createSandbox!(...args); - }); - - await handleSandboxState(baseOptions(deps)); - - expect(calls.resolveCreateIntent).toHaveBeenCalledOnce(); - expect(calls.createSandbox).toHaveBeenCalledOnce(); - expect(lockHeld).toBe(false); - }); - - it("rejects a transaction that appears while onboarding waits for its sandbox lock", async () => { - let lockHeld = false; - const transitionSpy = vi - .spyOn(registry, "getBaselineExclusionTransition") - .mockImplementation(() => - lockHeld - ? { - id: "00000000-0000-4000-8000-000000000001", - operation: "exclude", - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - } - : null, - ); - try { - const { deps, calls } = createDeps({ - withSandboxMutationLock: async (_sandboxName, action) => { - lockHeld = true; - try { - return await action(); - } finally { - lockHeld = false; - } - }, - }); - - await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( - "needs repair before sandbox creation", - ); - - expect(calls.resolveCreateIntent).not.toHaveBeenCalled(); - expect(calls.removeSandbox).not.toHaveBeenCalled(); - expect(calls.createSandbox).not.toHaveBeenCalled(); - } finally { - transitionSpy.mockRestore(); - } - }); - - it("rejects exclusion intent that changes before the destructive create edge", async () => { - const original = { - version: 1 as const, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: null, - }; - const changed = { ...original, digest: "b".repeat(64) }; - const transitionSpy = vi - .spyOn(registry, "getBaselineExclusionTransition") - .mockReturnValue(null); - const exclusionsSpy = vi - .spyOn(registry, "getBaselineExclusions") - .mockReturnValueOnce([original]) - .mockReturnValue([changed]); - try { - const { deps, calls } = createDeps({ - withSandboxMutationLock: async (_sandboxName, action) => await action(), - }); - - await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( - "changed while sandbox creation was being prepared", - ); - - expect(calls.resolveCreateIntent).toHaveBeenCalledWith( - expect.objectContaining({ baselineExclusions: [original] }), - ); - expect(calls.removeSandbox).not.toHaveBeenCalled(); - expect(calls.createSandbox).not.toHaveBeenCalled(); - } finally { - exclusionsSpy.mockRestore(); - transitionSpy.mockRestore(); - } - }); -}); diff --git a/src/lib/onboard/machine/handlers/sandbox-baseline-exclusions.test.ts b/src/lib/onboard/machine/handlers/sandbox-baseline-exclusions.test.ts deleted file mode 100644 index 4b55a2fb799..00000000000 --- a/src/lib/onboard/machine/handlers/sandbox-baseline-exclusions.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// 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 * as registry from "../../../state/registry"; -import { handleSandboxState } from "./sandbox"; -import { baseOptions, createDeps } from "./sandbox-test-fixtures"; - -vi.mock("../../messaging-channel-setup", () => ({ - detectMessagingChannelsFromEnv: vi.fn(() => []), -})); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("handleSandboxState baseline exclusions", () => { - it("carries complete records into the pre-destructive create intent", async () => { - const exclusion = { - version: 1 as const, - agent: "openclaw", - key: "nous_research", - digest: "abc", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: null, - }; - vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([exclusion]); - const { deps, calls } = createDeps(); - - await handleSandboxState(baseOptions(deps)); - - expect(calls.resolveCreateIntent).toHaveBeenCalledWith( - expect.objectContaining({ baselineExclusions: [exclusion] }), - ); - const createIntent = calls.createSandbox.mock.calls[0]?.at(-1) as unknown as { - resolved?: { policy?: { options?: { baselineExclusions?: unknown[] } } }; - }; - expect(createIntent.resolved?.policy?.options?.baselineExclusions).toEqual([exclusion]); - }); -}); diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index d25c682a7f2..916220cb203 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -26,13 +26,9 @@ vi.mock("../../messaging-channel-setup", () => ({ vi.mocked(detectMessagingChannelsFromEnv).mockReturnValue([]); -function defaultCreateFingerprint( - builtFingerprint = "my-assistant", - policyFingerprint = "default", -): string { +function defaultCreateFingerprint(builtFingerprint = "my-assistant"): string { return [ builtFingerprint, - policyFingerprint, "provider", "model", "openai-completions", @@ -390,54 +386,57 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ["my-assistant-brave-search"], ["my-assistant-brave-search", "my-assistant-brave-search"], ], - ] as const)("does not grant provider replay authority to %s", async (_case, fingerprints, registeredProviderNames) => { - const [webFingerprint, messagingFingerprint] = fingerprints; - const { deps, calls } = createDeps({ - getSandboxReuseState: () => "missing", - providerMatchesGatewayCredential: () => false, - }); - const session = sessionWithCheckpoint( - crashedCheckpoint({ - effectGroups: { - sandbox_create: { - completedAt: "2026-01-01T00:00:00.000Z", - fingerprint: defaultCreateFingerprint(), + ] as const)( + "does not grant provider replay authority to %s", + async (_case, fingerprints, registeredProviderNames) => { + const [webFingerprint, messagingFingerprint] = fingerprints; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "missing", + providerMatchesGatewayCredential: () => false, + }); + const session = sessionWithCheckpoint( + crashedCheckpoint({ + effectGroups: { + sandbox_create: { + completedAt: "2026-01-01T00:00:00.000Z", + fingerprint: defaultCreateFingerprint(), + }, + web_search_provider: { + completedAt: "2026-01-01T00:00:00.000Z", + fingerprint: webFingerprint, + }, + ...(messagingFingerprint + ? { + messaging_providers: { + completedAt: "2026-01-01T00:00:00.000Z", + fingerprint: messagingFingerprint, + }, + } + : {}), }, - web_search_provider: { - completedAt: "2026-01-01T00:00:00.000Z", - fingerprint: webFingerprint, + bindings: { + credentialEnvs: ["BRAVE_API_KEY"], + registeredProviders: registeredProviderNames.map((name) => ({ + name, + type: "brave", + credentialEnv: "BRAVE_API_KEY", + })), }, - ...(messagingFingerprint - ? { - messaging_providers: { - completedAt: "2026-01-01T00:00:00.000Z", - fingerprint: messagingFingerprint, - }, - } - : {}), - }, - bindings: { - credentialEnvs: ["BRAVE_API_KEY"], - registeredProviders: registeredProviderNames.map((name) => ({ - name, - type: "brave", - credentialEnv: "BRAVE_API_KEY", - })), - }, - }), - ); + }), + ); - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - env: {}, - }), - ).rejects.toThrow("exit 1"); + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + env: {}, + }), + ).rejects.toThrow("exit 1"); - expect(calls.createSandbox).not.toHaveBeenCalled(); - }); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }, + ); it("rejects a malformed provider receipt before registering a changed selection (#7702)", async () => { const oldBinding = { @@ -776,115 +775,115 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { ); }); - it.each([ - "interactive", - "non-interactive", - ] as const)("replays %s web-search provider registration without duplicating the external effect after receipt loss (#7022)", async (mode) => { - const { stageSandboxCredentialProviders, providerMatchesGatewayCredential, runOpenshell } = - realStageSandboxCredentialProviders( - [ - { - name: "my-assistant-brave-search", - envKey: "BRAVE_API_KEY", - token: "brave-secret", - providerType: "brave", - }, - ], - true, + it.each(["interactive", "non-interactive"] as const)( + "replays %s web-search provider registration without duplicating the external effect after receipt loss (#7022)", + async (mode) => { + const { stageSandboxCredentialProviders, providerMatchesGatewayCredential, runOpenshell } = + realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-brave-search", + envKey: "BRAVE_API_KEY", + token: "brave-secret", + providerType: "brave", + }, + ], + true, + ); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + const { deps, getSession } = createDeps( + { + getSandboxReuseState: () => "missing", + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + stageSandboxCredentialProviders, + providerMatchesGatewayCredential, + }, + session, ); - const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); - const { deps, getSession } = createDeps( - { - getSandboxReuseState: () => "missing", - configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), - stageSandboxCredentialProviders, - providerMatchesGatewayCredential, - }, - session, - ); - await expect( - handleSandboxState({ ...baseOptions(deps, session), resume: false }), - ).rejects.toThrow("gateway connection dropped mid-registration"); + await expect( + handleSandboxState({ ...baseOptions(deps, session), resume: false }), + ).rejects.toThrow("gateway connection dropped mid-registration"); - const crashedSession = getSession(); - expect(crashedSession.checkpoint?.effectGroups.web_search_provider).toBeUndefined(); - expect(crashedSession.checkpoint?.bindings.registeredProviders).toEqual([]); + const crashedSession = getSession(); + expect(crashedSession.checkpoint?.effectGroups.web_search_provider).toBeUndefined(); + expect(crashedSession.checkpoint?.bindings.registeredProviders).toEqual([]); - await handleSandboxState({ - ...baseOptions(deps, crashedSession), - resume: true, - sandboxName: "my-assistant", - webSearchConfig: { fetchEnabled: true }, - }); + await handleSandboxState({ + ...baseOptions(deps, crashedSession), + resume: true, + sandboxName: "my-assistant", + webSearchConfig: { fetchEnabled: true }, + }); - expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); - expect( - runOpenshell.mock.calls.filter(([args]) => args[0] === "provider" && args[1] === "create"), - ).toHaveLength(1); - const resumedSession = getSession(); - expect(resumedSession.checkpoint?.effectGroups.web_search_provider).toBeDefined(); - expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ - { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, - ]); - }); + expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); + expect( + runOpenshell.mock.calls.filter(([args]) => args[0] === "provider" && args[1] === "create"), + ).toHaveLength(1); + const resumedSession = getSession(); + expect(resumedSession.checkpoint?.effectGroups.web_search_provider).toBeDefined(); + expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ + { name: "my-assistant-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, + ]); + }, + ); - it.each([ - "interactive", - "non-interactive", - ] as const)("recovers the %s messaging provider receipt without duplicating the external effect after receipt loss (#7022)", async (mode) => { - const { stageSandboxCredentialProviders, providerMatchesGatewayCredential, runOpenshell } = - realStageSandboxCredentialProviders( - [ - { - name: "my-assistant-discord-bridge", - envKey: "DISCORD_BOT_TOKEN", - token: "discord-secret", - providerType: "nemoclaw-mcp-v1", - }, - ], - true, + it.each(["interactive", "non-interactive"] as const)( + "recovers the %s messaging provider receipt without duplicating the external effect after receipt loss (#7022)", + async (mode) => { + const { stageSandboxCredentialProviders, providerMatchesGatewayCredential, runOpenshell } = + realStageSandboxCredentialProviders( + [ + { + name: "my-assistant-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: "discord-secret", + providerType: "nemoclaw-mcp-v1", + }, + ], + true, + ); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + const messagingPlan = discordMessagingPlan(); + const { deps, getSession } = createDeps( + { + getSandboxReuseState: () => "missing", + readMessagingPlanFromEnv: () => messagingPlan, + stageSandboxCredentialProviders, + providerMatchesGatewayCredential, + }, + session, ); - const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); - const messagingPlan = discordMessagingPlan(); - const { deps, getSession } = createDeps( - { - getSandboxReuseState: () => "missing", - readMessagingPlanFromEnv: () => messagingPlan, - stageSandboxCredentialProviders, - providerMatchesGatewayCredential, - }, - session, - ); - await expect( - handleSandboxState({ ...baseOptions(deps, session), resume: false }), - ).rejects.toThrow("gateway connection dropped mid-registration"); + await expect( + handleSandboxState({ ...baseOptions(deps, session), resume: false }), + ).rejects.toThrow("gateway connection dropped mid-registration"); - const crashedSession = getSession(); - expect(crashedSession.checkpoint?.effectGroups.messaging_providers).toBeUndefined(); - expect(crashedSession.checkpoint?.bindings.registeredProviders).toEqual([]); + const crashedSession = getSession(); + expect(crashedSession.checkpoint?.effectGroups.messaging_providers).toBeUndefined(); + expect(crashedSession.checkpoint?.bindings.registeredProviders).toEqual([]); - await handleSandboxState({ - ...baseOptions(deps, crashedSession), - resume: true, - sandboxName: "my-assistant", - }); + await handleSandboxState({ + ...baseOptions(deps, crashedSession), + resume: true, + sandboxName: "my-assistant", + }); - expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); - expect( - runOpenshell.mock.calls.filter(([args]) => args[0] === "provider" && args[1] === "create"), - ).toHaveLength(1); - const resumedSession = getSession(); - expect(resumedSession.checkpoint?.effectGroups.messaging_providers).toBeDefined(); - expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ - { - name: "my-assistant-discord-bridge", - type: "nemoclaw-mcp-v1", - credentialEnv: "DISCORD_BOT_TOKEN", - }, - ]); - }); + expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); + expect( + runOpenshell.mock.calls.filter(([args]) => args[0] === "provider" && args[1] === "create"), + ).toHaveLength(1); + const resumedSession = getSession(); + expect(resumedSession.checkpoint?.effectGroups.messaging_providers).toBeDefined(); + expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ + { + name: "my-assistant-discord-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "DISCORD_BOT_TOKEN", + }, + ]); + }, + ); it("rejects receipt recovery when a required messaging provider did not survive", async () => { const messagingPlan = discordMessagingPlan(); @@ -965,33 +964,33 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ recreate: true }); }); - it.each([ - ["build", defaultCreateFingerprint("v0.0.108")], - ["policy", defaultCreateFingerprint("my-assistant", "previous-policy")], - ] as const)("recreates after %s drift when explicitly requested (#9297)", async (_drift, fingerprint) => { - const session = sessionWithCheckpoint( - crashedCheckpoint({ - effectGroups: { - sandbox_create: { completedAt: "2026-01-01T00:00:00.000Z", fingerprint }, - }, - }), - ); - session.machine.state = "openclaw"; - const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }, session); + it.each([["build", defaultCreateFingerprint("v0.0.108")]] as const)( + "recreates after %s drift when explicitly requested (#9297)", + async (_drift, fingerprint) => { + const session = sessionWithCheckpoint( + crashedCheckpoint({ + effectGroups: { + sandbox_create: { completedAt: "2026-01-01T00:00:00.000Z", fingerprint }, + }, + }), + ); + session.machine.state = "openclaw"; + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }, session); - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - recreateSandbox: () => true, - }); + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + recreateSandbox: () => true, + }); - expect(calls.createSandbox).toHaveBeenCalledOnce(); - expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual( - expect.objectContaining({ recreate: true }), - ); - expect(calls.error).not.toHaveBeenCalled(); - }); + expect(calls.createSandbox).toHaveBeenCalledOnce(); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual( + expect.objectContaining({ recreate: true }), + ); + expect(calls.error).not.toHaveBeenCalled(); + }, + ); it("rejects reuse when a resolved policy or package input drifted despite an unchanged build version and policy tier (#7022)", async () => { const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); @@ -1052,82 +1051,6 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.error).not.toHaveBeenCalled(); }); - it("rejects stable resolved create-intent drift despite an unchanged light fingerprint (#7022)", async () => { - const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); - const updateSession = vi.fn((mutator: (value: typeof session) => void) => { - mutator(session); - return session; - }); - const firstRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); - - await handleSandboxState({ - ...baseOptions(firstRun.deps, session), - resume: false, - sandboxName: "my-assistant", - }); - - const resumedRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); - const defaultResolve = resumedRun.calls.resolveCreateIntent.getMockImplementation(); - expect(defaultResolve).toBeDefined(); - resumedRun.calls.resolveCreateIntent.mockImplementation(async (input) => { - const resolved = await defaultResolve!(input); - return { - ...resolved, - policy: { ...resolved.policy, basePolicyPath: "/repo/changed-policy.yaml" }, - }; - }); - - await expect( - handleSandboxState({ - ...baseOptions(resumedRun.deps, session), - resume: true, - sandboxName: "my-assistant", - }), - ).rejects.toThrow("exit 1"); - - expect(resumedRun.calls.createSandbox).not.toHaveBeenCalled(); - expect(resumedRun.calls.error.mock.calls.flat().join("\n")).toContain("--recreate-sandbox"); - }); - - it("recreates after stable resolved create-intent drift when explicitly requested (#9297)", async () => { - const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); - const updateSession = vi.fn((mutator: (value: typeof session) => void) => { - mutator(session); - return session; - }); - const firstRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); - - await handleSandboxState({ - ...baseOptions(firstRun.deps, session), - resume: false, - sandboxName: "my-assistant", - }); - - const resumedRun = createDeps({ getSandboxReuseState: () => "missing", updateSession }); - const defaultResolve = resumedRun.calls.resolveCreateIntent.getMockImplementation(); - expect(defaultResolve).toBeDefined(); - resumedRun.calls.resolveCreateIntent.mockImplementation(async (input) => { - const resolved = await defaultResolve!(input); - return { - ...resolved, - policy: { ...resolved.policy, basePolicyPath: "/repo/changed-policy.yaml" }, - }; - }); - - await handleSandboxState({ - ...baseOptions(resumedRun.deps, session), - resume: true, - recreateSandbox: () => true, - sandboxName: "my-assistant", - }); - - expect(resumedRun.calls.createSandbox).toHaveBeenCalledOnce(); - expect(resumedRun.calls.createSandbox.mock.calls[0]?.at(-1)).toEqual( - expect.objectContaining({ recreate: true }), - ); - expect(resumedRun.calls.error).not.toHaveBeenCalled(); - }); - it("rejects reasoning capability drift before replaying a recorded sandbox create (#7570)", async () => { const session = createSession({ sessionId: "sess-1", agent: "openclaw" }); const updateSession = vi.fn((mutator: (value: typeof session) => void) => { @@ -1240,135 +1163,135 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(calls.createSandbox).toHaveBeenCalledTimes(1); }); - it.each([ - "interactive", - "non-interactive", - ] as const)("resumes a %s onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", async (mode) => { - const recordStepComplete = vi - .fn() - .mockRejectedValueOnce(new Error("process crashed after create")); - const { deps, calls, getSession } = createDeps({ - getSandboxReuseState: () => "missing", - recordStepComplete, - }); - const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + it.each(["interactive", "non-interactive"] as const)( + "resumes a %s onboarding attempt that crashed after create succeeded but before its completion receipt (#7022)", + async (mode) => { + const recordStepComplete = vi + .fn() + .mockRejectedValueOnce(new Error("process crashed after create")); + const { deps, calls, getSession } = createDeps({ + getSandboxReuseState: () => "missing", + recordStepComplete, + }); + const session = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: false, + sandboxName: "my-assistant", + authoritativeResumeConfig: true, + }), + ).rejects.toThrow("process crashed after create"); + + expect(calls.createSandbox).toHaveBeenCalledTimes(1); + expect(calls.promptName).not.toHaveBeenCalled(); + expect(calls.configureWebSearch).not.toHaveBeenCalled(); + const crashedSession = getSession(); + expect(crashedSession.checkpoint?.effectGroups.sandbox_create).toBeUndefined(); + expect(crashedSession.checkpoint?.sandboxIdentity).toEqual( + decisionSelected({ name: "my-assistant", agent: "openclaw" }), + ); - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - resume: false, + const { deps: resumeDeps, calls: resumeCalls } = createDeps({ + getSandboxReuseState: () => "ready", + }); + + await handleSandboxState({ + ...baseOptions(resumeDeps, crashedSession), + resume: true, sandboxName: "my-assistant", authoritativeResumeConfig: true, - }), - ).rejects.toThrow("process crashed after create"); - - expect(calls.createSandbox).toHaveBeenCalledTimes(1); - expect(calls.promptName).not.toHaveBeenCalled(); - expect(calls.configureWebSearch).not.toHaveBeenCalled(); - const crashedSession = getSession(); - expect(crashedSession.checkpoint?.effectGroups.sandbox_create).toBeUndefined(); - expect(crashedSession.checkpoint?.sandboxIdentity).toEqual( - decisionSelected({ name: "my-assistant", agent: "openclaw" }), - ); - - const { deps: resumeDeps, calls: resumeCalls } = createDeps({ - getSandboxReuseState: () => "ready", - }); + }); - await handleSandboxState({ - ...baseOptions(resumeDeps, crashedSession), - resume: true, - sandboxName: "my-assistant", - authoritativeResumeConfig: true, - }); + expect(resumeCalls.createSandbox).not.toHaveBeenCalled(); + expect(resumeCalls.recordSkip).toHaveBeenCalled(); + }, + ); - expect(resumeCalls.createSandbox).not.toHaveBeenCalled(); - expect(resumeCalls.recordSkip).toHaveBeenCalled(); - }); + it.each(["interactive", "non-interactive"] as const)( + "backfills effect receipts after a %s crash following sandbox registration (#7022)", + async (mode) => { + let persistedSession = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); + const updateSession = vi.fn((mutator: (value: Session) => Session | void) => { + persistedSession = mutator(persistedSession) ?? persistedSession; + return persistedSession; + }); + const recordStepComplete = vi.fn(async (_stepName: string, updates: SessionUpdates) => { + Object.assign(persistedSession, updates); + updateSession.mockImplementationOnce(() => { + throw new Error("process crashed after sandbox registration"); + }); + return persistedSession; + }); + const firstRun = createDeps({ + getSandboxReuseState: () => "missing", + recordStepComplete, + updateSession, + }); - it.each([ - "interactive", - "non-interactive", - ] as const)("backfills effect receipts after a %s crash following sandbox registration (#7022)", async (mode) => { - let persistedSession = createSession({ sessionId: "sess-1", agent: "openclaw", mode }); - const updateSession = vi.fn((mutator: (value: Session) => Session | void) => { - persistedSession = mutator(persistedSession) ?? persistedSession; - return persistedSession; - }); - const recordStepComplete = vi.fn(async (_stepName: string, updates: SessionUpdates) => { - Object.assign(persistedSession, updates); - updateSession.mockImplementationOnce(() => { - throw new Error("process crashed after sandbox registration"); + await expect( + handleSandboxState({ + ...baseOptions(firstRun.deps, persistedSession), + resume: false, + sandboxName: "my-assistant", + authoritativeResumeConfig: true, + }), + ).rejects.toThrow("process crashed after sandbox registration"); + + expect(firstRun.calls.createSandbox).toHaveBeenCalledTimes(1); + expect(firstRun.calls.updateSandbox).toHaveBeenCalledTimes(1); + expect(recordStepComplete).toHaveBeenCalledTimes(1); + expect(persistedSession.checkpoint?.effectGroups.sandbox_create).toBeUndefined(); + expect(persistedSession.checkpoint?.effectGroups.sandbox_register).toBeUndefined(); + + const resumeUpdateSession = vi.fn((mutator: (value: Session) => Session | void) => { + persistedSession = mutator(persistedSession) ?? persistedSession; + return persistedSession; + }); + const recordStateSkipped = vi.fn(async () => persistedSession); + const resumedRun = createDeps({ + getSandboxReuseState: () => "ready", + recordStateSkipped, + updateSession: resumeUpdateSession, + getSandboxRegistryEntry: () => ({ + name: "my-assistant", + agent: null, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + gatewayName: "nemoclaw", + gatewayPort: 8080, + pendingRouteReservation: true, + reservationSessionId: persistedSession.sessionId, + }), }); - return persistedSession; - }); - const firstRun = createDeps({ - getSandboxReuseState: () => "missing", - recordStepComplete, - updateSession, - }); - await expect( - handleSandboxState({ - ...baseOptions(firstRun.deps, persistedSession), - resume: false, + await handleSandboxState({ + ...baseOptions(resumedRun.deps, persistedSession), + resume: true, sandboxName: "my-assistant", authoritativeResumeConfig: true, - }), - ).rejects.toThrow("process crashed after sandbox registration"); - - expect(firstRun.calls.createSandbox).toHaveBeenCalledTimes(1); - expect(firstRun.calls.updateSandbox).toHaveBeenCalledTimes(1); - expect(recordStepComplete).toHaveBeenCalledTimes(1); - expect(persistedSession.checkpoint?.effectGroups.sandbox_create).toBeUndefined(); - expect(persistedSession.checkpoint?.effectGroups.sandbox_register).toBeUndefined(); - - const resumeUpdateSession = vi.fn((mutator: (value: Session) => Session | void) => { - persistedSession = mutator(persistedSession) ?? persistedSession; - return persistedSession; - }); - const recordStateSkipped = vi.fn(async () => persistedSession); - const resumedRun = createDeps({ - getSandboxReuseState: () => "ready", - recordStateSkipped, - updateSession: resumeUpdateSession, - getSandboxRegistryEntry: () => ({ - name: "my-assistant", - agent: null, - provider: "provider", - model: "model", - endpointUrl: null, - preferredInferenceApi: "openai-completions", - gatewayName: "nemoclaw", - gatewayPort: 8080, - pendingRouteReservation: true, - reservationSessionId: persistedSession.sessionId, - }), - }); - - await handleSandboxState({ - ...baseOptions(resumedRun.deps, persistedSession), - resume: true, - sandboxName: "my-assistant", - authoritativeResumeConfig: true, - }); + }); - expect(resumedRun.calls.createSandbox).not.toHaveBeenCalled(); - expect(recordStateSkipped).toHaveBeenCalledTimes(1); - expect(resumedRun.calls.finalizeRouteReservation).toHaveBeenCalledExactlyOnceWith( - "my-assistant", - persistedSession.sessionId, - ); - expect( - resumedRun.calls.updateSandbox.mock.calls.some(([, updates]) => - Object.prototype.hasOwnProperty.call(updates, "provider"), - ), - ).toBe(false); - expect(persistedSession.checkpoint?.effectGroups.sandbox_create?.fingerprint).toBe( - defaultCreateFingerprint(), - ); - expect(persistedSession.checkpoint?.effectGroups.sandbox_register?.fingerprint).toBe( - "my-assistant", - ); - }); + expect(resumedRun.calls.createSandbox).not.toHaveBeenCalled(); + expect(recordStateSkipped).toHaveBeenCalledTimes(1); + expect(resumedRun.calls.finalizeRouteReservation).toHaveBeenCalledExactlyOnceWith( + "my-assistant", + persistedSession.sessionId, + ); + expect( + resumedRun.calls.updateSandbox.mock.calls.some(([, updates]) => + Object.prototype.hasOwnProperty.call(updates, "provider"), + ), + ).toBe(false); + expect(persistedSession.checkpoint?.effectGroups.sandbox_create?.fingerprint).toBe( + defaultCreateFingerprint(), + ); + expect(persistedSession.checkpoint?.effectGroups.sandbox_register?.fingerprint).toBe( + "my-assistant", + ); + }, + ); }); diff --git a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts index f828537e3f8..d67485fc057 100644 --- a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts @@ -25,30 +25,6 @@ const resourceProfiles: [string, { cpu: string; memory: string } | null][] = [ ]; describe("sandbox create intent machine boundary", () => { - it("checks final policy requirements before credential provider registration (#9833)", async () => { - const preflightPolicyRequirements = vi.fn(() => { - throw new Error("external policy authority must supply the selected route"); - }); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - calls.setupMessaging.mockResolvedValue(["telegram"]); - - await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(preflightPolicyRequirements).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "provider", - selectedMessagingChannels: ["telegram"], - observabilityEnabled: false, - }), - ); - expect(calls.stageCredentialProviders).not.toHaveBeenCalled(); - expect(calls.resolveCreateIntent).not.toHaveBeenCalled(); - expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(calls.updateSandbox).not.toHaveBeenCalled(); - }); - it("rejects deterministic create conflicts before resume recreation mutates state (#6226)", async () => { const session = createSession({ sandboxName: "saved" }); session.steps.sandbox.status = "complete"; @@ -109,8 +85,6 @@ describe("sandbox create intent machine boundary", () => { options: { directGpu: false, additionalPresets: [], - policyTier: null, - baselineExclusions: [], }, }, gpuCreateArgs: [], @@ -141,10 +115,9 @@ describe("sandbox create intent machine boundary", () => { expect(resolvedIntents[2]).toEqual(resolvedIntents[0]); }); - it("replaces a stale resumed create-intent policy with the authoritative rebuild selection (#9792)", async () => { + it("does not replace current create policy input from a recorded preset selection (#9792)", async () => { const session = createSession({ sandboxName: "saved", - policyPresets: ["github"], }); const { deps, calls } = createDeps(); calls.resolveCreateIntent.mockResolvedValue({ @@ -162,8 +135,6 @@ describe("sandbox create intent machine boundary", () => { options: { directGpu: false, additionalPresets: ["mcp-bridge-fake"], - policyTier: null, - baselineExclusions: [], }, }, gpuCreateArgs: [], @@ -177,15 +148,13 @@ describe("sandbox create intent machine boundary", () => { await handleSandboxState({ ...baseOptions(deps, session), authoritativeResumeConfig: true, - rebuildPolicyPresets: ["github"], resume: true, sandboxName: "saved", }); expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ - rebuildPolicyPresets: ["github"], resolved: { - policy: { options: { additionalPresets: ["github"] } }, + policy: { options: { additionalPresets: ["mcp-bridge-fake"] } }, }, }); }); @@ -198,15 +167,12 @@ describe("sandbox create intent machine boundary", () => { async ({ authoritativeResumeConfig }) => { const session = createSession({ sandboxName: "saved", - policyAuthority: "externally-managed", }); - session.policyPresets = ["github"]; const { deps, calls } = createDeps({}, session); await handleSandboxState({ ...baseOptions(deps, session), authoritativeResumeConfig, - rebuildPolicyPresets: ["github"], resume: true, sandboxName: "saved", }); @@ -216,7 +182,6 @@ describe("sandbox create intent machine boundary", () => { expect(createIntent).toMatchObject({ resolved: { policy: { options: { additionalPresets: [] } } }, }); - expect(session.policyPresets).toBeNull(); }, ); @@ -448,7 +413,6 @@ describe("sandbox create intent machine boundary", () => { requiredBindings: [ { name: "tm-brave-search", type: "brave", credentialEnv: "BRAVE_API_KEY" }, ], - revalidatePolicyRequirements: expect.any(Function), }); expect(stageSandboxCredentialProviders.mock.invocationCallOrder[0]).toBeGreaterThan( setupMessagingChannels.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY, @@ -468,7 +432,6 @@ describe("sandbox create intent machine boundary", () => { credentialEnv: "TELEGRAM_BOT_TOKEN", }, ], - revalidatePolicyRequirements: expect.any(Function), }); expect(stageSandboxCredentialProviders.mock.invocationCallOrder[1]).toBeGreaterThan( setupMessagingChannels.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY, diff --git a/src/lib/onboard/machine/handlers/sandbox-credential-drift.test.ts b/src/lib/onboard/machine/handlers/sandbox-credential-drift.test.ts index 0b2bf667eeb..7ebf9d4be34 100644 --- a/src/lib/onboard/machine/handlers/sandbox-credential-drift.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-credential-drift.test.ts @@ -7,7 +7,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { MessagingSetupApplier } from "../../../messaging/applier/setup-applier"; import { hashCredential } from "../../../security/credential-hash"; -import { decisionSelected } from "../../../state/onboard-checkpoint-decision"; import { createSession } from "../../../state/onboard-session"; import { recordCheckpointEffectGroup, @@ -75,124 +74,6 @@ describe("sandbox messaging credential drift", () => { detectMessagingChannelsFromEnvMock.mockReturnValue([]); }); - it("validates a changed credential before reusing a ready sandbox (#3631)", async () => { - const previousToken = "123456:previous-telegram-token"; - const replacementToken = "123456:replacement-telegram-token"; - const previousPlan = withTelegramCredentialHash( - makeMinimalPlan("saved", "openclaw", ["telegram"]), - hashCredential(previousToken), - ); - const replacementPlan = withTelegramCredentialHash( - makeMinimalPlan("saved", "openclaw", ["telegram"]), - hashCredential(replacementToken), - ); - const session = createSession({ sandboxName: "saved", messagingPlan: previousPlan }); - session.steps.sandbox.status = "complete"; - session.machine = { ...session.machine, state: "agent_setup" }; - recordCheckpointSandboxIdentity(session, "saved", "openclaw"); - recordCheckpointMessaging(session, previousPlan); - recordCheckpointEffectGroup( - session, - "sandbox_create", - [ - "saved", - "default", - "provider", - "model", - "openai-completions", - "", - JSON.stringify({ sandboxGpuEnabled: false, mode: "0" }), - "", - ].join("|"), - ); - recordCheckpointEffectGroup(session, "sandbox_register", "saved"); - expect(session.checkpoint).not.toBeNull(); - session.checkpoint = { - ...session.checkpoint!, - gatewayAuthority: decisionSelected({ - gatewayName: "nemoclaw", - gatewayPort: 18789, - mode: "nemoclaw-managed", - source: "standalone", - endpoint: null, - stateDir: null, - supervisor: null, - requiredCapabilities: [], - }), - }; - registry.registerSandbox({ - name: "saved", - messaging: { schemaVersion: 1, plan: previousPlan }, - }); - detectMessagingChannelsFromEnvMock.mockReturnValue(["telegram"]); - const messagingEnv: NodeJS.ProcessEnv = {}; - const readMessagingPlanFromEnv = () => - MessagingSetupApplier.readPlanFromEnv({ env: messagingEnv }); - const writePlanToEnv = (plan: typeof replacementPlan) => - MessagingSetupApplier.writePlanToEnv(plan, { env: messagingEnv }); - const { deps, calls, getSession } = createDeps( - { - getSandboxReuseState: () => "ready", - getRegistrySandboxMessagingAuthority: (name) => ({ - authoritative: true, - plan: registry.getHydratedMessagingPlanFromEntry(registry.getSandbox(name)), - }), - getRecordedMessagingChannelsForResume: () => null, - readMessagingPlanFromEnv, - writePlanToEnv, - listRegistrySandboxes: registry.listSandboxes, - }, - session, - ); - calls.removeSandbox.mockImplementation(() => registry.removeSandboxWithReceipt("saved")); - calls.setupMessaging.mockImplementation(async () => { - writePlanToEnv(replacementPlan); - return ["telegram"]; - }); - calls.createSandbox.mockImplementation(async () => { - const plan = readMessagingPlanFromEnv(); - registry.registerSandbox({ - name: "saved", - messaging: plan ? { schemaVersion: 1, plan } : undefined, - }); - return "saved"; - }); - - await withEnv("TELEGRAM_BOT_TOKEN", replacementToken, async () => { - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - env: { TELEGRAM_BOT_TOKEN: replacementToken }, - }); - }); - - expect(calls.note).toHaveBeenCalledWith( - " [resume] Messaging credential changed; recreating sandbox after configured checks.", - ); - expect(calls.setupMessaging).toHaveBeenCalled(); - expect(calls.removeSandbox).not.toHaveBeenCalled(); - expect(calls.createSandbox).toHaveBeenCalled(); - expect(calls.setupMessaging.mock.invocationCallOrder[0]).toBeLessThan( - calls.createSandbox.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - expect(getSession().messagingPlan?.credentialBindings[0]?.credentialHash).toBe( - hashCredential(replacementToken), - ); - const registryState = registry.listSandboxes(); - expect(registryState.sandboxes).toHaveLength(1); - expect(registryState.sandboxes[0]?.name).toBe("saved"); - expect( - registryState.sandboxes[0]?.messaging?.plan.credentialBindings.map( - (binding) => binding.credentialHash, - ), - ).toEqual([hashCredential(replacementToken)]); - const serializedRegistry = JSON.stringify(registryState); - expect(serializedRegistry).not.toContain(hashCredential(previousToken)); - expect(serializedRegistry).not.toContain(previousToken); - expect(serializedRegistry).not.toContain(replacementToken); - }); - it("restages a validated replacement credential despite a live provider receipt (#3631)", async () => { const previousToken = "123456:previous-telegram-token"; const replacementToken = "123456:replacement-telegram-token"; diff --git a/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts b/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts index 14d9dfb8542..31795294145 100644 --- a/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts @@ -35,9 +35,6 @@ describe("handleSandboxState journaled replacement failure", () => { preferredInferenceApi: "openai-completions", toolDisclosure: "progressive", webSearchEnabled: true, - baselineExclusions: [ - { version: 1 as const, agent: "openclaw", key: "nous_research", digest: "abc" }, - ], } satisfies SandboxEntry; const journal = bindJournaledRecreate(session); const getSandboxRegistryEntry = vi.fn(() => sourceEntry); diff --git a/src/lib/onboard/machine/handlers/sandbox-policy-authority-completion.test.ts b/src/lib/onboard/machine/handlers/sandbox-policy-authority-completion.test.ts deleted file mode 100644 index d00af59cd8d..00000000000 --- a/src/lib/onboard/machine/handlers/sandbox-policy-authority-completion.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { createSession } from "../../../state/onboard-session"; -import { handleSandboxState } from "./sandbox"; -import { baseOptions, bindJournaledRecreate, createDeps } from "./sandbox-test-fixtures"; - -vi.mock("../../messaging-channel-setup", () => ({ - detectMessagingChannelsFromEnv: vi.fn(() => []), - detectUnconfiguredMessagingChannels: vi.fn(() => []), -})); - -function refuseAt(targetOperation: string) { - const actions = new Map void>([ - [ - targetOperation, - () => { - throw new Error("external policy authority must supply the final sandbox entries"); - }, - ], - ]); - return vi.fn((input: { operation: string }) => actions.get(input.operation)?.()); -} - -describe("sandbox completion policy authority", () => { - it("rechecks immediately before sandbox creation (#9833)", async () => { - const preflightPolicyRequirements = refuseAt("create sandbox 'my-assistant'"); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - - await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(calls.updateSandbox).not.toHaveBeenCalled(); - expect(calls.complete).not.toHaveBeenCalled(); - }); - - it("rechecks after the inner create before registry or session completion (#9833)", async () => { - const preflightPolicyRequirements = refuseAt("complete the created sandbox"); - const { deps, calls } = createDeps({ preflightPolicyRequirements }); - - await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(calls.createSandbox).toHaveBeenCalledOnce(); - expect(calls.updateSandbox).not.toHaveBeenCalled(); - expect(calls.complete).not.toHaveBeenCalled(); - }); - - it("rechecks after repair persistence before registry completion (#9833)", async () => { - const session = createSession({ sandboxName: "saved" }); - const journal = bindJournaledRecreate(session); - const preflightPolicyRequirements = refuseAt("complete sandbox repair"); - const { deps, calls } = createDeps( - { - getSandboxReuseState: () => "not_ready", - getSandboxRecreateObservation: journal.observe, - createSandbox: journal.completeCreate, - preflightPolicyRequirements, - }, - session, - ); - - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - }), - ).rejects.toThrow(/external policy authority must supply/u); - - expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.completed", { - state: "sandbox", - metadata: { repair: "recorded-sandbox-cleanup", sandboxName: "saved" }, - }); - expect(calls.updateSandbox).not.toHaveBeenCalled(); - expect(calls.complete).not.toHaveBeenCalled(); - }); - - it("rechecks before recording reused sandbox state (#9833)", async () => { - const session = createSession({ sandboxName: "saved" }); - session.steps.sandbox.status = "complete"; - const preflightPolicyRequirements = refuseAt("record reused sandbox state for 'saved'"); - const { deps, calls } = createDeps( - { - getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: () => ({ - name: "saved", - pendingRouteReservation: true, - reservationSessionId: session.sessionId, - provider: "provider", - model: "model", - endpointUrl: null, - preferredInferenceApi: "openai-completions", - toolDisclosure: "progressive", - fromDockerfile: null, - hermesAuthMethod: null, - }), - preflightPolicyRequirements, - }, - session, - ); - - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - }), - ).rejects.toThrow(/external policy authority must supply/u); - - expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(calls.skipped).not.toHaveBeenCalled(); - }); - - it.each([ - { - operation: "register the created sandbox", - registryCalls: 0, - completionCalls: 0, - }, - { - operation: "complete the sandbox onboarding session", - registryCalls: 1, - completionCalls: 0, - }, - { - operation: "record the final sandbox creation receipt", - registryCalls: 1, - completionCalls: 1, - }, - ])( - "rechecks before $operation mutation (#9833)", - async ({ operation, registryCalls, completionCalls }) => { - const session = createSession(); - const preflightPolicyRequirements = refuseAt(operation); - const { deps, calls, getSession } = createDeps({ preflightPolicyRequirements }, session); - - await expect(handleSandboxState(baseOptions(deps, session))).rejects.toThrow( - /external policy authority must supply/u, - ); - - expect(calls.updateSandbox).toHaveBeenCalledTimes(registryCalls); - expect(calls.complete).toHaveBeenCalledTimes(completionCalls); - expect(getSession().checkpoint?.effectGroups.sandbox_create).toBeUndefined(); - expect(getSession().checkpoint?.effectGroups.sandbox_register).toBeUndefined(); - }, - ); -}); diff --git a/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts b/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts index 6702de57029..bfc97f7f775 100644 --- a/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts @@ -212,7 +212,6 @@ describe("handleSandboxState provider effect replay", () => { webSearchConfig: null, agent: null, requiredBindings: slackProviderBindings, - revalidatePolicyRequirements: expect.any(Function), }); expect(calls.createSandbox).toHaveBeenCalledTimes(1); expect(result.session?.checkpoint?.bindings).toEqual({ @@ -300,7 +299,6 @@ describe("handleSandboxState provider effect replay", () => { webSearchConfig: { fetchEnabled: true, provider: "tavily" }, agent: null, requiredBindings: [tavilyBinding], - revalidatePolicyRequirements: expect.any(Function), }); expect(calls.createSandbox).toHaveBeenCalledTimes(1); expect(result.session?.checkpoint?.bindings).toEqual({ diff --git a/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts b/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts index bcaba5e7111..c7506fb2264 100644 --- a/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts @@ -167,58 +167,60 @@ it.each([ "authority-unproven", "no-owned-image", "image-reused", -] as const)("reports the bounded %s image-retirement skip after journaled recreation", async (reason) => { - const session = createSession({ sandboxName: "saved", agent: "openclaw" }); - const journal = bindJournaledRecreate(session); - const sourceEntry: SandboxEntry = { - name: "saved", - provider: "provider", - model: "model", - endpointUrl: null, - preferredInferenceApi: "openai-completions", - webSearchEnabled: false, - toolDisclosure: "progressive", - fromDockerfile: null, - hermesAuthMethod: null, - imageTag: "openshell/sandbox-from:old", - workload: { - schemaVersion: 1, - kind: "legacy-dockerfile", - reference: "openshell/sandbox-from:old", - shared: false, - }, - }; - const retireReplacedSandboxWorkload = vi.fn(() => ({ - status: "skipped" as const, - reason, - })); - const { deps, calls } = createDeps( - { - getSandboxReuseState: () => "not_ready", - getSandboxRecreateObservation: journal.observe, - getSandboxRegistryEntry: () => sourceEntry, - createSandbox: journal.completeCreate, - retireReplacedSandboxWorkload, - }, - session, - ); +] as const)( + "reports the bounded %s image-retirement skip after journaled recreation", + async (reason) => { + const session = createSession({ sandboxName: "saved", agent: "openclaw" }); + const journal = bindJournaledRecreate(session); + const sourceEntry: SandboxEntry = { + name: "saved", + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + webSearchEnabled: false, + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + imageTag: "openshell/sandbox-from:old", + workload: { + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "openshell/sandbox-from:old", + shared: false, + }, + }; + const retireReplacedSandboxWorkload = vi.fn(() => ({ + status: "skipped" as const, + reason, + })); + const { deps, calls } = createDeps( + { + getSandboxReuseState: () => "not_ready", + getSandboxRecreateObservation: journal.observe, + getSandboxRegistryEntry: () => sourceEntry, + createSandbox: journal.completeCreate, + retireReplacedSandboxWorkload, + }, + session, + ); - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - }); + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); - const diagnostics = calls.note.mock.calls - .map(([message]) => message) - .filter((message) => message.startsWith(" Obsolete sandbox image retirement skipped:")); - expect(diagnostics).toEqual([` Obsolete sandbox image retirement skipped: ${reason}`]); - expect(retireReplacedSandboxWorkload).toHaveBeenCalledOnce(); -}); + const diagnostics = calls.note.mock.calls + .map(([message]) => message) + .filter((message) => message.startsWith(" Obsolete sandbox image retirement skipped:")); + expect(diagnostics).toEqual([` Obsolete sandbox image retirement skipped: ${reason}`]); + expect(retireReplacedSandboxWorkload).toHaveBeenCalledOnce(); + }, +); -it("carries filtered presets through post-delete onboard resume", async () => { +it("does not carry a recorded preset list through post-delete onboard resume", async () => { const session = createSession({ sandboxName: "saved", agent: "openclaw" }); - session.policyPresets = ["github"]; session.steps.sandbox.status = "complete"; session.machine.state = "agent_setup"; session.checkpoint = { @@ -247,8 +249,6 @@ it("carries filtered presets through post-delete onboard resume", async () => { hermesAuthMethod: null, gatewayName: "nemoclaw", gatewayPort: 8080, - policies: ["github", "mcp-bridge-fake"], - policyPresetsFinalized: true, }; const targetIntentFingerprint = fingerprintSandboxRecreateValue({ sandboxName: "saved", @@ -283,9 +283,8 @@ it("carries filtered presets through post-delete onboard resume", async () => { expect(createIntent).toMatchObject({ recreate: true, recreateJournalTargetIntentFingerprint: targetIntentFingerprint, - rebuildPolicyPresets: ["github"], resolved: { - policy: { options: { additionalPresets: ["github"] } }, + policy: { options: { additionalPresets: [] } }, }, recreateTransaction: { id: transaction.id, diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index 0a3736993ae..52ccc5d6c11 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -9,7 +9,7 @@ import { deriveCheckpointFromSession } from "../../../state/onboard-checkpoint-m import type { CheckpointProviderBinding } from "../../../state/onboard-checkpoint-types"; import type { CheckpointSandboxRecreateTransaction } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; -import type { BaselineExclusionEntry, SandboxRemovalReceipt } from "../../../state/registry"; +import type { SandboxRemovalReceipt } from "../../../state/registry"; import { advanceSandboxRecreateTransaction, fingerprintSandboxRecreateValue, @@ -183,7 +183,6 @@ export function createDeps( getRecordedChannels: vi.fn(() => null), showMessagingStage: vi.fn(), setupMessaging: vi.fn(async () => [] as string[]), - preflightPolicyRequirements: vi.fn(), stageCredentialProviders: vi.fn(async () => [] as CheckpointProviderBinding[]), promptName: vi.fn(async () => "my-assistant"), selectResourceProfile: vi.fn(async () => null as ResourceProfile | null), @@ -198,7 +197,6 @@ export function createDeps( inferenceProvider?: string | null; extraProviders: readonly string[]; staleExtraProviders: readonly string[]; - baselineExclusions?: readonly BaselineExclusionEntry[]; }) => ({ sandboxName: input.sandboxName, inferenceProvider: input.inferenceProvider ?? null, @@ -215,8 +213,6 @@ export function createDeps( directGpu: false, additionalPresets: [], policyTier: null, - baselineExclusions: - input.baselineExclusions?.map((exclusion) => ({ ...exclusion })) ?? [], }, }, gpuCreateArgs: [], @@ -310,7 +306,6 @@ export function createDeps( clearPlanEnv: calls.clearPlanEnv, getRegistrySandboxMessagingAuthority: () => ({ authoritative: false, plan: null }), providerMatchesGatewayCredential: () => true, - preflightPolicyRequirements: calls.preflightPolicyRequirements, stageSandboxCredentialProviders: calls.stageCredentialProviders, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index c3157ceb28a..e4260ff2d73 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -135,9 +135,6 @@ describe("handleSandboxState", () => { }); expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ endpointSource: null }); - expect(calls.preflightPolicyRequirements).toHaveBeenCalledWith( - expect.objectContaining({ hostLocalInferenceRouteOnly: true }), - ); }); it("records credential-provider bindings and the resource-profile decision in the checkpoint (#7022)", async () => { @@ -236,28 +233,22 @@ describe("handleSandboxState", () => { ...baseOptions(deps), agent: { name: "langchain-deepagents-code" }, authoritativeResumeConfig: true, - authoritativePolicyTier: "restricted", }); - expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ - policyTier: "restricted", - }); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({}); }); - it("preserves an authoritative null tier in the sandbox create intent", async () => { + it("does not persist an authoritative policy tier in sandbox create state", async () => { const { deps, calls } = createDeps(); await handleSandboxState({ ...baseOptions(deps), agent: { name: "langchain-deepagents-code" }, authoritativeResumeConfig: true, - authoritativePolicyTier: null, }); - expect(calls.resolveCreateIntent).toHaveBeenCalledWith( - expect.objectContaining({ policyTier: null }), - ); - expect(calls.createSandbox.mock.calls[0]?.at(-1)).toHaveProperty("policyTier", null); + expect(calls.resolveCreateIntent.mock.calls[0]?.[0]).not.toHaveProperty("policyTier"); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).not.toHaveProperty("policyTier"); }); it("rejects observability for a selected non-DCode agent", async () => { diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index f064442a43b..b25271f9724 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -37,11 +37,7 @@ import type { SessionResourceProfile, SessionUpdates, } from "../../../state/onboard-session"; -import { - type BaselineExclusionEntry, - type SandboxEntry, - type SandboxRemovalReceipt, -} from "../../../state/registry"; +import { type SandboxEntry, type SandboxRemovalReceipt } from "../../../state/registry"; import { getSandboxEntryInference } from "../../../state/registry-entry-view"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; import { @@ -97,11 +93,7 @@ import { selectSandboxRecreateTargetIntentFingerprint, selectedGatewayForSandboxRecreate, } from "../../sandbox-recreate-transaction"; -import { - assertBaselineExclusionsMatchCreateIntent, - baselineExclusionsForCreate, - sandboxCreateInferenceSelection, -} from "../../sandbox-registration"; +import { sandboxCreateInferenceSelection } from "../../sandbox-registration"; import { withSandboxPhaseTrace } from "../../tracing"; import type { InferenceRouteReservationAuthority, SandboxCreateIntent } from "../../types"; @@ -197,7 +189,6 @@ export interface SandboxStateOptions< /** Internal rebuild mode: null web-search state is an authoritative disable, not a prompt. */ authoritativeResumeConfig?: boolean; /** Internal rebuild tier that must govern create-time and resumed policy selection. */ - authoritativePolicyTier?: string | null; /** Keep provider and credential effects behind the exact post-create policy gate. */ deferSandboxEffectsUntilPolicyVerification?: boolean; /** Endpoint source to preserve during an authoritative rebuild. */ @@ -208,7 +199,7 @@ export interface SandboxStateOptions< requestedObservabilityEnabled?: boolean | null; requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; rebuildPreservedEnv?: readonly import("../../../state/preserved-env").PreservedEnvFile[]; - rebuildPolicyPresets?: readonly string[]; + rebuildPolicySourcePath?: string; hostMounts?: readonly import("../../../state/registry/types").SandboxHostMount[]; recreateSandbox: (requested?: boolean) => boolean; gatewayName: string; @@ -312,19 +303,6 @@ export interface SandboxStateOptions< sandboxName: string, ): import("../../../messaging/plan-authority").RegistryMessagingAuthority; providerMatchesGatewayCredential(name: string, type: string, credentialEnv: string): boolean; - preflightPolicyRequirements(input: { - gatewayName: string; - sandboxName: string | null; - agent: Agent; - selectedMessagingChannels: readonly string[]; - hermesToolGateways: readonly string[]; - gpuPassthrough: boolean; - provider: string | null; - hostLocalInferenceRouteOnly?: boolean; - webSearchConfig: WebSearchConfig | null; - observabilityEnabled: boolean; - operation: string; - }): void; stageSandboxCredentialProviders(input: { sandboxName: string; enabledChannels: readonly string[]; @@ -332,7 +310,7 @@ export interface SandboxStateOptions< agent: Agent; requiredBindings: readonly CheckpointProviderBinding[]; replaceExisting?: boolean; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; }): Promise; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; @@ -354,7 +332,6 @@ export interface SandboxStateOptions< extraProviders: readonly string[]; staleExtraProviders: readonly string[]; policyTier?: string | null; - baselineExclusions?: readonly BaselineExclusionEntry[]; reuseRegisteredCredentials?: boolean; hostMounts?: readonly import("../../../state/registry/types").SandboxHostMount[]; }): Promise; @@ -498,54 +475,6 @@ function compatibleEndpointReasoningForCreateIntent( return value === "true" || value === "false" ? { compatibleEndpointReasoning: value } : {}; } -function rebuildPolicyPresetsForCreateIntent( - value: readonly string[] | undefined, - session: Session | null, - sandboxName: string, -): Pick { - if (session?.policyAuthority === "externally-managed") return {}; - // A later `onboard --resume` no longer has the outer rebuild's in-memory - // options. The matching recreate journal makes its filtered session value - // the durable replacement target instead of the preserved source row. - const journaledValue = - session?.checkpoint?.sandboxRecreate?.sandboxName === sandboxName && - Array.isArray(session.policyPresets) - ? session.policyPresets - : undefined; - const selectedValue = Array.isArray(value) ? value : journaledValue; - return Array.isArray(selectedValue) ? { rebuildPolicyPresets: [...selectedValue] } : {}; -} - -function clearExternallyManagedPolicyPresets( - session: Session | null, - updateSession: (mutator: (current: Session) => Session | void) => Session, -): void { - if (session?.policyAuthority !== "externally-managed" || session.policyPresets === null) return; - updateSession((current) => { - if (current.policyAuthority === "externally-managed") current.policyPresets = null; - return current; - }); - session.policyPresets = null; -} - -/** Replace a resumed create-plan snapshot with the outer rebuild's normalized built-ins. */ -function applyAuthoritativeRebuildPolicyPresets( - intent: ResolvedSandboxCreateIntent, - rebuildPolicyPresets: readonly string[] | undefined, -): ResolvedSandboxCreateIntent { - if (!Array.isArray(rebuildPolicyPresets)) return intent; - return { - ...intent, - policy: { - ...intent.policy, - options: { - ...intent.policy.options, - additionalPresets: [...rebuildPolicyPresets], - }, - }, - }; -} - function deferredSandboxEffectsIntent(enabled: boolean): { readonly deferSandboxEffectsUntilPolicyVerification?: true; } { @@ -951,10 +880,8 @@ class SandboxStateFlow< this.options.agent, !this.options.fromDockerfile, ); - const policyFingerprint = this.options.authoritativePolicyTier ?? "default"; const lightFingerprint = [ typeof builtFingerprint === "string" ? builtFingerprint : sandboxName, - policyFingerprint, ...apfCreateFingerprintFields(this.options.apfInterceptorRequested === true), this.options.provider, this.options.model, @@ -974,6 +901,7 @@ class SandboxStateFlow< const { extraProviders: _extraProviders, staleExtraProviders: _staleExtraProviders, + policy: _policy, ...durableCreateIntent } = createIntent; return `${lightFingerprint}|${JSON.stringify(durableCreateIntent)}`; @@ -1270,15 +1198,6 @@ class SandboxStateFlow< this.deps, state.session?.messagingPlan ?? null, ); - if (state.sandboxName) { - this.revalidatePolicyRequirements( - state.sandboxName, - messaging.selectedChannels, - state.webSearchConfig, - state.session, - `record reused sandbox state for '${state.sandboxName}'`, - ); - } if (messaging.changed) { this.deps.updateSession((current) => { current.messagingPlan = messaging.plan; @@ -1288,28 +1207,10 @@ class SandboxStateFlow< } this.backfillReusedSandboxFidelity(state); this.deps.skippedStepMessage("sandbox", state.sandboxName, "reuse"); - if (state.sandboxName) { - this.revalidatePolicyRequirements( - state.sandboxName, - messaging.selectedChannels, - state.webSearchConfig, - state.session, - `record reused sandbox completion for '${state.sandboxName}'`, - ); - } const skippedSession = await this.deps.recordStateSkipped("sandbox", { reason: "resume", sandboxName: state.sandboxName, }); - if (state.sandboxName) { - this.revalidatePolicyRequirements( - state.sandboxName, - messaging.selectedChannels, - state.webSearchConfig, - state.session, - `record reused sandbox receipts for '${state.sandboxName}'`, - ); - } const recordedSession = this.backfillReusedSandboxCheckpointReceipts( skippedSession, state.sandboxName, @@ -1587,32 +1488,6 @@ class SandboxStateFlow< return { ...state, session }; } - private revalidatePolicyRequirements( - sandboxName: string, - selectedMessagingChannels: readonly string[], - webSearchConfig: WebSearchConfig | null, - session: Session | null, - operation: string, - ): void { - this.deps.preflightPolicyRequirements({ - gatewayName: this.options.gatewayName, - sandboxName, - agent: this.options.agent, - selectedMessagingChannels, - hermesToolGateways: effectiveHermesToolGatewaysForWebSearch( - this.options.agent as { name?: string } | null, - webSearchConfig as unknown as SharedWebSearchConfig | null, - this.options.hermesToolGateways, - ), - gpuPassthrough: session?.gpuPassthrough === true, - provider: this.options.provider, - hostLocalInferenceRouteOnly: this.options.hostLocalInferenceRouteOnly === true, - webSearchConfig, - observabilityEnabled: session?.observabilityEnabled === true, - operation, - }); - } - private async registerCompletedCredentialProviders( sandboxName: string, enabledChannels: readonly string[], @@ -1662,17 +1537,7 @@ class SandboxStateFlow< const registeredProviders = await this.deps.withGatewayRouteMutationLock( this.options.gatewayName, async () => { - const revalidatePolicyRequirements = - verifiedPolicyRevalidation ?? - ((operation: string) => - this.revalidatePolicyRequirements( - sandboxName, - selectedMessagingChannels, - webSearchConfig, - session, - operation, - )); - revalidatePolicyRequirements( + verifiedPolicyRevalidation?.( `register credential providers for sandbox ${JSON.stringify(sandboxName)}`, ); const staged = await this.deps.stageSandboxCredentialProviders({ @@ -1682,7 +1547,9 @@ class SandboxStateFlow< agent: this.options.agent, requiredBindings, ...(replaceExisting ? { replaceExisting: true } : {}), - revalidatePolicyRequirements, + ...(verifiedPolicyRevalidation + ? { verifyLivePolicyRequirements: verifiedPolicyRevalidation } + : {}), }); const stagedProviderNames = new Set(); for (const binding of staged) { @@ -1852,35 +1719,22 @@ class SandboxStateFlow< hermesToolGateways: readonly string[], deferSandboxEffectsUntilPolicyVerification: boolean, ): Promise { - clearExternallyManagedPolicyPresets(state.session, this.deps.updateSession); const reuseRegisteredCredentials = this.resumesSandboxPrompts && this.options.resume; - const rebuildPolicyPresetSelection = rebuildPolicyPresetsForCreateIntent( - this.options.rebuildPolicyPresets, - state.session, + const resolved = await this.deps.resolveSandboxCreateIntent({ sandboxName, - ); - const resolved = applyAuthoritativeRebuildPolicyPresets( - await this.deps.resolveSandboxCreateIntent({ - sandboxName, - inferenceProvider: this.options.provider, - hostLocalInferenceRouteOnly: this.options.hostLocalInferenceRouteOnly === true, - enabledChannels: state.selectedMessagingChannels, - webSearchConfig: state.webSearchConfig, - agent: this.options.agent, - sandboxGpuConfig: this.options.sandboxGpuConfig, - resourceProfile, - hermesToolGateways, - extraProviders, - staleExtraProviders, - hostMounts: this.options.hostMounts, - baselineExclusions: baselineExclusionsForCreate(sandboxName), - ...(reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), - ...(this.options.authoritativePolicyTier !== undefined - ? { policyTier: this.options.authoritativePolicyTier } - : {}), - }), - rebuildPolicyPresetSelection.rebuildPolicyPresets, - ); + inferenceProvider: this.options.provider, + hostLocalInferenceRouteOnly: this.options.hostLocalInferenceRouteOnly === true, + enabledChannels: state.selectedMessagingChannels, + webSearchConfig: state.webSearchConfig, + agent: this.options.agent, + sandboxGpuConfig: this.options.sandboxGpuConfig, + resourceProfile, + hermesToolGateways, + extraProviders, + staleExtraProviders, + hostMounts: this.options.hostMounts, + ...(reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), + }); return { resolved, recreate: requiresSandboxRecreation(decision, this.options.recreateSandbox(false)), @@ -1898,16 +1752,15 @@ class SandboxStateFlow< isDcodeAgent((this.options.agent as { name?: string } | null)?.name) ? { dcodeAutoApprovalMode: this.dcodeAutoApprovalMode } : {}), - ...(this.options.authoritativePolicyTier !== undefined - ? { policyTier: this.options.authoritativePolicyTier } - : {}), ...deferredSandboxEffectsIntent(deferSandboxEffectsUntilPolicyVerification), ...(this.options.rebuildPreservedEnv ? { rebuildPreservedEnv: this.options.rebuildPreservedEnv } : {}), recreateJournalTargetIntentFingerprint: this.options.recreateJournalTargetIntentFingerprint ?? undefined, - ...rebuildPolicyPresetSelection, + ...(this.options.rebuildPolicySourcePath + ? { rebuildPolicySourcePath: this.options.rebuildPolicySourcePath } + : {}), extraProviders, }; } @@ -2199,7 +2052,7 @@ class SandboxStateFlow< deferSandboxEffectsUntilPolicyVerification: boolean, activateVerifiedCredentialProviders?: ( state: SandboxStepState, - revalidatePolicyRequirements: (operation: string) => void, + verifyLivePolicyRequirements: (operation: string) => void, ) => Promise>, ): Promise> { const resourceSelection = await this.resolveResourceProfile(initialState); @@ -2210,14 +2063,6 @@ class SandboxStateFlow< state.webSearchConfig as unknown as SharedWebSearchConfig | null, this.options.hermesToolGateways, ); - const revalidatePolicyRequirements = (operation: string) => - this.revalidatePolicyRequirements( - requestedSandboxName, - state.selectedMessagingChannels, - state.webSearchConfig, - state.session, - operation, - ); const extraProviderPlan = this.deps.planRegisteredExtraProviders(this.options.gatewayName); const createAndRecord = async (): Promise> => { this.assertRegistryMessagingPlanUnchanged( @@ -2267,19 +2112,11 @@ class SandboxStateFlow< current.messagingPlan = messagingPlan; return current; }); - // Re-read at the destructive edge. The lock prevents cooperating - // writers from changing this state; the equality check also catches a - // direct registry writer that bypassed the lock. - assertBaselineExclusionsMatchCreateIntent( - requestedSandboxName, - createIntent.resolved.policy.options.baselineExclusions, - ); const { transaction, sourceEntry, effectiveCreateIntent, repairMetadata } = await this.prepareSandboxRecreate(state, requestedSandboxName, createIntent, decision); let sandboxName: string; try { - revalidatePolicyRequirements(`create sandbox '${requestedSandboxName}'`); if (this.options.fresh && !deferSandboxEffectsUntilPolicyVerification) { this.deps.stopStaleDashboardListenersForSandbox( this.deps.listRegistrySandboxes().sandboxes, @@ -2337,7 +2174,7 @@ class SandboxStateFlow< } state = await activateVerifiedCredentialProviders( state, - verifiedContext.revalidatePolicyRequirements, + verifiedContext.verifyLivePolicyRequirements, ); }, ] @@ -2348,7 +2185,6 @@ class SandboxStateFlow< await this.recordSandboxRecreateRepairFailure(transaction, repairMetadata, error); throw error; } - revalidatePolicyRequirements("complete the created sandbox"); let recordedTransaction: CheckpointSandboxRecreateTransaction | null; try { recordedTransaction = this.reloadSandboxRecreateTransaction(transaction); @@ -2358,7 +2194,6 @@ class SandboxStateFlow< await this.recordSandboxRecreateRepairFailure(transaction, repairMetadata, error); throw error; } - revalidatePolicyRequirements("complete sandbox repair"); this.recordSandboxRecreateRegistryCommit(recordedTransaction); // createSandbox() owns the build fingerprint. In particular, reusing an // image must not stamp it with the current version and hide build drift. @@ -2368,7 +2203,6 @@ class SandboxStateFlow< ...agentRegistryFields } = this.deps.getSandboxAgentRegistryFields(this.options.agent, !this.options.fromDockerfile); // Preserve the validated route and credential env-var name, never a credential value. - revalidatePolicyRequirements("register the created sandbox"); this.deps.updateSandboxRegistry(sandboxName, { ...(providerlessApf ? {} @@ -2385,7 +2219,6 @@ class SandboxStateFlow< }); // Finalization marks the default so a cancelled onboarding cannot leave a // partially configured sandbox selected as the default. - revalidatePolicyRequirements("complete the sandbox onboarding session"); await this.deps.recordStepComplete( "sandbox", this.deps.toSessionUpdates({ @@ -2399,7 +2232,6 @@ class SandboxStateFlow< hermesToolGateways: effectiveHermesToolGateways, }), ); - revalidatePolicyRequirements("record the final sandbox creation receipt"); const recordedSession = this.recordSandboxCreateEffects( transaction, sandboxName, @@ -2593,7 +2425,7 @@ class SandboxStateFlow< // those providers are attached. For ordinary managed creation, register the // required providers first and let `sandbox create --provider` attach them // atomically with that policy. Keep APF-selected creation behind its strict - // post-create boundary because APF owns the initial policy. + // post-create boundary because APF contributes to the initial policy. const hasCreateTimeCredentialBindings = webSearchProviderBindings.length > 0 || messagingProviderBindings.length > 0; const deferCredentialProviderEffects = diff --git a/src/lib/onboard/machine/initial-flow-phases.test.ts b/src/lib/onboard/machine/initial-flow-phases.test.ts index b739b333f92..9a64a56666c 100644 --- a/src/lib/onboard/machine/initial-flow-phases.test.ts +++ b/src/lib/onboard/machine/initial-flow-phases.test.ts @@ -163,7 +163,6 @@ describe("initial onboard flow phases", () => { getInitialGatewayReuseState: () => "healthy", assertGatewayReadiness: vi.fn(async () => undefined), gatewayName: "nemoclaw", - bindPolicyAuthority: async (_gatewayName, session) => session, recreateSandbox: () => false, gatewayDeps: { resolveGatewayOwner: () => @@ -435,7 +434,6 @@ describe("initial onboard flow phases", () => { calls.push("assert-gateway-readiness"); }), gatewayName: "nemoclaw", - bindPolicyAuthority: async (_gatewayName, gatewaySession) => gatewaySession, recreateSandbox: () => false, gatewayDeps: { resolveGatewayOwner: () => diff --git a/src/lib/onboard/machine/initial-flow-phases.ts b/src/lib/onboard/machine/initial-flow-phases.ts index aa048cde0b5..769b47a0f81 100644 --- a/src/lib/onboard/machine/initial-flow-phases.ts +++ b/src/lib/onboard/machine/initial-flow-phases.ts @@ -67,10 +67,6 @@ export interface InitialOnboardFlowPhaseOptions< getInitialGatewayReuseState(): GatewayReuseState; assertGatewayReadiness(): Promise; gatewayName: string; - bindPolicyAuthority( - gatewayName: string, - session: import("../../state/onboard-session").Session | null, - ): Promise; recreateSandbox(): boolean; requiresBindMounts?: boolean; gatewayDeps: GatewayStateOptions["deps"]; @@ -192,9 +188,6 @@ export function createInitialOnboardFlowPhases< const gatewayPhase: OnboardSequencePhase = { state: "gateway", async run(context) { - // Resolve authority before the managed-only reuse helper can select a - // gateway or mutate OPENSHELL_GATEWAY. External attachment revalidates - // the same owner again at the effect edge. const owner = options.gatewayDeps.resolveGatewayOwner(); await options.assertGatewayReadiness(); const gatewayResult = await handleGatewayState({ @@ -213,12 +206,8 @@ export function createInitialOnboardFlowPhases< requiresBindMounts: options.requiresBindMounts === true, deps: options.gatewayDeps, }); - const policySession = await options.bindPolicyAuthority( - options.gatewayName, - gatewayResult.session, - ); return { - context: { ...context, session: policySession }, + context: { ...context, session: gatewayResult.session }, result: gatewayResult.stateResult, }; }, diff --git a/src/lib/onboard/machine/resume-provider-shim.ts b/src/lib/onboard/machine/resume-provider-shim.ts index 2eb54cbb861..c399bfd5b79 100644 --- a/src/lib/onboard/machine/resume-provider-shim.ts +++ b/src/lib/onboard/machine/resume-provider-shim.ts @@ -29,7 +29,7 @@ export type ResumeProviderShimDeps = { /** Recover an exact gateway-scoped managed runtime; false means no managed owner state exists. */ resumeManagedLlamaCppRuntime?: ( sandboxName: string, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; }; @@ -38,18 +38,18 @@ export function createResumeProviderShim(deps: ResumeProviderShimDeps) { async ensureManagedLlamaCppResumeReady( provider: string | null | undefined, sandboxName: string | null | undefined, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise { if (provider !== "llama-cpp-local" || !sandboxName || !deps.resumeManagedLlamaCppRuntime) { return false; } - return deps.resumeManagedLlamaCppRuntime(sandboxName, revalidatePolicyRequirements); + return deps.resumeManagedLlamaCppRuntime(sandboxName, verifyLivePolicyRequirements); }, async ensureResumeProviderReady( gatewayName: string, provider: string | null | undefined, credentialEnv: string | null | undefined, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise { return ensureResumeProviderReadyImpl(provider, credentialEnv, { remoteProviderConfig: onboardProviders.REMOTE_PROVIDER_CONFIG, @@ -61,7 +61,7 @@ export function createResumeProviderShim(deps: ResumeProviderShimDeps) { isNonInteractive: deps.isNonInteractive, note: (message) => console.log(`${D}${message}${R}`), replaceNamedCredential: deps.replaceNamedCredential, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, validateNvidiaApiKeyValue, log: (message) => console.log(message), warn: (message) => console.error(message), diff --git a/src/lib/onboard/machine/types.ts b/src/lib/onboard/machine/types.ts index cb7bf0e6938..6888708a0f9 100644 --- a/src/lib/onboard/machine/types.ts +++ b/src/lib/onboard/machine/types.ts @@ -81,7 +81,6 @@ export interface OnboardMachineContext { reasoningEffort?: "low" | "medium" | "high" | "endpoint-default" | null; hermesAuthMethod?: "oauth" | "api_key" | null; hermesToolGateways?: string[] | null; - policyPresets?: string[] | null; messagingChannels?: string[] | null; gpuPassthrough?: boolean; } diff --git a/src/lib/onboard/managed-workload-rebuild-transaction.test.ts b/src/lib/onboard/managed-workload-rebuild-transaction.test.ts index 72ee82fe656..046ef5e8fe0 100644 --- a/src/lib/onboard/managed-workload-rebuild-transaction.test.ts +++ b/src/lib/onboard/managed-workload-rebuild-transaction.test.ts @@ -699,33 +699,6 @@ describe("managed workload rebuild transaction", () => { ); }); - it("publishes a replacement without carrying the previous policy receipt (#9833)", async () => { - const lifecycleGeneration = "00000000-0000-4000-8000-000000000001"; - const sandboxIdentityFingerprint = "a".repeat(64); - const harness = transactionHarness("openclaw", "mxc", null, "linux/amd64", { - lifecycleGeneration, - lifecycleLiveIdentityFingerprint: sandboxIdentityFingerprint, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "rebuild-openclaw", - lifecycleGeneration, - sandboxIdentityFingerprint, - policyHash: "policy-old", - policyVersion: 1, - }, - }); - - const result = await harness.run(); - - expect(result.entry.lifecycleGeneration).toBe("generation-new"); - expect(result.entry).not.toHaveProperty("policyAuthority"); - expect(result.entry).not.toHaveProperty("policyCreationReceipt"); - }); - it("rolls back a not-ready replacement by exact staged handle", async () => { const harness = transactionHarness("hermes", "docker", "readiness"); diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts index ecf2ffd0970..fbf44f710e1 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts @@ -51,6 +51,7 @@ function createFreshOnboardingRuntime( options: { readonly stockManagedRuntime?: boolean; readonly tempManagedRuntime?: boolean; + readonly tempManagedRuntimeCatalog?: string | null; readonly unavailableCatalog?: boolean; } = {}, ) { @@ -73,7 +74,7 @@ function createFreshOnboardingRuntime( managedWorkloadRebuild: null, tempManagedRuntime: options.tempManagedRuntime ?? false, stockManagedRuntime: options.stockManagedRuntime ?? false, - tempManagedRuntimeCatalog: null, + tempManagedRuntimeCatalog: options.tempManagedRuntimeCatalog ?? null, agentName: "openclaw", legacyDockerfilePath: "agents/openclaw/Dockerfile", customDockerfilePath: null, @@ -211,6 +212,26 @@ describe("managed workload onboard orchestration", () => { await expect(runtime.ensurePreparedWorkload()).rejects.toThrow("registry offline"); }); + it("treats an explicit temporary catalog as strict managed-image selection", async () => { + const { prepared, runtime } = createFreshOnboardingRuntime( + {}, + { tempManagedRuntimeCatalog: "/tmp/pi-candidate-catalog.json" }, + ); + + await expect(runtime.ensurePreparedWorkload()).resolves.toBe(prepared); + expect(prepareSandboxWorkloadSource).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + catalogPath: "/tmp/pi-candidate-catalog.json", + runtime: expect.objectContaining({ + driverName: "docker", + managedImages: expect.objectContaining({ + exactDigestReferences: true, + }), + }), + }), + ); + }); + it("selects only the shipped Hermes Dockerfile fallback without profile or prebuild work", async () => { const expectedDockerfilePath = "/workspace/agents/hermes/Dockerfile"; const ensurePreparedProfile = vi.fn(() => null); @@ -379,7 +400,6 @@ describe("managed workload onboard orchestration", () => { policyPath: "/tmp/nemoclaw-policy.yaml", }, messagingProviders: [], - policyTier: null, sandboxGpuLogMessage: null, })); diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index a45b7173268..ddacc31df8b 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -227,7 +227,10 @@ export function createManagedWorkloadOnboardRuntime( const discoveredRuntimeCapabilities = resolveSandboxWorkloadRuntimeCapabilities( input.computePlan, ); - const strictManagedRuntime = input.tempManagedRuntime || input.managedWorkloadRebuild !== null; + const strictManagedRuntime = + input.tempManagedRuntime || + input.tempManagedRuntimeCatalog !== null || + input.managedWorkloadRebuild !== null; const runtimeCapabilities = strictManagedRuntime || input.stockManagedRuntime ? discoveredRuntimeCapabilities @@ -358,7 +361,7 @@ export interface PrepareOnboardSandboxWorkloadLaunchInput { }; readonly plan: { readonly intent: SandboxCreateIntent; - readonly policyAuthority: MaterializeSandboxCreatePlanInput["policyAuthority"]; + readonly policylessCreate?: boolean; readonly deferSandboxEffectsUntilPolicyVerification?: boolean; readonly rebindMessagingTokenDefs: () => Promise; readonly runProviderPreDeleteCleanup: MaterializeSandboxCreatePlanInput["runProviderPreDeleteCleanup"]; @@ -388,8 +391,6 @@ export interface PrepareOnboardSandboxWorkloadLaunchInput { export interface PreparedOnboardSandboxWorkloadLaunch { readonly initialSandboxPolicy: InitialSandboxPolicy; - readonly policyTier: string | null; - readonly policyAuthority: MaterializeSandboxCreatePlanInput["policyAuthority"]; readonly messagingProviders: string[]; readonly gpuRoutePlan: SandboxCreateIntent["gpuRoutePlan"]; readonly compatibilityPolicyPath: string | null; @@ -434,7 +435,7 @@ export async function prepareOnboardSandboxWorkloadLaunch( const createPlan = input.dependencies.materializeSandboxCreatePlan({ intent: input.plan.intent, fromRef, - policyAuthority: input.plan.policyAuthority, + policylessCreate: input.plan.policylessCreate, deferSandboxEffectsUntilPolicyVerification: input.plan.deferSandboxEffectsUntilPolicyVerification, messagingTokenDefs: [...messagingTokenDefs], @@ -521,8 +522,6 @@ export async function prepareOnboardSandboxWorkloadLaunch( return { initialSandboxPolicy: createPlan.initialSandboxPolicy, - policyTier: createPlan.policyTier, - policyAuthority: createPlan.policyAuthority, messagingProviders: createPlan.messagingProviders, gpuRoutePlan: createPlan.gpuRoutePlan, compatibilityPolicyPath: createPlan.compatibilityPolicyPath, @@ -540,14 +539,12 @@ export async function prepareOnboardSandboxWorkloadLaunch( export function prepareHermesPortableOnboardSandboxLaunch(input: { readonly intent: SandboxCreateIntent; readonly fromRef: string; - readonly policyAuthority: MaterializeSandboxCreatePlanInput["policyAuthority"]; readonly launchInput: Omit; readonly gpuConfig: SandboxGpuConfig; }): PreparedOnboardSandboxWorkloadLaunch { const createPlan = materializeHermesPortableCreatePlan({ intent: input.intent, fromRef: input.fromRef, - policyAuthority: input.policyAuthority, }); const launch = prepareSandboxCreateLaunch({ ...input.launchInput, diff --git a/src/lib/onboard/managed-workload/rebuild/commit.ts b/src/lib/onboard/managed-workload/rebuild/commit.ts index c555dadffa7..80d14cccc08 100644 --- a/src/lib/onboard/managed-workload/rebuild/commit.ts +++ b/src/lib/onboard/managed-workload/rebuild/commit.ts @@ -73,13 +73,8 @@ export function materializeManagedWorkloadReplacementEntry( plan: ManagedWorkloadRebuildPlan, replacement: ReboundManagedWorkloadReplacement, ): SandboxEntry { - const { - policyAuthority: _previousPolicyAuthority, - policyCreationReceipt: _previousPolicyCreationReceipt, - ...retainedPreviousEntry - } = previousEntry; return structuredClone({ - ...retainedPreviousEntry, + ...previousEntry, ...plan.replacementMetadata, name: plan.sandboxName, pendingRouteReservation: undefined, diff --git a/src/lib/onboard/managed-workload/rebuild/plan.ts b/src/lib/onboard/managed-workload/rebuild/plan.ts index 836d2e7d19e..8dfb8efb234 100644 --- a/src/lib/onboard/managed-workload/rebuild/plan.ts +++ b/src/lib/onboard/managed-workload/rebuild/plan.ts @@ -29,8 +29,6 @@ const PROTECTED_REBUILD_METADATA_FIELDS = new Set([ "workload", "lifecycleGeneration", "lifecycleLiveIdentityFingerprint", - "policyAuthority", - "policyCreationReceipt", ]); function safeReplacementMetadata( diff --git a/src/lib/onboard/messaging-policy-presets.test.ts b/src/lib/onboard/messaging-policy-presets.test.ts index 8a10d2ae1dd..71a90eb2e48 100644 --- a/src/lib/onboard/messaging-policy-presets.test.ts +++ b/src/lib/onboard/messaging-policy-presets.test.ts @@ -9,7 +9,6 @@ import { mergeAppliedPolicyPresetsForDisabledMessagingCleanup, mergeEnabledMessagingChannelPolicyPresets, mergePolicyMessagingChannels, - mergeRebuildMessagingPolicyPresets, messagingChannelsForPolicyPresets, pruneDisabledMessagingPolicyPresets, requiredMessagingChannelPolicyPresets, @@ -109,19 +108,6 @@ describe("messaging policy presets", () => { expect(hasDisabledMessagingPolicyPreset(["npm", "pypi"], ["slack"])).toBe(false); }); - it("recovers presets for enabled channels absent from sb.policies after a prior stop+rebuild (#5596)", () => { - const enabledChannels = ["telegram", "discord", "whatsapp", "wechat", "slack"]; - expect( - mergeRebuildMessagingPolicyPresets(["npm", "npm", "telegram"], ["pypi"], enabledChannels, [ - "telegram", - "wechat", - ]), - ).toEqual(["npm", "discord", "whatsapp", "slack"]); - expect( - mergeRebuildMessagingPolicyPresets(undefined, ["pypi"], enabledChannels, ["wechat"]), - ).toEqual(["pypi", "telegram", "discord", "whatsapp", "slack"]); - }); - it("preserves unrelated applied presets when cleaning disabled messaging presets", () => { expect( mergeAppliedPolicyPresetsForDisabledMessagingCleanup( diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts index f15560e9362..b18afa519bb 100644 --- a/src/lib/onboard/messaging-policy-presets.ts +++ b/src/lib/onboard/messaging-policy-presets.ts @@ -60,13 +60,13 @@ export function requiredMessagingChannelPolicyPresets( // selection. An enabled channel cannot function without its network-egress // preset, so that preset must survive policy finalization regardless of how the // operator arrived at the selection (interactive tier, env-driven custom list, -// or a recorded resume set). We intentionally merge *all* of a channel's +// or a live-policy-derived resume set). We intentionally merge *all* of a channel's // presets, not just the create-time `requiredAtCreate` ones: `requiredAtCreate` // governs whether a preset is injected into the boot policy at sandbox-create // time (Discord and Slack today), while finalization applies any newly-merged preset // to the live gateway itself. Using only the create-time-required set here drops // every other channel's preset (Telegram, WhatsApp, Teams, WeChat) from -// the persisted selection, so `policy-list` shows them unapplied even though the +// the command-time selection, so `policy-list` shows them unapplied even though the // channel was configured during onboard. See #5967. export function mergeEnabledMessagingChannelPolicyPresets( selectedPresets: string[], @@ -152,36 +152,6 @@ export function pruneDisabledMessagingPolicyPresets( ); } -/** - * Recover the desired preset set after stop+rebuild pruned disabled-channel - * egress from persisted policies and a later start+rebuild re-enabled those - * channels. The backup manifest is authoritative when present, with the - * registry as the stale-sandbox fallback; the current messaging plan owns - * enabled/disabled state, and channel manifests own channel-to-preset mapping. - * The helper and caller boundaries are covered in messaging-policy-presets.test.ts - * and rebuild-flow.test.ts, respectively. - * - * Remove this recovery merge when the registry or planner durably persists one - * canonical desired preset set across stop/start rebuilds. - */ -export function mergeRebuildMessagingPolicyPresets( - backupPresets: string[] | null | undefined, - registryPresets: string[], - enabledChannels: string[] | null | undefined, - disabledChannels: string[] | null | undefined, -): string[] { - const persistedPresets = backupPresets ?? registryPresets; - return [ - ...new Set([ - ...pruneDisabledMessagingPolicyPresets(persistedPresets, disabledChannels), - ...pruneDisabledMessagingPolicyPresets( - allMessagingChannelPolicyPresets(enabledChannels), - disabledChannels, - ), - ]), - ]; -} - export function hasDisabledMessagingPolicyPreset( selectedPresets: string[], disabledChannels: string[] | null | undefined, diff --git a/src/lib/onboard/observability-policy-presets.ts b/src/lib/onboard/observability-policy-presets.ts index a1e9b692a56..0b35dc56abc 100644 --- a/src/lib/onboard/observability-policy-presets.ts +++ b/src/lib/onboard/observability-policy-presets.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { ManagedPolicyBinding } from "../policy/managed-policy-binding"; import { type ManagedSandboxFeature, managedSandboxFeatureHasDrift, @@ -9,10 +8,6 @@ import { export const DCODE_AGENT_NAME = "langchain-deepagents-code"; export const OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET = "observability-otlp-local"; -export const OBSERVABILITY_POLICY_BINDING = new ManagedPolicyBinding({ - presetName: OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, -}); - export const DCODE_ONLY_POLICY_PRESETS = new Set([OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET]); export function isDcodeAgent(agent: string | null | undefined): boolean { diff --git a/src/lib/onboard/onboard-recreate-journal.test.ts b/src/lib/onboard/onboard-recreate-journal.test.ts index c1932ac4969..094c24e3284 100644 --- a/src/lib/onboard/onboard-recreate-journal.test.ts +++ b/src/lib/onboard/onboard-recreate-journal.test.ts @@ -39,7 +39,6 @@ const BASE_INTENT: OnboardRecreateTargetIntent = { toolDisclosure: "progressive", dcodeAutoApprovalMode: null, observabilityEnabled: false, - policyTier: "restricted", }; describe("non-resumed replacement target fingerprint (#7735)", () => { diff --git a/src/lib/onboard/onboard-recreate-journal.ts b/src/lib/onboard/onboard-recreate-journal.ts index 402b79126aa..29cdb0d4a72 100644 --- a/src/lib/onboard/onboard-recreate-journal.ts +++ b/src/lib/onboard/onboard-recreate-journal.ts @@ -36,7 +36,6 @@ export interface OnboardRecreateTargetIntent { readonly toolDisclosure: string; readonly dcodeAutoApprovalMode: string | null; readonly observabilityEnabled: boolean; - readonly policyTier: string | null; } export function fingerprintOnboardRecreateTargetIntent( diff --git a/src/lib/onboard/policy-authority/preflight-reservation.test.ts b/src/lib/onboard/policy-authority/preflight-reservation.test.ts deleted file mode 100644 index 4dd6775da95..00000000000 --- a/src/lib/onboard/policy-authority/preflight-reservation.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { - managedPolicyInspection, - managedSandboxEntry, - SANDBOX_IDENTITY, -} from "../../../../test/helpers/managed-policy-receipt-fixture"; -import type { SandboxEntry } from "../../state/registry"; -import { createOnboardPolicyAuthorityBindings, qualifySandboxPolicyAuthority } from "./preflight"; - -const CURRENT_SESSION_ID = "session-current"; -const requiredPolicy = { - policyPath: "/tmp/unused.yaml", - sourceBytes: Buffer.from("version: 1\nnetwork_policies: {}\n"), - appliedPresets: [], -}; - -function pendingManagedSandboxEntry(overrides: Partial = {}): SandboxEntry { - return { - ...managedSandboxEntry("demo"), - pendingRouteReservation: true, - reservationSessionId: CURRENT_SESSION_ID, - ...overrides, - }; -} - -function managedQualificationDeps() { - return { - inspectSandboxPolicyAuthority: managedPolicyInspection, - inspectOpenShellSandboxIdentityFingerprint: () => SANDBOX_IDENTITY, - assertOpenShellGatewayPortBinding: vi.fn(), - }; -} - -describe("receipt-bound policy authority during inference route reservation", () => { - it("accepts the pending route owned by the current onboarding session (#9833)", () => { - const recorded = pendingManagedSandboxEntry(); - const deps = managedQualificationDeps(); - - expect( - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - recordedSandbox: recorded, - readRecordedSandbox: () => ({ ...recorded }), - currentSessionId: CURRENT_SESSION_ID, - prepareRequiredPolicy: () => requiredPolicy, - operation: "reuse sandbox 'demo'", - }, - deps, - ), - ).toEqual({ authority: "nemoclaw-managed" }); - expect(deps.assertOpenShellGatewayPortBinding).toHaveBeenCalledOnce(); - }); - - it("continues from provider reservation to sandbox preflight for the same session (#9833)", async () => { - const home = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-policy-reservation-")); - vi.stubEnv("HOME", home); - vi.resetModules(); - try { - const registry = await import("../../state/registry"); - const original = registry.registerSandbox(managedSandboxEntry("demo")); - registry.reserveSandboxInferenceRoute("demo", { - provider: "nvidia-prod", - model: "nvidia/test", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: "openai-responses", - gatewayName: "nemoclaw", - reservationSessionId: CURRENT_SESSION_ID, - }); - const inspectSandboxForCreate = vi.fn((name: string) => ({ - existingEntry: registry.getSandbox(name), - liveExists: true, - })); - const prepareAgentPolicy = vi.fn(() => "/unused/openclaw-policy.yaml"); - const bindings = createOnboardPolicyAuthorityBindings( - { - GATEWAY_NAME: "nemoclaw", - ROOT: "/repo", - agentDefs: { loadAgent: () => ({ name: "openclaw" }) as never }, - agentOnboard: { getAgentPolicyPath: prepareAgentPolicy as never }, - inspectSandboxForCreate, - onboardSession: { - loadSession: () => ({ sessionId: CURRENT_SESSION_ID }), - updateSession: vi.fn(), - }, - }, - null, - managedQualificationDeps(), - ); - - expect(() => - bindings.preflightPolicyRequirements({ - gatewayName: "nemoclaw", - sandboxName: "demo", - agent: { name: "openclaw" } as never, - selectedMessagingChannels: [], - hermesToolGateways: [], - gpuPassthrough: false, - provider: "nvidia-prod", - webSearchConfig: null, - observabilityEnabled: false, - operation: "continue after reserving the inference route", - }), - ).not.toThrow(); - expect(registry.getSandbox("demo")).toMatchObject({ - pendingRouteReservation: true, - reservationSessionId: CURRENT_SESSION_ID, - policyCreationReceipt: original.policyCreationReceipt, - }); - expect(inspectSandboxForCreate).toHaveBeenCalledTimes(2); - expect(prepareAgentPolicy).not.toHaveBeenCalled(); - } finally { - vi.unstubAllEnvs(); - vi.resetModules(); - await fs.rm(home, { recursive: true, force: true }); - } - }); - - it.each([ - ["is missing", undefined], - ["is empty", ""], - ["belongs to another session", "session-foreign"], - ])("refuses when the current onboarding session %s (#9833)", (_case, currentSessionId) => { - const recorded = pendingManagedSandboxEntry(); - const deps = managedQualificationDeps(); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - recordedSandbox: recorded, - readRecordedSandbox: () => ({ ...recorded }), - currentSessionId, - prepareRequiredPolicy: () => requiredPolicy, - operation: "reuse sandbox 'demo'", - }, - deps, - ), - ).toThrow(/ownership is not durably verified/u); - expect(deps.assertOpenShellGatewayPortBinding).not.toHaveBeenCalled(); - }); - - it.each([ - ["has no receipt", { policyCreationReceipt: undefined }, /creation receipt does not match/u], - [ - "has no lifecycle generation", - { lifecycleGeneration: undefined }, - /ownership is not durably verified/u, - ], - [ - "has no live identity", - { lifecycleLiveIdentityFingerprint: undefined }, - /ownership is not durably verified/u, - ], - [ - "has an incomplete policy checkpoint", - { pendingPolicyVerification: {} as never }, - /ownership is not durably verified/u, - ], - ])("refuses when a pending managed row %s (#9833)", (_case, overrides, expected) => { - const recorded = pendingManagedSandboxEntry(overrides); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - recordedSandbox: recorded, - readRecordedSandbox: () => ({ ...recorded }), - currentSessionId: CURRENT_SESSION_ID, - prepareRequiredPolicy: () => requiredPolicy, - operation: "reuse sandbox 'demo'", - }, - managedQualificationDeps(), - ), - ).toThrow(expected); - }); - - it.each([ - ["disappears", null], - [ - "changes its reservation owner", - pendingManagedSandboxEntry({ reservationSessionId: "session-raced" }), - ], - [ - "completes concurrently", - pendingManagedSandboxEntry({ - pendingRouteReservation: undefined, - reservationSessionId: CURRENT_SESSION_ID, - }), - ], - ])("refuses when the recorded route %s during live verification (#9833)", (_case, reread) => { - const recorded = pendingManagedSandboxEntry(); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - recordedSandbox: recorded, - readRecordedSandbox: () => reread, - currentSessionId: CURRENT_SESSION_ID, - prepareRequiredPolicy: () => requiredPolicy, - operation: "reuse sandbox 'demo'", - }, - managedQualificationDeps(), - ), - ).toThrow(/recorded sandbox policy boundary changed/u); - }); -}); diff --git a/src/lib/onboard/policy-authority/preflight.test.ts b/src/lib/onboard/policy-authority/preflight.test.ts deleted file mode 100644 index 0be1d3c69f9..00000000000 --- a/src/lib/onboard/policy-authority/preflight.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { - managedPolicyInspection, - managedSandboxEntry, - SANDBOX_IDENTITY, -} from "../../../../test/helpers/managed-policy-receipt-fixture"; - -import { PolicyAuthorityRefusalError } from "../../adapters/openshell/policy-authority"; -import type { SandboxEntry } from "../../state/registry"; -import { - createOnboardPolicyAuthorityBindings, - qualifySandboxPolicyAuthority, - requiredOnboardPolicyPresets, -} from "./preflight"; - -const requiredPolicy = { - policyPath: "/tmp/unused.yaml", - sourceBytes: Buffer.from( - "version: 1\nnetwork_policies:\n required_route:\n endpoints: [example.com]\n", - ), - appliedPresets: [], -}; - -describe("sandbox policy authority preflight", () => { - it("normalizes a null default agent before policy preflight (#9833)", () => { - const openclaw = { name: "openclaw" }; - const loadAgent = vi.fn(() => openclaw); - const getAgentPolicyPath = vi.fn(() => "/unused/openclaw-policy.yaml"); - const bindings = createOnboardPolicyAuthorityBindings( - { - GATEWAY_NAME: "nemoclaw", - ROOT: "/repo", - agentDefs: { loadAgent: loadAgent as never }, - agentOnboard: { getAgentPolicyPath: getAgentPolicyPath as never }, - inspectSandboxForCreate: () => ({ existingEntry: null, liveExists: false }), - onboardSession: { - loadSession: () => null, - updateSession: vi.fn(), - }, - }, - null, - { - inspectActiveGlobalPolicy: () => ({ state: "absent" }), - }, - ); - - expect(() => - bindings.preflightPolicyRequirements({ - gatewayName: "nemoclaw", - sandboxName: null, - agent: null, - selectedMessagingChannels: [], - hermesToolGateways: [], - gpuPassthrough: false, - provider: null, - webSearchConfig: null, - observabilityEnabled: false, - operation: "prepare the default sandbox", - }), - ).not.toThrow(); - expect(loadAgent).toHaveBeenCalledExactlyOnceWith("openclaw"); - }); - - it("includes every final selected policy requirement (#9833)", () => { - expect( - requiredOnboardPolicyPresets({ - additionalPresets: ["github", "github"], - provider: "ollama-local", - webSearchConfig: { provider: "tavily", fetchEnabled: true }, - agentName: "langchain-deepagents-code", - observabilityEnabled: true, - }), - ).toEqual(["github", "local-inference", "tavily", "observability-otlp-local"]); - }); - - it("does not require a local-inference preset for a proven route-only provider (#9833)", () => { - expect( - requiredOnboardPolicyPresets({ - additionalPresets: ["github"], - provider: "vllm-local", - hostLocalInferenceRouteOnly: true, - webSearchConfig: null, - agentName: "openclaw", - observabilityEnabled: false, - }), - ).toEqual(["github"]); - }); - - it("uses live sandbox metadata and accepts externally supplied requirements (#9833)", () => { - const inspectSandbox = vi.fn(() => ({ - authority: "externally-managed" as const, - effectivePolicy: { - network_policies: { required_route: { endpoints: ["example.com"] } }, - }, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - })); - - const result = qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["externally-managed"], - prepareRequiredPolicy: () => requiredPolicy, - operation: "prepare sandbox 'demo'", - }, - { - inspectSandboxPolicyAuthority: inspectSandbox, - }, - ); - - expect(result.authority).toBe("externally-managed"); - expect(inspectSandbox).toHaveBeenCalledWith({ - sandboxName: "demo", - gatewayName: "nemoclaw", - }); - }); - - it.each([ - ["malformed YAML", "version: [unterminated"], - ["non-mapping YAML", "- version: 1"], - ["an invalid policy root", "unexpected: true"], - ])("rejects %s through the canonical required-policy parser (#9833)", (_case, source) => { - const cleanup = vi.fn(() => true); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["externally-managed"], - prepareRequiredPolicy: () => ({ - ...requiredPolicy, - sourceBytes: Buffer.from(source), - cleanup, - }), - operation: "verify external policy", - }, - { - inspectSandboxPolicyAuthority: () => ({ - authority: "externally-managed", - effectivePolicy: { network_policies: {} }, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }), - }, - ), - ).toThrow(/required sandbox policy is invalid/u); - expect(cleanup).toHaveBeenCalledOnce(); - }); - - it("stops before cleanup-owning callers when external requirements are missing (#9833)", () => { - const cleanup = vi.fn(() => true); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: false, - recordedAuthorities: [], - prepareRequiredPolicy: () => ({ ...requiredPolicy, cleanup }), - operation: "create sandbox 'demo'", - }, - { - inspectActiveGlobalPolicy: () => ({ - state: "active", - inspection: { - authority: "externally-managed", - effectivePolicy: { network_policies: {} }, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }, - }), - }, - ), - ).toThrow(/external policy authority to supply/u); - expect(cleanup).toHaveBeenCalledOnce(); - }); - - it("preserves the authority refusal when temporary-policy cleanup also fails (#9833)", () => { - const cleanup = vi.fn(() => false); - let received: unknown; - - try { - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: false, - recordedAuthorities: [], - prepareRequiredPolicy: () => ({ ...requiredPolicy, cleanup }), - operation: "create sandbox 'demo'", - }, - { - inspectActiveGlobalPolicy: () => ({ - state: "active", - inspection: { - authority: "externally-managed", - effectivePolicy: { network_policies: {} }, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }, - }), - }, - ); - } catch (error) { - received = error; - } - - expect(received).toBeInstanceOf(PolicyAuthorityRefusalError); - expect(received).toMatchObject({ - message: expect.stringMatching(/external policy authority to supply/u), - cause: expect.any(AggregateError), - }); - expect((received as Error).message).toMatch( - /temporary sandbox policy cleanup failed.*remove the temporary sandbox policy before retrying/iu, - ); - expect((received as Error).message).not.toContain("example.com"); - expect(cleanup).toHaveBeenCalledOnce(); - }); - - it("rejects live and create-time authority drift before materializing requirements (#9833)", () => { - const prepareRequiredPolicy = vi.fn(() => requiredPolicy); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - prepareRequiredPolicy, - operation: "recreate sandbox 'demo'", - }, - { - inspectSandboxPolicyAuthority: () => ({ - authority: "externally-managed", - effectivePolicy: {}, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }), - }, - ), - ).toThrow(/authority changed/u); - expect(prepareRequiredPolicy).not.toHaveBeenCalled(); - }); - - it("does not materialize requirements for NemoClaw-managed policy (#9833)", () => { - const prepareRequiredPolicy = vi.fn(() => requiredPolicy); - - expect( - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: false, - recordedAuthorities: [], - prepareRequiredPolicy, - operation: "create sandbox 'demo'", - }, - { - inspectActiveGlobalPolicy: () => ({ state: "absent" }), - }, - ).authority, - ).toBe("nemoclaw-managed"); - expect(prepareRequiredPolicy).not.toHaveBeenCalled(); - }); - - it("refuses a recorded managed owner without a durable gateway port (#9833)", () => { - const recorded = { - ...managedSandboxEntry("demo"), - gatewayPort: undefined, - } as unknown as SandboxEntry; - const assertGatewayBinding = vi.fn(); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - recordedSandbox: recorded, - prepareRequiredPolicy: () => requiredPolicy, - operation: "reuse sandbox 'demo'", - }, - { - inspectSandboxPolicyAuthority: managedPolicyInspection, - assertOpenShellGatewayPortBinding: assertGatewayBinding, - }, - ), - ).toThrow(/ownership is not durably verified/u); - expect(assertGatewayBinding).not.toHaveBeenCalled(); - }); - - it("refuses when the recorded route becomes pending during live verification (#9833)", () => { - const recorded = managedSandboxEntry("demo"); - - expect(() => - qualifySandboxPolicyAuthority( - { - sandboxName: "demo", - gatewayName: "nemoclaw", - liveExists: true, - recordedAuthorities: ["nemoclaw-managed"], - recordedSandbox: recorded, - readRecordedSandbox: () => ({ - ...recorded, - pendingRouteReservation: true, - }), - prepareRequiredPolicy: () => requiredPolicy, - operation: "reuse sandbox 'demo'", - }, - { - inspectSandboxPolicyAuthority: managedPolicyInspection, - inspectOpenShellSandboxIdentityFingerprint: () => SANDBOX_IDENTITY, - assertOpenShellGatewayPortBinding: vi.fn(), - }, - ), - ).toThrow(/recorded sandbox policy boundary changed/u); - }); -}); diff --git a/src/lib/onboard/policy-authority/preflight.ts b/src/lib/onboard/policy-authority/preflight.ts deleted file mode 100644 index 48bcac367e4..00000000000 --- a/src/lib/onboard/policy-authority/preflight.ts +++ /dev/null @@ -1,491 +0,0 @@ -// 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 { isDeepStrictEqual } from "node:util"; - -import type { AgentDefinition } from "../../agent/defs"; -import { - assertExternalPolicyRequirements, - assertObservedPolicyRequirements, - assertOpenShellGatewayPortBinding, - assertRecordedPolicyAuthority, - inspectActiveGlobalPolicy, - inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, - PolicyAuthorityRefusalError, - type SandboxPolicyAuthority, - type SandboxPolicyAuthorityInspection, -} from "../../adapters/openshell/policy-authority"; -import { - assertNemoClawPolicyCreationReceiptMatches, - parseOpenShellPolicy, -} from "../../policy/merge"; -import type { SandboxEntry } from "../../state/registry"; -import { type InitialSandboxPolicy, prepareInitialSandboxCreatePolicy } from "../initial-policy"; -import { requiredObservabilityPolicyPresets } from "../observability-policy-presets"; -import { type WebSearchConfig, webSearchProviderForConfig } from "../policy-presets"; -import { getDefaultSandboxNameForAgent } from "../sandbox-agent"; - -const { LOCAL_INFERENCE_POLICY_PROVIDERS } = require("../providers") as { - LOCAL_INFERENCE_POLICY_PROVIDERS: string[]; -}; - -type PolicyAuthorityInspectionDeps = { - readonly inspectActiveGlobalPolicy?: typeof inspectActiveGlobalPolicy; - readonly inspectOpenShellSandboxIdentityFingerprint?: typeof inspectOpenShellSandboxIdentityFingerprint; - readonly assertOpenShellGatewayPortBinding?: typeof assertOpenShellGatewayPortBinding; - readonly inspectSandboxPolicyAuthority?: typeof inspectSandboxPolicyAuthority; -}; - -type RecordedPolicyAuthority = Exclude; - -export type QualifiedSandboxPolicyAuthority = - | { readonly authority: "nemoclaw-managed" } - | { - readonly authority: "externally-managed"; - readonly inspection: SandboxPolicyAuthorityInspection; - }; - -/** Bind the global policy authority before provider selection can mutate gateway state. */ -export function qualifyGlobalPolicyAuthority( - input: { - readonly gatewayName: string; - readonly recordedAuthority?: SandboxPolicyAuthority | null; - readonly operation: string; - }, - deps: Pick = {}, -): QualifiedSandboxPolicyAuthority { - const presence = (deps.inspectActiveGlobalPolicy ?? inspectActiveGlobalPolicy)({ - gatewayName: input.gatewayName, - }); - const authority: RecordedPolicyAuthority = - presence.state === "active" ? "externally-managed" : "nemoclaw-managed"; - if (input.recordedAuthority) { - assertRecordedPolicyAuthority(input.recordedAuthority, authority, input.operation); - } - return presence.state === "active" - ? { authority: "externally-managed", inspection: presence.inspection } - : { authority: "nemoclaw-managed" }; -} - -function parseRequiredPolicy(content: string, operation: string): Record { - try { - return parseOpenShellPolicy(content).policy; - } catch { - throw new Error(`Refusing to ${operation}: the required sandbox policy is invalid.`); - } -} - -function readInitialPolicy(policy: InitialSandboxPolicy, operation: string): string { - if (policy.sourceBytes) return policy.sourceBytes.toString("utf8"); - try { - return fs.readFileSync(policy.policyPath, "utf8"); - } catch { - throw new Error(`Refusing to ${operation}: the required sandbox policy is unreadable.`); - } -} - -function cleanupRequirement(policy: InitialSandboxPolicy, operation: string): void { - if (policy.cleanup && policy.cleanup() !== true) { - throw new Error( - `Temporary sandbox policy cleanup failed while trying to ${operation}. Inspect and remove the temporary sandbox policy before retrying.`, - ); - } -} - -function attachCleanupFailure(primaryError: unknown, cleanupError: unknown): Error { - const primaryMessage = - primaryError instanceof Error ? primaryError.message : "Policy authority validation failed."; - const cleanupMessage = - cleanupError instanceof Error - ? cleanupError.message - : "Temporary sandbox policy cleanup failed. Inspect and remove the temporary sandbox policy before retrying."; - const cause = new AggregateError( - [primaryError, cleanupError], - "Policy authority validation and temporary policy cleanup both failed.", - ); - const message = `${primaryMessage} ${cleanupMessage}`; - if (primaryError instanceof PolicyAuthorityRefusalError) { - return new PolicyAuthorityRefusalError(message, primaryError.observedAuthority, { cause }); - } - return new Error(message, { cause }); -} - -/** Resolve and verify policy authority before sandbox lifecycle effects. */ -export function qualifySandboxPolicyAuthority( - input: { - readonly sandboxName: string; - readonly gatewayName: string; - readonly liveExists: boolean; - readonly recordedAuthorities: readonly (SandboxPolicyAuthority | null | undefined)[]; - readonly recordedSandbox?: SandboxEntry | null; - readonly readRecordedSandbox?: (sandboxName: string) => SandboxEntry | null; - readonly currentSessionId?: string | null; - readonly prepareRequiredPolicy: () => InitialSandboxPolicy; - readonly operation: string; - }, - deps: PolicyAuthorityInspectionDeps = {}, -): QualifiedSandboxPolicyAuthority { - const sandboxInspection = input.liveExists - ? (deps.inspectSandboxPolicyAuthority ?? inspectSandboxPolicyAuthority)({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }) - : null; - let inspection: QualifiedSandboxPolicyAuthority; - if (!sandboxInspection) { - inspection = qualifyGlobalPolicyAuthority( - { gatewayName: input.gatewayName, operation: input.operation }, - deps, - ); - } else if (sandboxInspection.authority === "externally-managed") { - inspection = { authority: "externally-managed", inspection: sandboxInspection }; - } else if (sandboxInspection.authority === "owner-unknown") { - inspection = qualifyRecordedSandboxPolicyAuthority( - { - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - recordedSandbox: input.recordedSandbox ?? null, - readRecordedSandbox: input.readRecordedSandbox, - currentSessionId: input.currentSessionId, - inspection: sandboxInspection, - operation: input.operation, - }, - deps, - ); - } else { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the observed sandbox policy authority is invalid.`, - ); - } - - for (const recorded of input.recordedAuthorities) { - if (recorded) { - assertRecordedPolicyAuthority(recorded, inspection.authority, input.operation); - } - } - if (inspection.authority !== "externally-managed") return inspection; - - const requiredPolicy = input.prepareRequiredPolicy(); - let primaryError: unknown; - try { - const parsedPolicy = parseRequiredPolicy( - readInitialPolicy(requiredPolicy, input.operation), - input.operation, - ); - const observed = inspection.inspection; - const assertRequirements = - observed.authority === "owner-unknown" - ? assertObservedPolicyRequirements - : assertExternalPolicyRequirements; - assertRequirements({ - inspection: observed, - requiredPolicy: parsedPolicy, - operation: input.operation, - sandboxName: input.sandboxName, - }); - } catch (error) { - primaryError = error; - } - let cleanupError: unknown; - try { - cleanupRequirement(requiredPolicy, input.operation); - } catch (error) { - cleanupError = error; - } - if (primaryError !== undefined) { - if (cleanupError !== undefined) { - throw attachCleanupFailure(primaryError, cleanupError); - } - throw primaryError; - } - if (cleanupError !== undefined) throw cleanupError; - return inspection; -} - -function qualifyRecordedSandboxPolicyAuthority( - input: { - readonly sandboxName: string; - readonly gatewayName: string; - readonly recordedSandbox: SandboxEntry | null; - readonly readRecordedSandbox?: (sandboxName: string) => SandboxEntry | null; - readonly currentSessionId?: string | null; - readonly inspection: SandboxPolicyAuthorityInspection; - readonly operation: string; - }, - deps: PolicyAuthorityInspectionDeps, -): QualifiedSandboxPolicyAuthority { - const recorded = input.recordedSandbox; - const gatewayPort = recorded?.gatewayPort; - const pendingReservationIsCurrent = - recorded?.pendingRouteReservation !== true || - (recorded.pendingPolicyVerification === undefined && - typeof input.currentSessionId === "string" && - input.currentSessionId.length > 0 && - recorded.reservationSessionId === input.currentSessionId); - if ( - !recorded?.policyAuthority || - !pendingReservationIsCurrent || - recorded.gatewayName !== input.gatewayName || - typeof gatewayPort !== "number" || - !Number.isSafeInteger(gatewayPort) || - typeof recorded.lifecycleGeneration !== "string" || - typeof recorded.lifecycleLiveIdentityFingerprint !== "string" - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: sandbox-scoped policy ownership is not durably verified.`, - "owner-unknown", - ); - } - const inspectIdentity = - deps.inspectOpenShellSandboxIdentityFingerprint ?? inspectOpenShellSandboxIdentityFingerprint; - (deps.assertOpenShellGatewayPortBinding ?? assertOpenShellGatewayPortBinding)({ - gatewayName: input.gatewayName, - gatewayPort, - }); - const beforeIdentity = inspectIdentity({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - if (beforeIdentity !== recorded.lifecycleLiveIdentityFingerprint) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the live sandbox identity does not match the recorded lifecycle.`, - "owner-unknown", - ); - } - const confirmedInspection = (deps.inspectSandboxPolicyAuthority ?? inspectSandboxPolicyAuthority)( - { sandboxName: input.sandboxName, gatewayName: input.gatewayName }, - ); - const afterIdentity = inspectIdentity({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - if ( - beforeIdentity !== afterIdentity || - confirmedInspection.authority !== "owner-unknown" || - confirmedInspection.policyIdentity.hash !== input.inspection.policyIdentity.hash || - confirmedInspection.policyIdentity.activeVersion !== - input.inspection.policyIdentity.activeVersion - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the sandbox or policy identity changed during verification.`, - "owner-unknown", - ); - } - if (recorded.policyAuthority === "nemoclaw-managed") { - try { - assertNemoClawPolicyCreationReceiptMatches(recorded.policyCreationReceipt, { - origin: "sandbox-create", - gatewayName: input.gatewayName, - gatewayPort, - sandboxName: input.sandboxName, - lifecycleGeneration: recorded.lifecycleGeneration, - sandboxIdentityFingerprint: afterIdentity, - policyHash: confirmedInspection.policyIdentity.hash, - policyVersion: confirmedInspection.policyIdentity.activeVersion, - }); - } catch { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the NemoClaw policy creation receipt does not match the live sandbox policy.`, - "owner-unknown", - ); - } - } - const confirmedRecorded = input.readRecordedSandbox - ? input.readRecordedSandbox(input.sandboxName) - : recorded; - if ( - !confirmedRecorded || - confirmedRecorded.pendingRouteReservation !== recorded.pendingRouteReservation || - confirmedRecorded.reservationSessionId !== recorded.reservationSessionId || - confirmedRecorded.pendingPolicyVerification !== undefined || - confirmedRecorded.policyAuthority !== recorded.policyAuthority || - !isDeepStrictEqual(confirmedRecorded.policyCreationReceipt, recorded.policyCreationReceipt) || - confirmedRecorded.lifecycleGeneration !== recorded.lifecycleGeneration || - confirmedRecorded.lifecycleLiveIdentityFingerprint !== - recorded.lifecycleLiveIdentityFingerprint || - confirmedRecorded.gatewayName !== recorded.gatewayName || - confirmedRecorded.gatewayPort !== recorded.gatewayPort - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the recorded sandbox policy boundary changed during live verification.`, - "owner-unknown", - ); - } - if (recorded.policyAuthority === "nemoclaw-managed") { - return { authority: "nemoclaw-managed" }; - } - return { - authority: "externally-managed", - inspection: confirmedInspection, - }; -} - -type ProviderPolicyRequirements = { - readonly gatewayName: string; - readonly sandboxName: string | null; - readonly agent: AgentDefinition | null; - readonly selectedMessagingChannels: readonly string[]; - readonly hermesToolGateways: readonly string[]; - readonly gpuPassthrough: boolean; - readonly provider: string | null; - readonly hostLocalInferenceRouteOnly?: boolean; - readonly webSearchConfig: WebSearchConfig | null; - readonly observabilityEnabled: boolean; - readonly operation: string; -}; - -type RevalidatedPolicyContext = Omit< - ProviderPolicyRequirements, - "agent" | "gatewayName" | "observabilityEnabled" | "operation" -> & { - readonly agent: AgentDefinition | null; - readonly session: { readonly observabilityEnabled?: boolean | null } | null; -}; - -/** Include every selected feature that adds a network policy requirement. */ -export function requiredOnboardPolicyPresets(input: { - readonly additionalPresets: readonly string[]; - readonly provider: string | null; - readonly webSearchConfig: WebSearchConfig | null; - readonly agentName: string | null | undefined; - readonly observabilityEnabled: boolean; - readonly hostLocalInferenceRouteOnly?: boolean; -}): string[] { - const required = new Set(input.additionalPresets); - if ( - input.provider && - !input.hostLocalInferenceRouteOnly && - LOCAL_INFERENCE_POLICY_PROVIDERS.includes(input.provider) - ) { - required.add("local-inference"); - } - if (input.webSearchConfig) { - required.add(webSearchProviderForConfig(input.webSearchConfig)); - } - for (const preset of requiredObservabilityPolicyPresets( - input.agentName, - input.observabilityEnabled, - )) { - required.add(preset); - } - return [...required]; -} - -/** Keep gateway and provider authority checks out of the onboarding entry point. */ -type PolicyAuthoritySession = { - sessionId?: string | null; - policyAuthority?: SandboxPolicyAuthority | null; - policyPresets?: string[] | null; -}; - -export function createOnboardPolicyAuthorityBindings( - runtime: { - readonly GATEWAY_NAME: string; - readonly ROOT: string; - readonly agentDefs: { - readonly loadAgent: (name: string) => AgentDefinition; - }; - readonly agentOnboard: { - readonly getAgentPolicyPath: (agent: AgentDefinition) => string | null; - }; - readonly inspectSandboxForCreate: (sandboxName: string) => { - readonly existingEntry: SandboxEntry | null; - readonly liveExists: boolean; - }; - readonly onboardSession: { - loadSession(): Session | null; - updateSession(mutator: (session: Session) => void): Session | Promise; - }; - }, - policyTier: string | null | undefined, - inspectionDeps: PolicyAuthorityInspectionDeps = {}, -): { - readonly bindPolicyAuthority: (gatewayName: string, session: Session | null) => Promise; - readonly preflightPolicyRequirements: (requirements: ProviderPolicyRequirements) => void; - readonly revalidatePolicyRequirements: ( - context: RevalidatedPolicyContext, - operation: string, - ) => void; -} { - const preflightPolicyRequirements = (requirements: ProviderPolicyRequirements): void => { - const agent = requirements.agent ?? runtime.agentDefs.loadAgent("openclaw"); - const sandboxName = requirements.sandboxName ?? getDefaultSandboxNameForAgent(agent); - const observed = runtime.inspectSandboxForCreate(sandboxName); - const currentSession = runtime.onboardSession.loadSession(); - qualifySandboxPolicyAuthority( - { - sandboxName, - gatewayName: requirements.gatewayName, - liveExists: observed.liveExists, - recordedAuthorities: [ - observed.existingEntry?.policyAuthority, - currentSession?.policyAuthority, - ], - recordedSandbox: observed.existingEntry, - readRecordedSandbox: (name) => runtime.inspectSandboxForCreate(name).existingEntry, - currentSessionId: currentSession?.sessionId, - operation: requirements.operation, - prepareRequiredPolicy: () => - prepareInitialSandboxCreatePolicy( - runtime.agentOnboard.getAgentPolicyPath(agent) ?? - path.join(runtime.ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), - [...requirements.selectedMessagingChannels], - { - directGpu: requirements.gpuPassthrough, - additionalPresets: requiredOnboardPolicyPresets({ - additionalPresets: requirements.hermesToolGateways, - provider: requirements.provider, - hostLocalInferenceRouteOnly: requirements.hostLocalInferenceRouteOnly, - webSearchConfig: requirements.webSearchConfig, - agentName: agent.name, - observabilityEnabled: requirements.observabilityEnabled, - }), - agentName: agent.name, - // Channel presets bind `{sandboxName}--bridge`; without - // the name, composing them throws. - sandboxName, - policyTier: observed.existingEntry?.policyTier ?? policyTier, - baselineExclusions: observed.existingEntry?.baselineExclusions ?? [], - }, - ), - }, - inspectionDeps, - ); - }; - return { - async bindPolicyAuthority(gatewayName, session) { - const inspection = qualifyGlobalPolicyAuthority( - { - gatewayName, - recordedAuthority: session?.policyAuthority, - operation: "continue onboarding after gateway setup", - }, - inspectionDeps, - ); - return runtime.onboardSession.updateSession((current) => { - current.policyAuthority = - inspection.authority === "externally-managed" ? "externally-managed" : null; - if (inspection.authority === "externally-managed") current.policyPresets = null; - }); - }, - preflightPolicyRequirements, - revalidatePolicyRequirements(context, operation) { - preflightPolicyRequirements({ - gatewayName: runtime.GATEWAY_NAME, - sandboxName: context.sandboxName, - agent: context.agent ?? runtime.agentDefs.loadAgent("openclaw"), - selectedMessagingChannels: context.selectedMessagingChannels, - hermesToolGateways: context.hermesToolGateways, - gpuPassthrough: context.gpuPassthrough, - provider: context.provider, - hostLocalInferenceRouteOnly: context.hostLocalInferenceRouteOnly, - webSearchConfig: context.webSearchConfig, - observabilityEnabled: context.session?.observabilityEnabled === true, - operation, - }); - }, - }; -} diff --git a/src/lib/onboard/policy-carryforward.test.ts b/src/lib/onboard/policy-carryforward.test.ts deleted file mode 100644 index 0681e592a8c..00000000000 --- a/src/lib/onboard/policy-carryforward.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - decidePolicyCarryForward, - decideReusePolicyPresets, - shouldCarryPreviousPolicies, -} from "./policy-carryforward"; - -describe("shouldCarryPreviousPolicies (#2675)", () => { - it("drops previous policies when NEMOCLAW_POLICY_PRESETS overrides on recreate", () => { - expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true)).toBe( - false, - ); - }); - - it("ignores env var in interactive mode (previous list still wins)", () => { - expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, false)).toBe( - true, - ); - }); - - it("drops previous policies when NEMOCLAW_POLICY_MODE=skip", () => { - expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_MODE: "skip" }, true)).toBe( - false, - ); - }); - - it("drops previous policies when NEMOCLAW_POLICY_MODE=custom forces explicit selection", () => { - expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_MODE: "custom" }, true)).toBe( - false, - ); - }); - - it("carries previous policies when NEMOCLAW_POLICY_MODE=suggested (implicit)", () => { - expect(shouldCarryPreviousPolicies(["npm"], { NEMOCLAW_POLICY_MODE: "suggested" }, true)).toBe( - true, - ); - }); -}); - -describe("decidePolicyCarryForward (#2675)", () => { - it("emits NEMOCLAW_POLICY_PRESETS override note when env clears previous presets", () => { - const decision = decidePolicyCarryForward(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true); - expect(decision.newPresets).toBeNull(); - expect(decision.overrideNote).toContain("NEMOCLAW_POLICY_PRESETS overrides previous presets"); - expect(decision.overrideNote).toContain("was: npm"); - }); - - it("emits NEMOCLAW_POLICY_MODE override note when mode forces clearing", () => { - const decision = decidePolicyCarryForward(["npm"], { NEMOCLAW_POLICY_MODE: "skip" }, true); - expect(decision.newPresets).toBeNull(); - expect(decision.overrideNote).toContain("NEMOCLAW_POLICY_MODE=skip"); - expect(decision.overrideNote).toContain("was: npm"); - }); - - it("carries presets forward in interactive mode even when env vars are set", () => { - const decision = decidePolicyCarryForward(["npm"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, false); - expect(decision.newPresets).toEqual(["npm"]); - expect(decision.overrideNote).toBeNull(); - }); - - it("clears without note when there are no previous policies to override", () => { - const decision = decidePolicyCarryForward([], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true); - expect(decision.newPresets).toBeNull(); - expect(decision.overrideNote).toBeNull(); - }); - - it("carries forward without note when no env override is set", () => { - const decision = decidePolicyCarryForward(["npm"], {}, true); - expect(decision.newPresets).toEqual(["npm"]); - expect(decision.overrideNote).toBeNull(); - }); -}); - -describe("decideReusePolicyPresets (#4621)", () => { - it("carries the recorded selection forward on reuse", () => { - expect(decideReusePolicyPresets(["dns", "github"], {}, false)).toEqual(["dns", "github"]); - }); - - it("preserves an intentionally-empty selection (Restricted tier) as []", () => { - // decidePolicyCarryForward would collapse this to null and re-prompt Balanced. - expect(decideReusePolicyPresets([], {}, false)).toEqual([]); - expect(decideReusePolicyPresets([], {}, true)).toEqual([]); - }); - - it("returns null when there is no recorded policy state", () => { - expect(decideReusePolicyPresets(null, {}, false)).toBeNull(); - expect(decideReusePolicyPresets(undefined, {}, true)).toBeNull(); - }); - - it("preserves custom preset names alongside built-ins", () => { - expect(decideReusePolicyPresets(["github", "my-custom"], {}, false)).toEqual([ - "github", - "my-custom", - ]); - }); - - it("defers to a non-interactive NEMOCLAW_POLICY_PRESETS override", () => { - expect(decideReusePolicyPresets(["dns"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true)).toBeNull(); - expect(decideReusePolicyPresets([], { NEMOCLAW_POLICY_PRESETS: "pypi" }, true)).toBeNull(); - }); - - it("defers to a non-interactive explicit NEMOCLAW_POLICY_MODE override", () => { - expect(decideReusePolicyPresets(["dns"], { NEMOCLAW_POLICY_MODE: "skip" }, true)).toBeNull(); - expect(decideReusePolicyPresets(["dns"], { NEMOCLAW_POLICY_MODE: "custom" }, true)).toBeNull(); - }); - - it("ignores env overrides in interactive mode (recorded selection wins)", () => { - expect(decideReusePolicyPresets(["dns"], { NEMOCLAW_POLICY_PRESETS: "pypi" }, false)).toEqual([ - "dns", - ]); - }); - - it("carries the recorded selection under implicit non-interactive modes", () => { - expect(decideReusePolicyPresets(["dns"], { NEMOCLAW_POLICY_MODE: "suggested" }, true)).toEqual([ - "dns", - ]); - }); -}); diff --git a/src/lib/onboard/policy-carryforward.ts b/src/lib/onboard/policy-carryforward.ts deleted file mode 100644 index 3113902abc5..00000000000 --- a/src/lib/onboard/policy-carryforward.ts +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Decides whether `nemoclaw onboard --recreate-sandbox` should carry the -// previous sandbox's policy presets forward into the new session, or honour -// a `NEMOCLAW_POLICY_PRESETS` / `NEMOCLAW_POLICY_MODE` environment override. -// See #2675. -// -// "suggested"/"default"/"auto" are intentionally absent from EXPLICIT_POLICY_MODES: -// they map to the implicit carry-forward semantic, equivalent to leaving -// NEMOCLAW_POLICY_MODE unset. -export const EXPLICIT_POLICY_MODES = ["skip", "none", "no", "custom", "list"]; - -export type PolicyEnv = { - NEMOCLAW_POLICY_PRESETS?: string; - NEMOCLAW_POLICY_MODE?: string; -}; - -export function shouldCarryPreviousPolicies( - previousPolicies: string[] | null | undefined, - env: PolicyEnv, - nonInteractive: boolean, -): boolean { - if (!Array.isArray(previousPolicies) || previousPolicies.length === 0) return false; - if (!nonInteractive) return true; - if ((env.NEMOCLAW_POLICY_PRESETS ?? "").trim().length > 0) return false; - const mode = (env.NEMOCLAW_POLICY_MODE ?? "").trim().toLowerCase(); - if (EXPLICIT_POLICY_MODES.includes(mode)) return false; - return true; -} - -export type PolicyCarryForwardDecision = { - // The value to assign to session.policyPresets: `previousPolicies` when the - // recreate path carries them forward, otherwise `null` to clear the slot. - newPresets: string[] | null; - // Human-readable note explaining that an env override is replacing the - // recorded presets. Null when no note is warranted. - overrideNote: string | null; -}; - -export function decidePolicyCarryForward( - previousPolicies: string[] | null | undefined, - env: PolicyEnv, - nonInteractive: boolean, -): PolicyCarryForwardDecision { - const prev = Array.isArray(previousPolicies) ? previousPolicies : null; - if (shouldCarryPreviousPolicies(prev, env, nonInteractive)) { - return { newPresets: prev, overrideNote: null }; - } - if (!prev || prev.length === 0 || !nonInteractive) - return { newPresets: null, overrideNote: null }; - const wasList = prev.join(", "); - if ((env.NEMOCLAW_POLICY_PRESETS ?? "").trim().length > 0) { - return { - newPresets: null, - overrideNote: ` [non-interactive] NEMOCLAW_POLICY_PRESETS overrides previous presets on recreate (was: ${wasList}).`, - }; - } - const mode = (env.NEMOCLAW_POLICY_MODE ?? "").trim().toLowerCase(); - if (EXPLICIT_POLICY_MODES.includes(mode)) { - return { - newPresets: null, - overrideNote: ` [non-interactive] NEMOCLAW_POLICY_MODE=${mode} overrides previous presets on recreate (was: ${wasList}).`, - }; - } - return { newPresets: null, overrideNote: null }; -} - -// True when a non-interactive run sets an env override that should replace the -// sandbox's recorded policy selection (NEMOCLAW_POLICY_PRESETS, or an explicit -// NEMOCLAW_POLICY_MODE). In interactive mode the recorded selection always wins. -function envOverridesRecordedPolicies(env: PolicyEnv, nonInteractive: boolean): boolean { - if (!nonInteractive) return false; - if ((env.NEMOCLAW_POLICY_PRESETS ?? "").trim().length > 0) return true; - const mode = (env.NEMOCLAW_POLICY_MODE ?? "").trim().toLowerCase(); - return EXPLICIT_POLICY_MODES.includes(mode); -} - -// Decide the policy presets to seed into a *reused* sandbox's fresh onboard -// session (`nemoclaw onboard --name ` without --recreate-sandbox). -// -// Unlike decidePolicyCarryForward (the recreate path), this preserves an -// intentionally-empty recorded selection (e.g. the Restricted tier, or all tier -// presets deselected): an empty array is carried forward as `[]` so the policy -// step reapplies "no presets" instead of falling back to the default Balanced -// tier and re-adding presets the operator removed. `null`/absent recorded state -// still yields `null` (let the policy step prompt). A non-interactive env -// override (NEMOCLAW_POLICY_PRESETS / NEMOCLAW_POLICY_MODE) still wins and -// returns `null` so the override drives the selection. See #4621. -export function decideReusePolicyPresets( - recordedAppliedPresets: string[] | null | undefined, - env: PolicyEnv, - nonInteractive: boolean, -): string[] | null { - if (!Array.isArray(recordedAppliedPresets)) return null; - if (envOverridesRecordedPolicies(env, nonInteractive)) return null; - return recordedAppliedPresets; -} diff --git a/src/lib/onboard/policy-preset-persistence.test.ts b/src/lib/onboard/policy-preset-persistence.test.ts deleted file mode 100644 index 8e2e82df684..00000000000 --- a/src/lib/onboard/policy-preset-persistence.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -// 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 * as onboardSession from "../state/onboard-session"; -import * as registry from "../state/registry"; -import { - applyRecreatePolicyCarryForward, - buildFinalizedPolicyPresetsUpdate, - persistFinalizedPolicyPresets, - resolveRecreatePolicyPresets, - seedReusedSandboxPolicyPresets, -} from "./policy-preset-persistence"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function readSeededPresets( - updateSession: ReturnType, -): string[] | null | undefined { - const session = { policyPresets: undefined } as { policyPresets: string[] | null | undefined }; - (updateSession.mock.calls[0][0] as (s: typeof session) => unknown)(session); - return session.policyPresets; -} - -describe("buildFinalizedPolicyPresetsUpdate (#4621)", () => { - it("keeps only built-in preset names and stamps the finalized marker", () => { - expect( - buildFinalizedPolicyPresetsUpdate(["github", "my-custom", "npm"], ["github", "npm", "dns"]), - ).toEqual({ policies: ["github", "npm"], policyPresetsFinalized: true }); - }); - - it("records an intentionally-empty selection as []", () => { - expect(buildFinalizedPolicyPresetsUpdate([], ["github", "npm"])).toEqual({ - policies: [], - policyPresetsFinalized: true, - }); - }); - - it("excludes a custom preset whose name collides with a built-in", () => { - // A custom `brave` must not be written into the built-in `policies` list. - expect( - buildFinalizedPolicyPresetsUpdate(["github", "brave"], ["github", "brave"], ["brave"]), - ).toEqual({ policies: ["github"], policyPresetsFinalized: true }); - }); -}); - -describe("resolveRecreatePolicyPresets (#4621)", () => { - it("carries a finalized non-empty selection forward", () => { - expect(resolveRecreatePolicyPresets(["github"], true, false, {}, true)).toEqual({ - policyPresets: ["github"], - overrideNote: null, - }); - }); - - it("honors a finalized empty selection instead of falling back to a tier", () => { - expect(resolveRecreatePolicyPresets([], true, false, {}, true)).toEqual({ - policyPresets: [], - overrideNote: null, - }); - }); - - it("does not honor an empty built-in list when custom presets were recorded", () => { - // Recreate discards custom-preset content, so fall back to the prompt rather - // than silently seeding [] and skipping the selector. - expect(resolveRecreatePolicyPresets([], true, true, {}, true)).toEqual({ - policyPresets: null, - overrideNote: null, - }); - }); - - it("treats a non-finalized empty list as no recorded selection", () => { - // Boot-time-only presets from an interrupted run must not be carried. - expect(resolveRecreatePolicyPresets([], false, false, {}, true)).toEqual({ - policyPresets: null, - overrideNote: null, - }); - }); - - it("defers to an env override even for a finalized empty selection", () => { - const result = resolveRecreatePolicyPresets( - [], - true, - false, - { NEMOCLAW_POLICY_MODE: "skip" }, - true, - ); - expect(result.policyPresets).toBeNull(); - }); - - it("surfaces the override note for a non-empty selection replaced by env", () => { - const result = resolveRecreatePolicyPresets( - ["npm"], - true, - false, - { NEMOCLAW_POLICY_PRESETS: "pypi" }, - true, - ); - expect(result.policyPresets).toBeNull(); - expect(result.overrideNote).toContain("NEMOCLAW_POLICY_PRESETS overrides previous presets"); - }); -}); - -describe("seedReusedSandboxPolicyPresets (#4621)", () => { - it("seeds the full recorded applied set when the policy step was finalized", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policyPresetsFinalized: true, - } as ReturnType); - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ policyPresets: undefined } as never); - const updateSession = vi - .spyOn(onboardSession, "updateSession") - .mockReturnValue(undefined as never); - const getAppliedPresets = vi.fn(() => ["github", "my-custom"]); - - seedReusedSandboxPolicyPresets("sb", false, getAppliedPresets); - - expect(getAppliedPresets).toHaveBeenCalledWith("sb"); - expect(updateSession).toHaveBeenCalledTimes(1); - expect(readSeededPresets(updateSession)).toEqual(["github", "my-custom"]); - }); - - it("does not seed when the prior policy step was not finalized", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "sb" } as ReturnType< - typeof registry.getSandbox - >); - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ policyPresets: undefined } as never); - const updateSession = vi.spyOn(onboardSession, "updateSession"); - const getAppliedPresets = vi.fn(() => ["github"]); - - seedReusedSandboxPolicyPresets("sb", false, getAppliedPresets); - - expect(updateSession).not.toHaveBeenCalled(); - expect(getAppliedPresets).not.toHaveBeenCalled(); - }); - - it("does not clobber an in-progress --resume session", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policyPresetsFinalized: true, - } as ReturnType); - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ policyPresets: ["dns"] } as never); - const updateSession = vi.spyOn(onboardSession, "updateSession"); - - seedReusedSandboxPolicyPresets( - "sb", - false, - vi.fn(() => ["github"]), - ); - - expect(updateSession).not.toHaveBeenCalled(); - }); -}); - -describe("applyRecreatePolicyCarryForward (#4621)", () => { - it("seeds the carried selection and prints no note when none is warranted", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policies: ["github"], - policyPresetsFinalized: true, - } as ReturnType); - const updateSession = vi - .spyOn(onboardSession, "updateSession") - .mockReturnValue(undefined as never); - const note = vi.fn(); - - applyRecreatePolicyCarryForward("sb", true, note); - - expect(readSeededPresets(updateSession)).toEqual(["github"]); - expect(note).not.toHaveBeenCalled(); - }); - - it("keeps the matching recreate journal selection instead of stale source presets", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policies: ["github", "mcp-bridge-fake"], - policyPresetsFinalized: true, - } as ReturnType); - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ - policyPresets: ["github"], - checkpoint: { sandboxRecreate: { sandboxName: "sb" } }, - } as never); - const updateSession = vi - .spyOn(onboardSession, "updateSession") - .mockReturnValue(undefined as never); - const note = vi.fn(); - - applyRecreatePolicyCarryForward("sb", true, note); - - expect(readSeededPresets(updateSession)).toEqual(["github"]); - expect(note).not.toHaveBeenCalled(); - }); - - it("uses an explicit rebuild selection instead of stale source presets", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policies: ["github", "mcp-bridge-fake"], - policyPresetsFinalized: true, - } as ReturnType); - vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); - const updateSession = vi - .spyOn(onboardSession, "updateSession") - .mockReturnValue(undefined as never); - const note = vi.fn(); - process.env.NEMOCLAW_POLICY_PRESETS = "pypi"; - - try { - applyRecreatePolicyCarryForward("sb", true, note, ["github"]); - } finally { - delete process.env.NEMOCLAW_POLICY_PRESETS; - } - - expect(readSeededPresets(updateSession)).toEqual(["github"]); - expect(note).not.toHaveBeenCalled(); - }); - - it("does not carry a different sandbox journal selection across targets", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policies: ["github"], - policyPresetsFinalized: true, - } as ReturnType); - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ - policyPresets: ["npm"], - checkpoint: { sandboxRecreate: { sandboxName: "other" } }, - } as never); - const updateSession = vi - .spyOn(onboardSession, "updateSession") - .mockReturnValue(undefined as never); - - applyRecreatePolicyCarryForward("sb", true, vi.fn()); - - expect(readSeededPresets(updateSession)).toEqual(["github"]); - }); - - it("prints the override note when an env override clears the selection", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "sb", - policies: ["npm"], - policyPresetsFinalized: true, - } as ReturnType); - vi.spyOn(onboardSession, "updateSession").mockReturnValue(undefined as never); - const note = vi.fn(); - process.env.NEMOCLAW_POLICY_PRESETS = "pypi"; - try { - applyRecreatePolicyCarryForward("sb", true, note); - } finally { - delete process.env.NEMOCLAW_POLICY_PRESETS; - } - - expect(note).toHaveBeenCalledWith( - expect.stringContaining("NEMOCLAW_POLICY_PRESETS overrides previous presets"), - ); - }); -}); - -describe("persistFinalizedPolicyPresets (#4621)", () => { - it("writes built-in presets only plus the finalized marker", () => { - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - const updateSandbox = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); - - persistFinalizedPolicyPresets("sb", ["github", "my-custom"], () => ["github", "npm"]); - - expect(updateSandbox).toHaveBeenCalledWith("sb", { - policies: ["github"], - policyPresetsFinalized: true, - }); - }); - - it("keeps a name-colliding custom preset out of the built-in policies list", () => { - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([ - { name: "brave", content: "" }, - ] as ReturnType); - const updateSandbox = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); - - persistFinalizedPolicyPresets("sb", ["github", "brave"], () => ["github", "brave"]); - - expect(updateSandbox).toHaveBeenCalledWith("sb", { - policies: ["github"], - policyPresetsFinalized: true, - }); - }); - - // #5967 was a registry-persistence regression: an enabled messaging channel's - // preset reached the live gateway but was dropped from the registry `policies` - // write, so `policy-list` (which reads registry.policies) rendered `○`. Using - // the REAL built-in preset catalog proves Discord and Slack are recognized as - // built-ins and are written back to the registry, not filtered out. - it("persists enabled messaging channel presets (Discord, Slack) to the registry (#5967)", () => { - // Model the registry as observable state and read it back through the same - // boundary policy-list uses (registry.getSandbox().policies) rather than - // inspecting updateSandbox's call shape. - const entry = { name: "sb", policies: ["npm"] } as Partial & { - policies: string[]; - policyPresetsFinalized?: boolean; - }; - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - vi.spyOn(registry, "getSandbox").mockImplementation((name) => - name === "sb" ? (entry as registry.SandboxEntry) : null, - ); - vi.spyOn(registry, "updateSandbox").mockImplementation((_name, fields) => { - Object.assign(entry, fields); - return true; - }); - - persistFinalizedPolicyPresets("sb", ["npm", "pypi", "discord", "slack"]); - - const stored = registry.getSandbox("sb"); - expect(stored?.policyPresetsFinalized).toBe(true); - // Discord and Slack survive the built-in filter and are stored where - // policy-list reads them — the #5967 registry-persistence guarantee. - expect([...(stored?.policies ?? [])].sort()).toEqual(["discord", "npm", "pypi", "slack"]); - }); -}); diff --git a/src/lib/onboard/policy-preset-persistence.ts b/src/lib/onboard/policy-preset-persistence.ts deleted file mode 100644 index 096ede46aad..00000000000 --- a/src/lib/onboard/policy-preset-persistence.ts +++ /dev/null @@ -1,200 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Keeps the sandbox registry's recorded policy selection in sync with the -// operator's *effective* choice across onboard, reuse, and recreate, and seeds -// a fresh re-onboard session from that recorded selection so the policy step -// preserves preset removals instead of silently reapplying tier defaults. -// See #4621. - -import type { Session } from "../state/onboard-session"; -import * as onboardSession from "../state/onboard-session"; -import * as registry from "../state/registry"; -import { - decidePolicyCarryForward, - decideReusePolicyPresets, - type PolicyEnv, -} from "./policy-carryforward"; - -// `../policy` pulls in the heavy runner stack at load time, so require it lazily -// inside the default accessors below. The policy-backed reads are injectable so -// the module (and its pure decision helpers) stays import-safe for unit tests. -function loadPolicyModule(): typeof import("../policy") { - return require("../policy"); -} - -function defaultGetAppliedPresets(sandboxName: string): string[] { - return loadPolicyModule().getAppliedPresets(sandboxName); -} - -function defaultListBuiltinPresetNames(): string[] { - return loadPolicyModule() - .listPresets() - .map((preset) => preset.name); -} - -/** - * Build the registry update for a *completed* policy step. - * - * `policies` is the built-in preset list only; sandbox-scoped custom presets - * are tracked separately in `customPolicies`. Filter the effective selection to - * built-in names, excluding any recorded custom-preset name (a custom preset - * may share a built-in's name, e.g. a custom `brave`), so a custom preset is - * not misclassified as a built-in — which would duplicate it in - * getAppliedPresets and mislead policy-remove / rebuild / status. - * `policyPresetsFinalized` records that the step fully reconciled the live set, - * so a later re-onboard can distinguish this from boot-time-only `policies` - * left behind by an interrupted run. - */ -export function buildFinalizedPolicyPresetsUpdate( - appliedPolicyPresets: string[], - builtinPresetNames: Iterable, - customPresetNames: Iterable = [], -): { policies: string[]; policyPresetsFinalized: true } { - const builtins = new Set(builtinPresetNames); - const custom = new Set(customPresetNames); - return { - policies: appliedPolicyPresets.filter((preset) => builtins.has(preset) && !custom.has(preset)), - policyPresetsFinalized: true, - }; -} - -/** - * Decide the presets to carry into a *recreated* sandbox's session. - * - * decidePolicyCarryForward collapses an empty previous list to `null`, after - * which the policy step falls back to the default tier — re-adding presets a - * Restricted-tier (or fully-deselected) operator intentionally removed. When - * the prior policy step was finalized with a genuinely empty selection, honor - * that empty set too, still deferring to an env override (decideReusePolicyPresets - * returns `null` for one). Only overrides the base decision when it carried - * nothing and printed no override note. - * - * `hadCustomPolicies` guards the empty-honor: an empty built-in list with - * recorded custom presets is not an "empty selection" — recreate discards the - * custom-preset content, so fall back to the prompt rather than silently seeding - * `[]` and skipping the selector. - */ -export function resolveRecreatePolicyPresets( - previousPolicies: string[] | null | undefined, - finalized: boolean, - hadCustomPolicies: boolean, - env: PolicyEnv, - nonInteractive: boolean, -): { policyPresets: string[] | null; overrideNote: string | null } { - const decision = decidePolicyCarryForward(previousPolicies, env, nonInteractive); - let policyPresets = decision.newPresets; - if ( - policyPresets === null && - decision.overrideNote === null && - finalized && - !hadCustomPolicies && - Array.isArray(previousPolicies) - ) { - policyPresets = decideReusePolicyPresets(previousPolicies, env, nonInteractive); - } - return { policyPresets, overrideNote: decision.overrideNote }; -} - -/** - * Reuse path (`nemoclaw onboard --name ` without --recreate-sandbox): - * seed the fresh onboard session's policy presets from the sandbox's recorded - * applied set so the policy step carries the operator's exact effective - * selection forward instead of re-prompting with raw tier defaults (which would - * silently reintroduce a removed Balanced default such as `npm`). - * - * Uses the full applied set (built-in `policies` plus custom-preset names) so a - * preserved custom preset is not diffed away as "deselected", and via - * decideReusePolicyPresets so an intentionally-empty selection (Restricted tier) - * is carried as `[]`. Gated on `policyPresetsFinalized` so boot-time-only state - * from an interrupted run is not mistaken for a final selection, and guarded so - * an in-progress --resume session is never clobbered. - */ -export function seedReusedSandboxPolicyPresets( - sandboxName: string, - nonInteractive: boolean, - getAppliedPresets: (sandboxName: string) => string[] = defaultGetAppliedPresets, -): void { - const priorPolicyStepCompleted = - registry.getSandbox(sandboxName)?.policyPresetsFinalized === true; - const session = onboardSession.loadSession(); - if (!priorPolicyStepCompleted || Array.isArray(session?.policyPresets)) return; - const policyPresets = decideReusePolicyPresets( - getAppliedPresets(sandboxName), - process.env, - nonInteractive, - ); - onboardSession.updateSession((current: Session) => { - current.policyPresets = policyPresets; - return current; - }); -} - -/** - * Recreate path: seed the session from the previous entry's recorded selection - * (carrying forward, or honoring a finalized empty set), then print any env - * override note. An explicit outer-rebuild selection is already normalized and - * cannot be replaced by the preserved source row or ambient environment. - * See resolveRecreatePolicyPresets. - */ -export function applyRecreatePolicyCarryForward( - sandboxName: string, - nonInteractive: boolean, - note: (message: string) => void, - rebuildPolicyPresets?: readonly string[], -): void { - const previousEntry = registry.getSandbox(sandboxName); - const session = onboardSession.loadSession(); - const journaledSessionPolicies = - session?.checkpoint?.sandboxRecreate?.sandboxName === sandboxName && - Array.isArray(session.policyPresets) - ? [...session.policyPresets] - : null; - const authoritativeRebuildPolicies = Array.isArray(rebuildPolicyPresets) - ? [...rebuildPolicyPresets] - : null; - // A matching recreate journal owns the replacement target. In particular, - // rebuild can remove generated MCP policies before deleting the old sandbox - // while retaining their registry metadata for crash recovery. Re-reading the - // preserved source row here would replace the already-normalized target with - // a generated policy name whose definition is intentionally absent. - const previousPolicies = - authoritativeRebuildPolicies ?? journaledSessionPolicies ?? previousEntry?.policies; - const { policyPresets, overrideNote } = resolveRecreatePolicyPresets( - previousPolicies, - authoritativeRebuildPolicies !== null || - journaledSessionPolicies !== null || - previousEntry?.policyPresetsFinalized === true, - authoritativeRebuildPolicies === null && - journaledSessionPolicies === null && - (previousEntry?.customPolicies?.length ?? 0) > 0, - authoritativeRebuildPolicies === null ? process.env : {}, - nonInteractive, - ); - onboardSession.updateSession((current: Session) => { - current.policyPresets = policyPresets; - return current; - }); - if (overrideNote !== null) note(overrideNote); -} - -/** - * Persist the operator's effective policy preset selection to the registry once - * the policy step has fully reconciled it onto the live gateway. Filters to - * built-in preset names and stamps `policyPresetsFinalized`. - */ -export function persistFinalizedPolicyPresets( - sandboxName: string, - appliedPolicyPresets: string[], - listBuiltinPresetNames: () => string[] = defaultListBuiltinPresetNames, -): boolean { - const customPresetNames = registry.getCustomPolicies(sandboxName).map((preset) => preset.name); - return registry.updateSandbox( - sandboxName, - buildFinalizedPolicyPresetsUpdate( - appliedPolicyPresets, - listBuiltinPresetNames(), - customPresetNames, - ), - ); -} diff --git a/src/lib/onboard/policy-preset-reconciliation.ts b/src/lib/onboard/policy-preset-reconciliation.ts index 652815c309d..825258a3266 100644 --- a/src/lib/onboard/policy-preset-reconciliation.ts +++ b/src/lib/onboard/policy-preset-reconciliation.ts @@ -17,7 +17,7 @@ import { mergeRequiredObservabilityPolicyPresets, } from "./observability-policy-presets"; import { mergeRequiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; -import { getTier, type TierDefinition } from "../policy/tiers"; +import { getTier } from "../policy/tiers"; import { ensureRequiredTierPolicyPresets, filterSuppressedAgentRequiredPresets, @@ -100,16 +100,20 @@ export function isStaleBuiltinWebSearchPolicyPreset( options: { webSearchConfig?: WebSearchConfig | null; customPresetNames?: ReadonlySet | null; - tier?: TierDefinition | null; - agent?: string | null; + tierName?: string | null; + agentName?: string | null; } = {}, ): boolean { if (options.customPresetNames?.has(name)) return false; - // A preset in the recorded tier is tier egress, not stale provider state. - // Unknown tiers fail closed because the canonical tier lookup returns no match. + // brave/tavily double as a tier's default egress preset (e.g. Brave Search API + // host access on the Balanced/Open tiers) AND the built-in web-search provider + // preset. When the preset is a default of the applied tier it is a tier egress + // default, not a stale web-search leftover — keep it regardless of the web-search + // provider choice. A tier supplied by the active selection flow can exempt + // its own default, but no tier is read from durable sandbox state. if ( - setupPolicyPresetAppliesToAgent(name, options.agent) && - options.tier?.presets.some( + setupPolicyPresetAppliesToAgent(name, options.agentName) && + getTier(options.tierName ?? "")?.presets.some( (preset) => preset.name.trim().toLowerCase() === name.trim().toLowerCase(), ) ) { @@ -143,8 +147,6 @@ export function createUnavailablePolicyPresetPruner(options: { // Custom and interactive selections may explicitly opt into a built-in web-search // preset without storing provider config. Inactive observability remains ineligible. return (presetNames, pruning = {}) => { - const tierName = pruning.tierName?.trim().toLowerCase(); - const tier = tierName ? getTier(tierName) : null; // OpenClaw keeps an already-applied channel preset until disabledChannels // explicitly retires it. Hermes recovery records the full enabled set, so // it can also prune repository defaults that are absent from that set. @@ -162,8 +164,8 @@ export function createUnavailablePolicyPresetPruner(options: { !isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig: options.webSearchConfig, customPresetNames: options.customPresetNames, - tier, - agent: options.agent, + tierName: pruning.tierName, + agentName: options.agent, })) && !isInactiveObservabilityPolicyPreset(name, options), ); diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index 5934ff9eba9..6ec2e0f2fe4 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -25,7 +25,6 @@ function policies( listSetupPolicyPresets: () => setupPresets, listCustomPresets: () => customPresets, customPresetOwnsNetworkPolicyKey: () => options.customOwnsObservability === true, - removeBuiltinPresetAttribution: () => undefined, getAppliedPresets: () => options.applied ?? [], clampSetupPolicyPresetNames( names: string[], @@ -40,17 +39,20 @@ function policies( } function prepare( - recordedPolicyPresets: string[], + livePolicyPresets: string[], provider: "brave" | "tavily", webSearchConfigChanged = false, ) { - return preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets, - agent: "openclaw", - webSearchConfig: { fetchEnabled: true, provider }, - webSearchConfigChanged, - webSearchSupported: true, - }); + return preparePolicyPresetResumeSelection( + { policies: policies({ applied: livePolicyPresets }) }, + "alpha", + { + agent: "openclaw", + webSearchConfig: { fetchEnabled: true, provider }, + webSearchConfigChanged, + webSearchSupported: true, + }, + ); } describe("preparePolicyPresetResumeSelection web search reconciliation", () => { @@ -58,29 +60,28 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { const result = prepare(["brave"], "tavily"); expect(result.policyPresets).toEqual(["tavily"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it("adds Tavily when web search becomes enabled on resume", () => { const result = prepare(["npm"], "tavily", true); expect(result.policyPresets).toEqual(["npm", "tavily"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it("preserves an intentionally removed provider preset when configuration is unchanged", () => { const result = prepare(["npm"], "tavily"); expect(result.policyPresets).toEqual(["npm"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(false); + expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); it("preserves an operator-owned preset name while adding the active provider", () => { const result = preparePolicyPresetResumeSelection( - { policies: policies({ custom: ["brave"] }) }, + { policies: policies({ applied: ["brave"], custom: ["brave"] }) }, "alpha", { - recordedPolicyPresets: ["brave"], agent: "openclaw", webSearchConfig: { fetchEnabled: true, provider: "tavily" }, webSearchConfigChanged: true, @@ -89,30 +90,32 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { ); expect(result.policyPresets).toEqual(["brave", "tavily"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); }); describe("preparePolicyPresetResumeSelection required preset reconciliation", () => { it.each(["openclaw", "hermes", "langchain-deepagents-code", "pi"])( - "repairs a Personal recording missing its tier-defining preset: %s", + "repairs a Personal live policy missing its tier-defining preset: %s", (agent) => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm"], - agent, - tierName: "personal", - webSearchConfig: null, - webSearchSupported: true, - }); + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm"] }) }, + "alpha", + { + agent, + tierName: "personal", + webSearchConfig: null, + webSearchSupported: true, + }, + ); expect(result.policyPresets).toEqual(["personal-open-internet", "npm"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }, ); - it("returns the Personal requirement when the legacy recording is null", () => { + it("returns the Personal requirement when the legacy live policy is null", () => { const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: null, agent: "pi", tierName: "personal", webSearchConfig: null, @@ -122,30 +125,36 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () expect(result.policyPresets).toEqual(["personal-open-internet"]); }); - it("marks an explicit empty Personal recording for reconciliation", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: [], - agent: "pi", - tierName: "personal", - webSearchConfig: null, - webSearchSupported: true, - }); + it("marks an explicit empty Personal live policy for reconciliation", () => { + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: [] }) }, + "alpha", + { + agent: "pi", + tierName: "personal", + webSearchConfig: null, + webSearchSupported: true, + }, + ); expect(result.policyPresets).toEqual(["personal-open-internet"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("marks an empty recording for reconciliation when Slack becomes required (#6042)", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: [], - enabledChannels: ["slack"], - agent: "openclaw", - webSearchConfig: null, - webSearchSupported: true, - }); + it("marks an empty live policy for reconciliation when Slack becomes required (#6042)", () => { + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: [] }) }, + "alpha", + { + enabledChannels: ["slack"], + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + }, + ); expect(result.policyPresets).toEqual(["slack"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it("removes a stale Hermes Slack preset when no messaging channel is enabled", () => { @@ -153,7 +162,6 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () { policies: policies({ applied: ["npm", "slack"] }) }, "alpha", { - recordedPolicyPresets: ["npm", "slack"], enabledChannels: [], agent: "hermes", webSearchConfig: null, @@ -162,7 +170,7 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () ); expect(result.policyPresets).toEqual(["npm"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it("keeps only the enabled Hermes messaging preset during resume", () => { @@ -170,7 +178,6 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () { policies: policies({ applied: ["npm", "slack", "discord"] }) }, "alpha", { - recordedPolicyPresets: ["npm", "slack", "discord"], enabledChannels: ["discord"], agent: "hermes", webSearchConfig: null, @@ -179,7 +186,7 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () ); expect(result.policyPresets).toEqual(["npm", "discord"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it("preserves custom ownership of an inactive Hermes messaging preset name", () => { @@ -187,7 +194,6 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () { policies: policies({ applied: ["npm", "slack"], custom: ["slack"] }) }, "alpha", { - recordedPolicyPresets: ["npm", "slack"], enabledChannels: [], agent: "hermes", webSearchConfig: null, @@ -196,7 +202,7 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () ); expect(result.policyPresets).toEqual(["npm", "slack"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(false); + expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); }); @@ -205,42 +211,51 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", // is a Balanced default, and Restricted lists no such default. it("preserves brave on reuse when it is a Balanced-tier default and web search is off", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm", "brave"], - agent: "openclaw", - webSearchConfig: null, - webSearchSupported: true, - tierName: "balanced", - }); + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm", "brave"] }) }, + "alpha", + { + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + tierName: "balanced", + }, + ); // brave is a Balanced default (an egress preset), not a stale web-search // leftover — it must survive reuse just like npm, and no reconcile is needed. expect(result.policyPresets).toEqual(["npm", "brave"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(false); + expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); it("still prunes a stale brave on the Restricted tier (no brave default)", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm", "brave"], - agent: "openclaw", - webSearchConfig: null, - webSearchSupported: true, - tierName: "restricted", - }); + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm", "brave"] }) }, + "alpha", + { + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + tierName: "restricted", + }, + ); expect(result.policyPresets).toEqual(["npm"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it("keeps brave on Balanced even when web search is set to a different provider", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm", "brave"], - agent: "openclaw", - webSearchConfig: { fetchEnabled: true, provider: "tavily" }, - webSearchConfigChanged: true, - webSearchSupported: true, - tierName: "balanced", - }); + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm", "brave"] }) }, + "alpha", + { + agent: "openclaw", + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + webSearchConfigChanged: true, + webSearchSupported: true, + tierName: "balanced", + }, + ); // brave stays as the tier egress default; tavily is added as the active provider. expect(result.policyPresets).toEqual(["npm", "brave", "tavily"]); @@ -250,16 +265,19 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", // Boundary: the exemption is scoped to real tier defaults. tavily is NOT a // Balanced default (brave is), so a leftover tavily with no matching provider // is still a stale web-search preset and must be pruned. - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm", "tavily"], - agent: "openclaw", - webSearchConfig: null, - webSearchSupported: true, - tierName: "balanced", - }); + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm", "tavily"] }) }, + "alpha", + { + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + tierName: "balanced", + }, + ); expect(result.policyPresets).toEqual(["npm"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); it.each(["hermes", "langchain-deepagents-code"])( @@ -269,7 +287,6 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", { policies: policies({ applied: ["npm", "brave"] }) }, "alpha", { - recordedPolicyPresets: ["npm", "brave"], agent, webSearchConfig: null, webSearchSupported: true, @@ -278,43 +295,52 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", ); expect(result.policyPresets).toEqual(["npm"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(true); }, ); }); describe("preparePolicyPresetResumeSelection observability reconciliation", () => { it("adds the local OTLP preset only while Deep Agents Code observability is enabled", () => { - const enabled = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm"], - agent: "langchain-deepagents-code", - observabilityEnabled: true, - webSearchConfig: null, - webSearchSupported: true, - }); - const disabled = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm", "observability-otlp-local"], - agent: "langchain-deepagents-code", - observabilityEnabled: false, - webSearchConfig: null, - webSearchSupported: true, - }); + const enabled = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm"] }) }, + "alpha", + { + agent: "langchain-deepagents-code", + observabilityEnabled: true, + webSearchConfig: null, + webSearchSupported: true, + }, + ); + const disabled = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm", "observability-otlp-local"] }) }, + "alpha", + { + agent: "langchain-deepagents-code", + observabilityEnabled: false, + webSearchConfig: null, + webSearchSupported: true, + }, + ); expect(enabled.policyPresets).toEqual(["npm", "observability-otlp-local"]); - expect(enabled.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(enabled.livePolicyPresetsNeedUpdate).toBe(true); expect(disabled.policyPresets).toEqual(["npm"]); - expect(disabled.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(disabled.livePolicyPresetsNeedUpdate).toBe(true); }); it("suppresses the enabled local OTLP preset on the restricted tier", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { - recordedPolicyPresets: ["npm"], - agent: "langchain-deepagents-code", - observabilityEnabled: true, - webSearchConfig: null, - webSearchSupported: true, - tierName: "restricted", - }); + const result = preparePolicyPresetResumeSelection( + { policies: policies({ applied: ["npm"] }) }, + "alpha", + { + agent: "langchain-deepagents-code", + observabilityEnabled: true, + webSearchConfig: null, + webSearchSupported: true, + tierName: "restricted", + }, + ); expect(result.policyPresets).toEqual(["npm"]); }); @@ -330,7 +356,6 @@ describe("preparePolicyPresetResumeSelection observability reconciliation", () = }, "alpha", { - recordedPolicyPresets: ["observability-otlp-local", "corp-otel"], agent: "langchain-deepagents-code", observabilityEnabled: true, webSearchConfig: null, @@ -339,15 +364,19 @@ describe("preparePolicyPresetResumeSelection observability reconciliation", () = ); expect(result.policyPresets).toEqual(["corp-otel"]); - expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); it("preserves same-name different-key custom collision semantics on resume", () => { const result = preparePolicyPresetResumeSelection( - { policies: policies({ custom: ["observability-otlp-local"] }) }, + { + policies: policies({ + applied: ["observability-otlp-local"], + custom: ["observability-otlp-local"], + }), + }, "alpha", { - recordedPolicyPresets: ["observability-otlp-local"], agent: "langchain-deepagents-code", observabilityEnabled: true, webSearchConfig: null, diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index c76655c9eae..b3c08f0f786 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -23,7 +23,6 @@ import { ensureRequiredTierPolicyPresets, suppressedAgentRequiredPresets, } from "./policy-tier-suppression"; -import { getTier } from "../policy/tiers"; type Preset = { name: string; access?: string }; @@ -39,7 +38,6 @@ type PoliciesApi = { listCustomPresets(sandboxName: string): Preset[]; getAppliedPresets(sandboxName: string): string[]; customPresetOwnsNetworkPolicyKey?(sandboxName: string, policyKey: string): boolean; - removeBuiltinPresetAttribution?(sandboxName: string, presetName: string): void; clampSetupPolicyPresetNames( names: string[], selectablePresets: Preset[], @@ -50,7 +48,7 @@ type PoliciesApi = { export type PreparedPolicyResumeSelection = { policyPresets: string[]; - recordedPolicyPresetsNeedReconcile: boolean; + livePolicyPresetsNeedUpdate: boolean; disabledMessagingPolicyPresetApplied: boolean; suppressedAgentRequiredPresetsLive: boolean; }; @@ -59,7 +57,6 @@ export function preparePolicyPresetResumeSelection( deps: { policies: PoliciesApi }, sandboxName: string, options: { - recordedPolicyPresets: string[] | null; disabledChannels?: string[] | null; enabledChannels?: string[] | null; hermesToolGateways?: string[] | null; @@ -81,12 +78,6 @@ export function preparePolicyPresetResumeSelection( sandboxName, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, ) === true; - if (customOwnsObservability) { - deps.policies.removeBuiltinPresetAttribution?.( - sandboxName, - OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - ); - } const rawAppliedPolicyPresets = deps.policies.getAppliedPresets(sandboxName); const appliedPolicyPresets = customOwnsObservability ? [...new Set(rawAppliedPolicyPresets)].filter( @@ -104,23 +95,22 @@ export function preparePolicyPresetResumeSelection( name, })), ]; - const clampedRecordedPolicyPresets = deps.policies.clampSetupPolicyPresetNames( - options.recordedPolicyPresets || [], + const clampedLivePolicyPresets = deps.policies.clampSetupPolicyPresetNames( + appliedPolicyPresets, selectablePolicyPresets, supportOptions, customPolicyPresetNames, ); - // Defaults of the recorded/active tier (e.g. `brave` on Balanced) are tier - // egress presets, not stale web-search leftovers. Pass the recorded tier so - // reconciliation checks the canonical tier definition. (#6844) - const normalizedTierName = options.tierName?.trim().toLowerCase(); - const tier = normalizedTierName ? getTier(normalizedTierName) : null; + // Defaults of the requested tier (e.g. `brave` on Balanced) are tier + // egress presets, not stale web-search leftovers — pass the requested tier + + // agent so the shared predicate exempts them via provenance and re-onboard + // reuse preserves them. (#6844) const isStaleBuiltinWebSearch = (name: string) => isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, - tier, - agent: options.agent, + tierName: options.tierName, + agentName: options.agent, }); const isInactiveObservability = (name: string) => isInactiveObservabilityPolicyPreset(name, { @@ -129,11 +119,11 @@ export function preparePolicyPresetResumeSelection( customPresetNames: customPolicyPresetNames, customOwnsObservability, }); - const recordedBuiltinWebSearchProviderChanged = clampedRecordedPolicyPresets.some( + const liveBuiltinWebSearchProviderChanged = clampedLivePolicyPresets.some( (name) => (name === "brave" || name === "tavily") && isStaleBuiltinWebSearch(name), ); let policyPresets = pruneDisabledMessagingPolicyPresets( - clampedRecordedPolicyPresets.filter( + clampedLivePolicyPresets.filter( (name) => !isStaleBuiltinWebSearch(name) && !isInactiveObservability(name), ), options.disabledChannels, @@ -155,45 +145,40 @@ export function preparePolicyPresetResumeSelection( appliedPolicyPresetsForSupport, options.disabledChannels, ); - if (Array.isArray(options.recordedPolicyPresets)) { - policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { - enabledChannels: options.enabledChannels, - hermesToolGateways: options.hermesToolGateways, - agent: options.agent, - observabilityEnabled: options.observabilityEnabled, - knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), - env: options.env, - tierName: options.tierName, - webSearchConfig: options.webSearchConfig, - customPresetNames: customPolicyPresetNames, - customOwnsObservability, - }); + policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { + enabledChannels: options.enabledChannels, + hermesToolGateways: options.hermesToolGateways, + agent: options.agent, + observabilityEnabled: options.observabilityEnabled, + knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), + env: options.env, + tierName: options.tierName, + webSearchConfig: options.webSearchConfig, + customPresetNames: customPolicyPresetNames, + customOwnsObservability, + }); - // Provider switches are build-time changes, but their matching egress - // preset is runtime state. Resume must add the newly active provider after - // pruning the stale one or the replacement sandbox cannot reach search. - const activeWebSearchPreset = options.webSearchConfig - ? webSearchProviderForConfig(options.webSearchConfig) - : null; - const selectablePolicyPresetNames = new Set( - selectablePolicyPresets.map((preset) => preset.name), - ); - if ( - activeWebSearchPreset && - options.webSearchSupported !== false && - (options.webSearchConfigChanged === true || recordedBuiltinWebSearchProviderChanged) && - selectablePolicyPresetNames.has(activeWebSearchPreset) && - !policyPresets.includes(activeWebSearchPreset) - ) { - policyPresets.push(activeWebSearchPreset); - } + // Provider switches are build-time changes, but their matching egress + // preset is runtime state. Resume must add the newly active provider after + // pruning the stale one or the replacement sandbox cannot reach search. + const activeWebSearchPreset = options.webSearchConfig + ? webSearchProviderForConfig(options.webSearchConfig) + : null; + const selectablePolicyPresetNames = new Set(selectablePolicyPresets.map((preset) => preset.name)); + if ( + activeWebSearchPreset && + options.webSearchSupported !== false && + (options.webSearchConfigChanged === true || liveBuiltinWebSearchProviderChanged) && + selectablePolicyPresetNames.has(activeWebSearchPreset) && + !policyPresets.includes(activeWebSearchPreset) + ) { + policyPresets.push(activeWebSearchPreset); } policyPresets = ensureRequiredTierPolicyPresets(options.tierName, policyPresets); - const recordedPolicyPresetsNeedReconcile = - Array.isArray(options.recordedPolicyPresets) && - (policyPresets.length !== options.recordedPolicyPresets.length || - policyPresets.some((name) => !options.recordedPolicyPresets?.includes(name)) || - options.recordedPolicyPresets.some((name) => !policyPresets.includes(name))); + const livePolicyPresetsNeedUpdate = + policyPresets.length !== appliedPolicyPresets.length || + policyPresets.some((name) => !appliedPolicyPresets.includes(name)) || + appliedPolicyPresets.some((name) => !policyPresets.includes(name)); const suppressedForTier = options.tierName ? new Set(suppressedAgentRequiredPresets(options.tierName, options.agent)) : null; @@ -204,7 +189,7 @@ export function preparePolicyPresetResumeSelection( return { policyPresets, - recordedPolicyPresetsNeedReconcile, + livePolicyPresetsNeedUpdate, disabledMessagingPolicyPresetApplied, suppressedAgentRequiredPresetsLive, }; diff --git a/src/lib/onboard/policy-selection-application.test.ts b/src/lib/onboard/policy-selection-application.test.ts index 279129ec1ce..8722a119574 100644 --- a/src/lib/onboard/policy-selection-application.test.ts +++ b/src/lib/onboard/policy-selection-application.test.ts @@ -58,8 +58,6 @@ describe("onboarding policy application", () => { withSandboxMutationLock, waitForSandboxReady: vi.fn(() => true), waitForSandboxControlPlaneReady: vi.fn(() => true), - setPolicyTier: vi.fn(), - getRecordedPolicyTier: vi.fn(() => null), parsePolicyPresetEnv: vi.fn(() => []), env: {}, }); @@ -104,10 +102,11 @@ describe("onboarding policy application", () => { withSandboxMutationLock: async (_sandboxName, action) => await action(), waitForSandboxReady: vi.fn(() => true), waitForSandboxControlPlaneReady: vi.fn(() => true), - setPolicyTier: vi.fn(), - getRecordedPolicyTier: vi.fn(() => "balanced"), parsePolicyPresetEnv: vi.fn((value: string) => - value.split(",").map((name) => name.trim()).filter(Boolean), + value + .split(",") + .map((name) => name.trim()) + .filter(Boolean), ), env, }); diff --git a/src/lib/onboard/policy-selection-host-local-route.test.ts b/src/lib/onboard/policy-selection-host-local-route.test.ts index 8fea60dfc8f..e2581bcbf51 100644 --- a/src/lib/onboard/policy-selection-host-local-route.test.ts +++ b/src/lib/onboard/policy-selection-host-local-route.test.ts @@ -14,7 +14,6 @@ function createHarness() { listCustomPresets: vi.fn(() => []), getAppliedPresets: vi.fn(() => ["local-inference", "npm"]), customPresetOwnsNetworkPolicyKey: vi.fn(() => false), - removeBuiltinPresetAttribution: vi.fn(), clampSetupPolicyPresetNames: vi.fn((names: string[]) => [...names]), }, tiers: { @@ -37,8 +36,6 @@ function createHarness() { waitForSandboxControlPlaneReady: vi.fn(() => true), syncPresetSelection, selectPolicyTier: vi.fn(async () => "balanced"), - setPolicyTier: vi.fn(), - getRecordedPolicyTier: vi.fn(() => null), selectTierPresetsAndAccess: vi.fn( async (): Promise> => [], ), @@ -100,44 +97,17 @@ describe("host-local route-only policy selection", () => { }); }); - it("refuses tier attribution when authority changes during the prompt (#9833)", async () => { - const { deps, syncPresetSelection } = createHarness(); - deps.isNonInteractive.mockReturnValue(false); - const refuseTierAttribution = () => { - throw new Error("policy authority changed"); - }; - const policyChecks = new Map([ - ["record the policy tier for sandbox 'alpha'", refuseTierAttribution], - ]); - const revalidatePolicyRequirements = vi.fn((operation: string) => - policyChecks.get(operation)?.(), - ); - - await expect( - setupPoliciesWithSelection(deps, "alpha", { - selectedPresets: null, - provider: null, - excludedPresets: ["local-inference"], - revalidatePolicyRequirements, - }), - ).rejects.toThrow("policy authority changed"); - - expect(deps.selectPolicyTier).toHaveBeenCalledOnce(); - expect(deps.setPolicyTier).not.toHaveBeenCalled(); - expect(syncPresetSelection).not.toHaveBeenCalled(); - }); - it("refuses interactive preset mutation when authority changes during the prompt (#9833)", async () => { const { deps, syncPresetSelection } = createHarness(); deps.isNonInteractive.mockReturnValue(false); deps.selectTierPresetsAndAccess.mockResolvedValue([{ name: "npm", access: "read" }]); const refusePresetMutation = () => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }; const policyChecks = new Map([ ["apply policy presets to sandbox 'alpha'", refusePresetMutation], ]); - const revalidatePolicyRequirements = vi.fn((operation: string) => + const verifyLivePolicyRequirements = vi.fn((operation: string) => policyChecks.get(operation)?.(), ); @@ -146,9 +116,9 @@ describe("host-local route-only policy selection", () => { selectedPresets: null, provider: null, excludedPresets: ["local-inference"], - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(deps.selectTierPresetsAndAccess).toHaveBeenCalledOnce(); expect(syncPresetSelection).not.toHaveBeenCalled(); @@ -158,7 +128,7 @@ describe("host-local route-only policy selection", () => { const { deps, syncPresetSelection } = createHarness(); const onSelection = vi.fn(); syncPresetSelection.mockImplementation(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); await expect( @@ -166,75 +136,26 @@ describe("host-local route-only policy selection", () => { selectedPresets: ["npm"], onSelection, }), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); expect(onSelection).not.toHaveBeenCalled(); }); it("refuses resumed preset synchronization when authority changes (#9833)", async () => { const { deps, syncPresetSelection } = createHarness(); - const revalidatePolicyRequirements = vi.fn(() => { - throw new Error("policy authority changed"); + const verifyLivePolicyRequirements = vi.fn(() => { + throw new Error("policy requirements changed"); }); await expect( setupPoliciesWithSelection(deps, "alpha", { selectedPresets: ["npm"], - revalidatePolicyRequirements, - }), - ).rejects.toThrow("policy authority changed"); - - expect(revalidatePolicyRequirements).toHaveBeenCalledWith( - "reapply recorded policy presets to sandbox 'alpha'", - ); - expect(syncPresetSelection).not.toHaveBeenCalled(); - }); - - it("refuses retained-preset synchronization when authority changes (#9833)", async () => { - const { deps, syncPresetSelection } = createHarness(); - deps.env.NEMOCLAW_POLICY_MODE = "skip"; - const revalidatePolicyRequirements = vi - .fn<() => void>() - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => { - throw new Error("policy authority changed"); - }); - - await expect( - setupPoliciesWithSelection(deps, "alpha", { - selectedPresets: null, - provider: null, - excludedPresets: ["local-inference"], - revalidatePolicyRequirements, - }), - ).rejects.toThrow("policy authority changed"); - - expect(revalidatePolicyRequirements).toHaveBeenLastCalledWith( - "apply retained policy presets to sandbox 'alpha'", - ); - expect(syncPresetSelection).not.toHaveBeenCalled(); - }); - - it("refuses non-interactive preset synchronization when authority changes (#9833)", async () => { - const { deps, syncPresetSelection } = createHarness(); - const revalidatePolicyRequirements = vi - .fn<() => void>() - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => { - throw new Error("policy authority changed"); - }); - - await expect( - setupPoliciesWithSelection(deps, "alpha", { - selectedPresets: null, - provider: null, - excludedPresets: ["local-inference"], - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), - ).rejects.toThrow("policy authority changed"); + ).rejects.toThrow("policy requirements changed"); - expect(revalidatePolicyRequirements).toHaveBeenLastCalledWith( - "apply non-interactive policy presets to sandbox 'alpha'", + expect(verifyLivePolicyRequirements).toHaveBeenCalledWith( + "reapply selected policy presets to sandbox 'alpha'", ); expect(syncPresetSelection).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/policy-selection-recorded-tier.test.ts b/src/lib/onboard/policy-selection-recorded-tier.test.ts deleted file mode 100644 index 33787a56788..00000000000 --- a/src/lib/onboard/policy-selection-recorded-tier.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -// 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 { type SetupPolicySelectionDeps, setupPoliciesWithSelection } from "./policy-selection"; - -function createPolicySelectionHarness(controlPlaneReady = true) { - const selectPolicyTier = vi.fn(async () => "balanced"); - const setPolicyTier = vi.fn(); - const syncPresetSelection = vi.fn(); - const waitForSandboxReady = vi.fn(() => true); - const waitForSandboxControlPlaneReady = vi.fn(() => controlPlaneReady); - const onSelection = vi.fn(); - const deps = { - policies: { - setupPolicyPresetSupported: vi.fn(() => true), - listSetupPolicyPresets: vi.fn(() => [{ name: "observability-otlp-local" }]), - listCustomPresets: vi.fn(() => []), - getAppliedPresets: vi.fn(() => []), - customPresetOwnsNetworkPolicyKey: vi.fn(() => false), - removeBuiltinPresetAttribution: vi.fn(), - clampSetupPolicyPresetNames: vi.fn((names: string[]) => [...names]), - }, - tiers: { - resolveTierPresets: vi.fn((tierName: string) => - tierName === "balanced" ? [{ name: "observability-otlp-local" }] : [], - ), - getTier: vi.fn((tierName: string) => ({ - name: tierName, - label: tierName, - description: "test tier", - presets: - tierName === "balanced" - ? [{ name: "observability-otlp-local", access: "read" as const }] - : [], - })), - }, - localInferenceProviders: [], - step: vi.fn(), - note: vi.fn(), - isNonInteractive: vi.fn(() => true), - waitForSandboxReady, - waitForSandboxControlPlaneReady, - syncPresetSelection, - selectPolicyTier, - setPolicyTier, - getRecordedPolicyTier: vi.fn(() => null), - selectTierPresetsAndAccess: vi.fn(async () => []), - parsePolicyPresetEnv: vi.fn(() => []), - env: { NEMOCLAW_POLICY_MODE: "suggested" }, - } satisfies SetupPolicySelectionDeps; - return { - deps, - onSelection, - selectPolicyTier, - setPolicyTier, - syncPresetSelection, - waitForSandboxControlPlaneReady, - waitForSandboxReady, - }; -} - -const setupOptions = { - selectedPresets: null, - tierName: "restricted", - agent: "langchain-deepagents-code", - observabilityEnabled: true, -}; - -describe("policy selection after interrupted onboarding", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("reuses the recorded tier and waits for sandbox re-registration after applying it (#7228)", async () => { - const { - deps, - onSelection, - selectPolicyTier, - setPolicyTier, - syncPresetSelection, - waitForSandboxControlPlaneReady, - waitForSandboxReady, - } = createPolicySelectionHarness(); - await expect( - setupPoliciesWithSelection(deps, "alpha", { - ...setupOptions, - onSelection, - }), - ).resolves.toEqual([]); - - expect(selectPolicyTier).not.toHaveBeenCalled(); - expect(setPolicyTier).toHaveBeenCalledWith("alpha", "restricted"); - expect(onSelection).toHaveBeenCalledWith([]); - expect(syncPresetSelection).toHaveBeenCalledWith("alpha", [], []); - expect(waitForSandboxReady).toHaveBeenCalledTimes(2); - expect(waitForSandboxReady.mock.invocationCallOrder[0]).toBeLessThan( - syncPresetSelection.mock.invocationCallOrder[0], - ); - expect(waitForSandboxReady.mock.invocationCallOrder[1]).toBeGreaterThan( - syncPresetSelection.mock.invocationCallOrder[0], - ); - expect(waitForSandboxControlPlaneReady).toHaveBeenCalledOnce(); - expect(waitForSandboxControlPlaneReady.mock.invocationCallOrder[0]).toBeGreaterThan( - waitForSandboxReady.mock.invocationCallOrder[1], - ); - expect(onSelection.mock.invocationCallOrder[0]).toBeGreaterThan( - waitForSandboxControlPlaneReady.mock.invocationCallOrder[0], - ); - }); - - it("fails closed when sandbox command execution does not recover after policy application (#7228)", async () => { - const exit = vi.spyOn(process, "exit").mockImplementation((code) => { - throw new Error(`process.exit(${code})`); - }); - const { deps, onSelection, syncPresetSelection, waitForSandboxControlPlaneReady } = - createPolicySelectionHarness(false); - - await expect( - setupPoliciesWithSelection(deps, "alpha", { ...setupOptions, onSelection }), - ).rejects.toThrow("process.exit(1)"); - - expect(syncPresetSelection).toHaveBeenCalledWith("alpha", [], []); - expect(onSelection).not.toHaveBeenCalled(); - expect(waitForSandboxControlPlaneReady).toHaveBeenCalledWith("alpha"); - expect(waitForSandboxControlPlaneReady.mock.invocationCallOrder[0]).toBeGreaterThan( - syncPresetSelection.mock.invocationCallOrder[0], - ); - expect(exit).toHaveBeenCalledWith(1); - }); - - it("does not persist the selection when gateway synchronization fails", async () => { - const syncFailure = new Error("policy removal failed"); - const { deps, onSelection, syncPresetSelection } = createPolicySelectionHarness(); - syncPresetSelection.mockImplementationOnce(() => { - throw syncFailure; - }); - - await expect( - setupPoliciesWithSelection(deps, "alpha", { ...setupOptions, onSelection }), - ).rejects.toBe(syncFailure); - - expect(onSelection).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 0b25475a2dd..af456f1b608 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -65,8 +65,6 @@ export type OnboardPolicyApplicationDeps = Omit< withSandboxMutationLock: typeof import("../state/mcp-lifecycle-lock").withSandboxMutationLock; waitForSandboxReady(sandboxName: string): boolean; waitForSandboxControlPlaneReady(sandboxName: string): boolean; - setPolicyTier(sandboxName: string, tierName: string): void; - getRecordedPolicyTier(sandboxName: string): string | null | undefined; parsePolicyPresetEnv(raw: string): string[]; env: NodeJS.ProcessEnv; }; @@ -79,7 +77,6 @@ type PoliciesApi = { listCustomPresets(sandboxName: string): Preset[]; getAppliedPresets(sandboxName: string): string[]; customPresetOwnsNetworkPolicyKey?(sandboxName: string, policyKey: string): boolean; - removeBuiltinPresetAttribution?(sandboxName: string, presetName: string): void; clampSetupPolicyPresetNames( names: string[], selectablePresets: Preset[], @@ -122,7 +119,7 @@ export type SetupPolicySelectionOptions = { disabledChannels?: string[] | null; /** Process-local exclusions imposed by a narrower runtime route authority. */ excludedPresets?: readonly string[]; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }; export type SetupPolicySelectionDeps = { @@ -141,8 +138,6 @@ export type SetupPolicySelectionDeps = { accessByName?: Record, ) => void; selectPolicyTier: () => Promise; - setPolicyTier?: (sandboxName: string, tierName: string) => void; - getRecordedPolicyTier?: (sandboxName: string) => string | null | undefined; selectTierPresetsAndAccess: ( tierName: string, presets: Preset[], @@ -180,8 +175,6 @@ export function createOnboardPolicyApplication(deps: OnboardPolicyApplicationDep waitForSandboxControlPlaneReady: deps.waitForSandboxControlPlaneReady, syncPresetSelection, selectPolicyTier, - setPolicyTier: deps.setPolicyTier, - getRecordedPolicyTier: deps.getRecordedPolicyTier, selectTierPresetsAndAccess, parsePolicyPresetEnv: deps.parsePolicyPresetEnv, env: deps.env, @@ -252,7 +245,6 @@ export function computeSetupPresetSuggestions( } = options; const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; const supportOptions = { webSearchSupported: options.webSearchSupported }; - const tier = deps.tiers.getTier(tierName); const suggestions = pruneInactiveMessagingPolicyPresets( deps.tiers .resolveTierPresets(tierName) @@ -263,8 +255,8 @@ export function computeSetupPresetSuggestions( !isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig, customPresetNames: options.customPresetNames, - tier, - agent, + tierName, + agentName: agent, }), ) .filter( @@ -297,8 +289,8 @@ export function computeSetupPresetSuggestions( isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig, customPresetNames: options.customPresetNames, - tier, - agent, + tierName, + agentName: agent, }) ) { return; @@ -419,15 +411,6 @@ async function setupPoliciesWithSelectionInner( sandboxName, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, ) === true; - if (customOwnsObservability) { - options.revalidatePolicyRequirements?.( - `remove built-in policy attribution from sandbox '${sandboxName}'`, - ); - deps.policies.removeBuiltinPresetAttribution?.( - sandboxName, - OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - ); - } const rawCurrentAppliedPresets = deps.policies.getAppliedPresets(sandboxName); const currentAppliedPresets = customOwnsObservability ? [...new Set(rawCurrentAppliedPresets)].filter( @@ -474,15 +457,9 @@ async function setupPoliciesWithSelectionInner( customPresetNames, ) : null; - // Resume keeps the recorded tier so stale suppressed presets from that tier - // still get filtered. An interrupted create can reach this fresh-selection - // branch before presets are recorded, so its persisted tier must also win - // over a new prompt or non-interactive default. - const persistedTierName = deps.getRecordedPolicyTier?.(sandboxName) ?? null; - const recordedTierName = options.tierName ?? persistedTierName; + const requestedTierName = options.tierName ?? null; const personalAlreadyActive = currentAppliedPresets.includes(PERSONAL_OPEN_INTERNET_PRESET_NAME) || - persistedTierName === PERSONAL_POLICY_TIER_NAME || (selectedPresets !== null && options.tierName === PERSONAL_POLICY_TIER_NAME); if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); @@ -493,16 +470,16 @@ async function setupPoliciesWithSelectionInner( observabilityEnabled, knownPresetNames: knownSelectablePresets, env: deps.env, - tierName: recordedTierName, + tierName: requestedTierName, webSearchConfig, customPresetNames, customOwnsObservability, }); - // Pass the recorded tier so the pruner exempts that tier's egress defaults + // Pass the requested tier so the pruner exempts that tier's egress defaults // (e.g. `brave` on Balanced) via provenance — a reconcile-triggered reuse // reapply must not narrow an applied tier default. (#6844) - chosen = excludePresets(pruneUnavailablePresets(chosen, { tierName: recordedTierName })); - chosen = ensureRequiredTierPolicyPresets(recordedTierName, chosen); + chosen = excludePresets(pruneUnavailablePresets(chosen, { tierName: requestedTierName })); + chosen = ensureRequiredTierPolicyPresets(requestedTierName, chosen); } if (selectedPresets !== null) { @@ -510,8 +487,8 @@ async function setupPoliciesWithSelectionInner( refuseInPlacePersonalRemoval(personalAlreadyActive, resumeSelection); requireSandboxReady(deps, sandboxName, "before"); deps.note(` [resume] Reapplying policy presets: ${resumeSelection.join(", ")}`); - options.revalidatePolicyRequirements?.( - `reapply recorded policy presets to sandbox '${sandboxName}'`, + options.verifyLivePolicyRequirements?.( + `reapply selected policy presets to sandbox '${sandboxName}'`, ); deps.syncPresetSelection(sandboxName, currentAppliedPresets, resumeSelection); requireSandboxReady(deps, sandboxName, "after"); @@ -519,12 +496,10 @@ async function setupPoliciesWithSelectionInner( return resumeSelection; } - const tierName = recordedTierName ?? (await deps.selectPolicyTier()); + const tierName = requestedTierName ?? (await deps.selectPolicyTier()); if (personalAlreadyActive && tierName !== PERSONAL_POLICY_TIER_NAME) { refuseInPlacePersonalRemoval(personalAlreadyActive, []); } - options.revalidatePolicyRequirements?.(`record the policy tier for sandbox '${sandboxName}'`); - deps.setPolicyTier?.(sandboxName, tierName); const personalTier = tierName === PERSONAL_POLICY_TIER_NAME; // The carry-forward set decides which *already applied* presets survive, so it // needs the applied tier for the same provenance exemption the resume reapply @@ -592,7 +567,7 @@ async function setupPoliciesWithSelectionInner( ? " [non-interactive] Applying the Personal tier requirement while skipping optional policy presets." : " [non-interactive] Removing excluded or unavailable policy presets.", ); - options.revalidatePolicyRequirements?.( + options.verifyLivePolicyRequirements?.( `apply retained policy presets to sandbox '${sandboxName}'`, ); deps.syncPresetSelection(sandboxName, currentAppliedPresets, retainedPresets); @@ -679,7 +654,7 @@ async function setupPoliciesWithSelectionInner( refuseInPlacePersonalRemoval(personalAlreadyActive, chosen); requireSandboxReady(deps, sandboxName, "before"); deps.note(` [non-interactive] Applying policy presets: ${chosen.join(", ")}`); - options.revalidatePolicyRequirements?.( + options.verifyLivePolicyRequirements?.( `apply non-interactive policy presets to sandbox '${sandboxName}'`, ); deps.syncPresetSelection(sandboxName, currentAppliedPresets, chosen); @@ -730,7 +705,7 @@ async function setupPoliciesWithSelectionInner( for (const preset of resolvedPresets) { if (interactiveChoiceNames.has(preset.name)) accessByName[preset.name] = preset.access; } - options.revalidatePolicyRequirements?.(`apply policy presets to sandbox '${sandboxName}'`); + options.verifyLivePolicyRequirements?.(`apply policy presets to sandbox '${sandboxName}'`); deps.syncPresetSelection(sandboxName, currentAppliedPresets, interactiveChoice, accessByName); requireSandboxReady(deps, sandboxName, "after"); if (onSelection) onSelection(interactiveChoice); diff --git a/src/lib/onboard/providers.test.ts b/src/lib/onboard/providers.test.ts index 4301d10bc58..9151b69ca0b 100644 --- a/src/lib/onboard/providers.test.ts +++ b/src/lib/onboard/providers.test.ts @@ -21,9 +21,8 @@ type RunOptions = { }; type RunOpenshell = (command: string[], opts?: RunOptions) => RunResult; -const messagingBridgeProvider = require( - "./messaging-bridge-provider" -) as typeof import("./messaging-bridge-provider"); +const messagingBridgeProvider = + require("./messaging-bridge-provider") as typeof import("./messaging-bridge-provider"); const DISCORD_STATIC_PROFILE_EXPORT = JSON.stringify({ id: "discord-hermes-static-v1", @@ -115,7 +114,7 @@ const { replaceExisting?: boolean; allowedSandboxes?: readonly string[]; requireExactBinding?: boolean; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; }, ) => { ok: boolean; status?: number; message?: string; reason?: string }; upsertMessagingProviders: ( @@ -130,7 +129,7 @@ const { allowedSandboxes?: readonly string[]; bestEffort?: boolean; replaceExisting?: boolean; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; requireExactBindings?: boolean; }, ) => string[]; @@ -936,7 +935,7 @@ describe("onboard provider helpers", () => { () => undefined, () => undefined, () => { - throw new Error("policy authority changed between providers"); + throw new Error("policy requirements changed between providers"); }, ]; @@ -952,21 +951,21 @@ describe("onboard provider helpers", () => { ? { status: 1, stdout: "", stderr: "" } : { status: 0, stdout: "", stderr: "" }; }, - { revalidatePolicyRequirements: () => revalidationSteps.shift()?.() }, + { verifyLivePolicyRequirements: () => revalidationSteps.shift()?.() }, ), - ).toThrow(/authority changed between providers/); + ).toThrow(/policy requirements changed between providers/); expect(commands).toEqual([ "provider get alpha-first", "provider create --name alpha-first --type generic --credential FIRST_TOKEN", ]); }); - it("rechecks policy authority after a provider probe and before its mutation (#9833)", () => { + it("rechecks policy requirements after a provider probe and before its mutation (#9833)", () => { const commands: string[] = []; const revalidationSteps = [ () => undefined, () => { - throw new Error("policy authority changed after provider probe"); + throw new Error("policy requirements changed after provider probe"); }, ]; @@ -981,9 +980,9 @@ describe("onboard provider helpers", () => { commands.push(command.join(" ")); return { status: 1, stdout: "", stderr: "not found" }; }, - { revalidatePolicyRequirements: () => revalidationSteps.shift()?.() }, + { verifyLivePolicyRequirements: () => revalidationSteps.shift()?.() }, ), - ).toThrow(/authority changed after provider probe/u); + ).toThrow(/policy requirements changed after provider probe/u); expect(commands).toEqual(["provider get alpha-discord-bridge"]); }); @@ -1056,7 +1055,6 @@ describe("onboard provider helpers", () => { ]); }); - it("throws instead of exiting when best-effort messaging provider upsert fails", () => { const originalExit = process.exit; process.exit = ((code?: number | string | null) => { @@ -1088,7 +1086,7 @@ describe("onboard provider helpers", () => { const configureRefreshes = vi .spyOn(messagingBridgeProvider, "configureMessagingBridgeRefreshes") .mockImplementation(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); try { expect(() => @@ -1099,7 +1097,7 @@ describe("onboard provider helpers", () => { ), ).toThrow( expect.objectContaining({ - message: expect.stringMatching(/policy authority changed.*alpha-bridge/isu), + message: expect.stringMatching(/policy requirements changed.*alpha-bridge/isu), mutatedProviderNames: ["alpha-bridge"], }), ); diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 80684e9302a..0f3774c162c 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -501,18 +501,18 @@ function providerExistsInGateway(name, _runOpenshell) { } /** - * Recheck the caller's policy receipt immediately before each OpenShell - * provider command. Commands in one provider operation can be separated by + * Recheck current OpenShell policy requirements before each provider command. + * Commands in one provider operation can be separated by * probes and recovery work, so one outer check is not sufficient. * @param {Function} runOpenshell - * @param {((operation: string) => void)|undefined} revalidatePolicyRequirements + * @param {((operation: string) => void)|undefined} verifyLivePolicyRequirements * @param {string} operation * @returns {Function} */ -function policyAuthorityCheckedRunner(runOpenshell, revalidatePolicyRequirements, operation) { - if (!revalidatePolicyRequirements) return runOpenshell; +function policyRequirementsCheckedRunner(runOpenshell, verifyLivePolicyRequirements, operation) { + if (!verifyLivePolicyRequirements) return runOpenshell; return (...args) => { - revalidatePolicyRequirements(operation); + verifyLivePolicyRequirements(operation); return runOpenshell(...args); }; } @@ -534,13 +534,13 @@ function policyAuthorityCheckedRunner(runOpenshell, revalidatePolicyRequirements * @param {string|null} baseUrl - Optional base URL for the provider endpoint. * @param {Record} env - Environment variables for the openshell command. * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. - * @param {{replaceExisting?: boolean, knownExists?: boolean, allowedSandboxes?: readonly string[], requireExactBinding?: boolean, allowExtendedCredentialKeys?: boolean, credentialEnvs?: string[], revalidatePolicyRequirements?: (operation: string) => void}} options - Optional replacement controls. + * @param {{replaceExisting?: boolean, knownExists?: boolean, allowedSandboxes?: readonly string[], requireExactBinding?: boolean, allowExtendedCredentialKeys?: boolean, credentialEnvs?: string[], verifyLivePolicyRequirements?: (operation: string) => void}} options - Optional replacement controls. * @returns {{ ok: boolean, status?: number, message?: string, reason?: string }} */ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, options = {}) { - const runOpenshell = policyAuthorityCheckedRunner( + const runOpenshell = policyRequirementsCheckedRunner( _runOpenshell, - options.revalidatePolicyRequirements, + options.verifyLivePolicyRequirements, `inspect or change provider ${JSON.stringify(name)}`, ); const exists = options.knownExists ?? providerExistsInGateway(name, runOpenshell); @@ -716,13 +716,13 @@ function assertCredentialFamilyProviderBindings(tokenDefs, runOpenshell, options * of terminating the CLI. * @param {Array<{name: string, envKey: string, token: string|null, providerType?: string, additionalCredentials?: Array<{envKey: string, token: string|null}>}>} tokenDefs * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. - * @param {{replaceExisting?: boolean, bestEffort?: boolean, allowedSandboxes?: readonly string[], requireExactBindings?: boolean, revalidatePolicyRequirements?: (operation: string) => void}} options - Forwarded to every upsertProvider call. + * @param {{replaceExisting?: boolean, bestEffort?: boolean, allowedSandboxes?: readonly string[], requireExactBindings?: boolean, verifyLivePolicyRequirements?: (operation: string) => void}} options - Forwarded to every upsertProvider call. * @returns {string[]} Provider names that were upserted. */ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { - const runMessagingBridgeOpenshell = policyAuthorityCheckedRunner( + const runMessagingBridgeOpenshell = policyRequirementsCheckedRunner( _runOpenshell, - options.revalidatePolicyRequirements, + options.verifyLivePolicyRequirements, "inspect or change a messaging bridge provider", ); assertCredentialFamilyProviderBindings(tokenDefs, runMessagingBridgeOpenshell, options); @@ -830,7 +830,7 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { replaceExisting: Boolean(options.replaceExisting), knownExists, allowedSandboxes: options.allowedSandboxes, - revalidatePolicyRequirements: options.revalidatePolicyRequirements, + verifyLivePolicyRequirements: options.verifyLivePolicyRequirements, requireExactBinding: Boolean( requiresFamilyBinding || (options.requireExactBindings && providerType), ), diff --git a/src/lib/onboard/resume-provider-recovery.ts b/src/lib/onboard/resume-provider-recovery.ts index 7821ab6bfe0..75bafda9b76 100644 --- a/src/lib/onboard/resume-provider-recovery.ts +++ b/src/lib/onboard/resume-provider-recovery.ts @@ -38,9 +38,9 @@ export type ResumeProviderRecoveryDeps = { label: string, helpUrl: string | null, validator: (value: string) => string | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; validateNvidiaApiKeyValue: (key: string, credentialEnv: string) => string | null; }; @@ -142,7 +142,7 @@ export async function ensureResumeProviderReady( `${providerLabel} API key`, helpUrl, (value) => deps.validateNvidiaApiKeyValue(value, resolvedCredentialEnv), - deps.revalidatePolicyRequirements, + deps.verifyLivePolicyRequirements, ); } else { deps.note(` [resume] Provider '${provider}' is missing from the gateway; recreating it.`); diff --git a/src/lib/onboard/runtime-boundary.ts b/src/lib/onboard/runtime-boundary.ts index 3453a3ce694..98cb0b017e4 100644 --- a/src/lib/onboard/runtime-boundary.ts +++ b/src/lib/onboard/runtime-boundary.ts @@ -73,7 +73,6 @@ export class OnboardRuntimeBoundary { sandboxName?: string | null; provider?: string | null; model?: string | null; - policyPresets?: string[] | null; } = {}, ): Promise { const runtime = this.getRuntime(); diff --git a/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts b/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts index cf74e4c20a2..d279d5faa06 100644 --- a/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts +++ b/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts @@ -3,7 +3,10 @@ import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MANAGED_STARTUP_E2E_CORPORATE_CA_PEM, managedStartupE2eProfile, @@ -953,6 +956,17 @@ describe("sandbox workload ownership receipt", () => { describe("socket-free MXC action contract", () => { const agents = ["openclaw", "hermes", "langchain-deepagents-code"] as const; + let testHome: string; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-provider-contract-")); + vi.stubEnv("HOME", testHome); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(testHome, { recursive: true, force: true }); + }); it.each(agents)( "routes %s registration, lifecycle, inference authority, destroy, and cleanup through one injected bundle", @@ -1005,7 +1019,6 @@ describe("socket-free MXC action contract", () => { reference: imageTag, shared: false, }, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, diff --git a/src/lib/onboard/sandbox-create-intent-resolution.ts b/src/lib/onboard/sandbox-create-intent-resolution.ts index 7a98a314edb..f08f3f4f8a3 100644 --- a/src/lib/onboard/sandbox-create-intent-resolution.ts +++ b/src/lib/onboard/sandbox-create-intent-resolution.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; -import type { BaselineExclusionEntry } from "../state/registry"; import type { DockerGpuRoutePlan } from "./docker-gpu-route"; import type { NamedMessagingChannel } from "./messaging-prep"; import { @@ -38,8 +37,6 @@ export type CompleteSandboxCreateIntentInput = { extraProviders: readonly string[]; staleExtraProviders: readonly string[]; policyTier?: string | null; - /** Operator baseline exclusions replayed into create/rebuild policy generation. */ - baselineExclusions?: readonly BaselineExclusionEntry[]; /** Internal OpenClaw resume authority for exact registered provider reuse. */ reuseRegisteredCredentials?: boolean; }; @@ -170,7 +167,6 @@ export function createSandboxCreateIntentResolver< extraPlaceholderKeys: messaging.extraPlaceholderKeys, agentName: input.agent?.name, policyTier: resolveSandboxCreatePolicyTier(input.policyTier), - baselineExclusions: input.baselineExclusions, }); } diff --git a/src/lib/onboard/sandbox-create-intent-types.ts b/src/lib/onboard/sandbox-create-intent-types.ts index 77d92092689..161da18ce3f 100644 --- a/src/lib/onboard/sandbox-create-intent-types.ts +++ b/src/lib/onboard/sandbox-create-intent-types.ts @@ -1,9 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { BaselineExclusionEntry } from "../state/registry"; import type { SandboxHostMount } from "../state/registry/types"; -import type { SandboxPolicyAuthority } from "../adapters/openshell/policy-authority"; import type { DockerGpuRoutePlan } from "./docker-gpu-route"; import type { InitialSandboxPolicy } from "./initial-policy"; import type { ManagedHermesStateVolumeMount } from "./managed-workload/hermes-state-volume"; @@ -32,7 +30,6 @@ export type SandboxCreatePolicyRequest = { readonly hostLocalInferenceRouteOnly?: true; readonly agentName?: string | null; readonly policyTier: string | null; - readonly baselineExclusions: readonly BaselineExclusionEntry[]; }; }; @@ -89,27 +86,24 @@ export type ResolveSandboxCreateIntentInput = { sandboxGpuLogMessage: string | null; extraPlaceholderKeys?: readonly string[]; agentName?: string | null; - policyTier: string | null; - baselineExclusions?: readonly BaselineExclusionEntry[]; + policyTier?: string | null; }; export type MaterializeSandboxCreatePlanInput = { intent: SandboxCreateIntent; fromRef: string; - policyAuthority: SandboxPolicyAuthority; + policylessCreate?: boolean; /** Keep provider mutations and attachments behind the exact post-create policy gate. */ deferSandboxEffectsUntilPolicyVerification?: boolean; managedStateMount?: ManagedHermesStateVolumeMount | null; messagingTokenDefs: MessagingTokenDef[]; - runProviderPreDeleteCleanup( - revalidatePolicyRequirements?: (operation: string) => void, - ): void; + runProviderPreDeleteCleanup(verifyLivePolicyRequirements?: (operation: string) => void): void; upsertMessagingProviders( tokenDefs: MessagingTokenDef[], options: { replaceExisting: true; allowedSandboxes: readonly [string]; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; }, ): string[]; getHermesToolGatewayProviderName(sandboxName: string): string; diff --git a/src/lib/onboard/sandbox-create-intent.ts b/src/lib/onboard/sandbox-create-intent.ts index 2a34d7dc02e..f698589aa04 100644 --- a/src/lib/onboard/sandbox-create-intent.ts +++ b/src/lib/onboard/sandbox-create-intent.ts @@ -159,8 +159,7 @@ export function resolveSandboxCreateIntent({ sandboxGpuLogMessage, extraPlaceholderKeys = [], agentName, - policyTier, - baselineExclusions = [], + policyTier = null, }: ResolveSandboxCreateIntentInput): SandboxCreateIntent { const selectedChannelNames = enabledChannels == null ? null : new Set(enabledChannels); const enabledMessagingProviderRequests = filterMessagingProviderRequestsByEnabledChannel( @@ -206,7 +205,6 @@ export function resolveSandboxCreateIntent({ ...(hostLocalInferenceRouteOnly ? { hostLocalInferenceRouteOnly: true as const } : {}), ...(agentName !== undefined ? { agentName } : {}), policyTier, - baselineExclusions: [...baselineExclusions].map((exclusion) => ({ ...exclusion })), }, }, sandboxGpuDevice: sandboxGpuConfig.sandboxGpuDevice?.trim() || null, diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index 6428eeaf9f8..d484808faa8 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { InitialSandboxPolicy } from "./initial-policy"; -import type { SandboxPolicyAuthority } from "../adapters/openshell/policy-authority"; import { hasConfiguredMessagingCredential, type MessagingTokenDef } from "./messaging-prep"; import { filterMessagingProvidersForSandboxCreate } from "./sandbox-create-intent"; import type { @@ -74,9 +73,6 @@ function buildSandboxDriverConfig( export type SandboxCreatePlan = { activeMessagingChannels: string[]; initialSandboxPolicy: InitialSandboxPolicy; - /** Tier resolved before create, persisted with the registry entry for safe resume. */ - policyTier: string | null; - policyAuthority: SandboxPolicyAuthority; createArgs: string[]; messagingProviders: string[]; gpuRoutePlan: SandboxCreateIntent["gpuRoutePlan"]; @@ -84,7 +80,7 @@ export type SandboxCreatePlan = { sandboxGpuLogMessage: string | null; /** One-shot provider activation owned by the post-create verification boundary. */ activateDeferredProviderEffects: - | ((revalidatePolicyRequirements: (operation: string) => void) => readonly string[]) + | ((verifyLivePolicyRequirements: (operation: string) => void) => readonly string[]) | null; }; @@ -168,9 +164,6 @@ export function prepareSandboxCreatePolicy( // composing them throws. sandboxName: intent.sandboxName, policyTier: intent.policy.options.policyTier, - baselineExclusions: intent.policy.options.baselineExclusions.map((exclusion) => ({ - ...exclusion, - })), }, intent.gpuRoutePlan, prepareInitialSandboxCreatePolicy, @@ -287,7 +280,7 @@ function assertDeferredProviderPlanSupported( export function materializeSandboxCreatePlan({ intent, fromRef, - policyAuthority, + policylessCreate = false, deferSandboxEffectsUntilPolicyVerification = false, managedStateMount, messagingTokenDefs, @@ -311,9 +304,6 @@ export function materializeSandboxCreatePlan({ agentName: intent.policy.options.agentName, sandboxName: intent.sandboxName, policyTier: intent.policy.options.policyTier, - baselineExclusions: intent.policy.options.baselineExclusions.map((exclusion) => ({ - ...exclusion, - })), }, intent.gpuRoutePlan, prepareInitialSandboxCreatePolicy, @@ -323,9 +313,7 @@ export function materializeSandboxCreatePlan({ fromRef, "--name", intent.sandboxName, - ...(policyAuthority === "nemoclaw-managed" - ? ["--policy", initialSandboxPolicy.policyPath] - : []), + ...(!policylessCreate ? ["--policy", initialSandboxPolicy.policyPath] : []), ...(driverConfig ? ["--driver-config-json", driverConfig] : []), ...intent.gpuCreateArgs, ...intent.resourceCreateArgs, @@ -352,7 +340,7 @@ export function materializeSandboxCreatePlan({ if (deferSandboxEffectsUntilPolicyVerification) { assertDeferredProviderPlanSupported(intent, plannedMessagingProviders, initialSandboxPolicy); } - if (policyAuthority === "nemoclaw-managed") { + if (!policylessCreate) { assertCredentialBindingProvidersAttached( initialSandboxPolicy, buildCreateProviderSet(intent, plannedMessagingProviders, resolveHermesToolGatewayProvider()), @@ -366,15 +354,15 @@ export function materializeSandboxCreatePlan({ } const activateProviderEffects = ( - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): readonly string[] => { - runProviderPreDeleteCleanup(revalidatePolicyRequirements); + runProviderPreDeleteCleanup(verifyLivePolicyRequirements); const activatedMessagingProviders = filterMessagingProvidersForSandboxCreate( [ ...upsertMessagingProviders(enabledMessagingTokenDefs, { replaceExisting: true, allowedSandboxes: [intent.sandboxName], - ...(revalidatePolicyRequirements ? { revalidatePolicyRequirements } : {}), + ...(verifyLivePolicyRequirements ? { verifyLivePolicyRequirements } : {}), }), ...intent.reusableMessagingProviders, ], @@ -387,7 +375,7 @@ export function materializeSandboxCreatePlan({ activatedMessagingProviders, resolveHermesToolGatewayProvider(), ); - if (policyAuthority === "nemoclaw-managed") { + if (!policylessCreate) { assertCredentialBindingProvidersAttached(initialSandboxPolicy, createProviders); } if (!sameProviderNames(activatedMessagingProviders, plannedMessagingProviders)) { @@ -406,8 +394,6 @@ export function materializeSandboxCreatePlan({ return { activeMessagingChannels: [...intent.policy.activeMessagingChannels], initialSandboxPolicy, - policyTier: intent.policy.options.policyTier, - policyAuthority, createArgs, messagingProviders: plannedMessagingProviders, gpuRoutePlan: intent.gpuRoutePlan, @@ -423,12 +409,8 @@ export function materializeSandboxCreatePlan({ export function materializeHermesPortableCreatePlan(input: { readonly intent: SandboxCreateIntent; readonly fromRef: string; - readonly policyAuthority: SandboxPolicyAuthority; }): SandboxCreatePlan { - const { intent, fromRef, policyAuthority } = input; - if (policyAuthority !== "nemoclaw-managed") { - throw new Error("Hermes portable sandbox creation requires NemoClaw-managed policy authority."); - } + const { intent, fromRef } = input; if ( intent.policy.options.agentName !== "hermes" || !["none", "native-only"].includes(intent.gpuRoutePlan) || @@ -455,7 +437,6 @@ export function materializeHermesPortableCreatePlan(input: { : [...intent.policy.options.additionalPresets], agentName: "hermes", policyTier: intent.policy.options.policyTier, - baselineExclusions: intent.policy.options.baselineExclusions.map((entry) => ({ ...entry })), }, ); const driverConfig = buildSandboxDriverConfig(intent, null); @@ -474,8 +455,6 @@ export function materializeHermesPortableCreatePlan(input: { return { activeMessagingChannels: [], initialSandboxPolicy, - policyTier: intent.policy.options.policyTier, - policyAuthority, createArgs, messagingProviders: [], gpuRoutePlan: intent.gpuRoutePlan, diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index e203212efef..c04ce2790ef 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -73,7 +73,11 @@ const channels = [ const discordProviderName = "sandbox-discord-bridge"; -function resolveDiscordCreateIntent(input: { selected: boolean; reusable?: boolean }) { +function resolveDiscordCreateIntent(input: { + selected: boolean; + reusable?: boolean; + policyTier?: "balanced" | "restricted"; +}) { const messagingTokenDefs: MessagingTokenDef[] = [ { name: discordProviderName, @@ -101,7 +105,7 @@ function resolveDiscordCreateIntent(input: { selected: boolean; reusable?: boole gpuRoutePlan: "none", sandboxGpuLogMessage: null, agentName: "openclaw", - policyTier: "balanced", + policyTier: input.policyTier ?? null, }); return { intent, messagingTokenDefs }; } @@ -123,7 +127,6 @@ function materializeDiscordCreatePlan( return materializeSandboxCreatePlan({ ...resolved, fromRef: "/tmp/Dockerfile", - policyAuthority: "nemoclaw-managed", runProviderPreDeleteCleanup: vi.fn(), upsertMessagingProviders: vi.fn(() => [discordProviderName]), getHermesToolGatewayProviderName: vi.fn(), @@ -158,7 +161,6 @@ function expectCredentialBindingFailure({ gpuCreateArgs: [], gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, - policyTier: null, }); const preparePolicy = vi.fn(() => ({ policyPath: "/tmp/policy.yaml", @@ -171,7 +173,6 @@ function expectCredentialBindingFailure({ materializeSandboxCreatePlan({ intent, fromRef: "/tmp/nemoclaw-build-1/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: materializedTokenDefs, prepareInitialSandboxCreatePolicy: preparePolicy, runProviderPreDeleteCleanup: cleanupProviders, @@ -201,7 +202,6 @@ describe("prepareSandboxCreatePolicy", () => { gpuCreateArgs: [], gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, - policyTier: null, }); const seenOptions: Array> = []; const preparePolicy: typeof prepareInitialSandboxCreatePolicy = ( @@ -226,6 +226,28 @@ describe("resolveSandboxCreatePolicyTier", () => { expect(resolveSandboxCreatePolicyTier()).toBe("personal"); }); + + it("ends policy-tier transport after initial policy composition", () => { + const resolved = resolveDiscordCreateIntent({ selected: false, policyTier: "balanced" }); + const preparePolicy = vi.fn(() => ({ + policyPath: "/tmp/policy.yaml", + appliedPresets: ["openclaw-diagnostics-otel-local"], + })); + + const plan = materializeDiscordCreatePlan(resolved, { + prepareInitialSandboxCreatePolicy: preparePolicy, + }); + + expect(preparePolicy).toHaveBeenCalledWith( + expect.any(String), + [], + expect.objectContaining({ policyTier: "balanced" }), + ); + expect(plan.initialSandboxPolicy.appliedPresets).toContain( + "openclaw-diagnostics-otel-local", + ); + expect(plan).not.toHaveProperty("policyTier"); + }); }); describe("resolveSandboxCreateIntent", () => { @@ -299,17 +321,6 @@ describe("resolveSandboxCreateIntent", () => { sandboxGpuLogMessage: "gpu note", extraPlaceholderKeys: ["TELEGRAM_BOT_TOKEN_AGENT_A"], agentName: "hermes", - policyTier: "balanced", - baselineExclusions: [ - { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "abc", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: null, - }, - ], }; const first = resolveSandboxCreateIntent(input); @@ -338,17 +349,7 @@ describe("resolveSandboxCreateIntent", () => { hostGpuAvailable: true, additionalPresets: ["github"], agentName: "hermes", - policyTier: "balanced", - baselineExclusions: [ - { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "abc", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: null, - }, - ], + policyTier: null, }, }); expect(JSON.parse(JSON.stringify(first))).toEqual(first); @@ -468,14 +469,12 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "none", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: "balanced", }); const upsertMessagingProviders = vi.fn(() => []); const plan = materializeSandboxCreatePlan({ intent, fromRef: "/tmp/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: [], runProviderPreDeleteCleanup: vi.fn(), upsertMessagingProviders, @@ -514,7 +513,6 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: "balanced", }); const preparePolicy = vi.fn(() => ({ policyPath: "/tmp/policy.yaml", @@ -524,7 +522,6 @@ describe("resolveSandboxCreateIntent", () => { const plan = materializeSandboxCreatePlan({ intent, fromRef: "/tmp/nemoclaw-build-1/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: [], prepareInitialSandboxCreatePolicy: preparePolicy, runProviderPreDeleteCleanup: vi.fn(), @@ -572,7 +569,6 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: "balanced", }); const serializedIntent = JSON.stringify(intent); const events: string[] = []; @@ -580,7 +576,6 @@ describe("resolveSandboxCreateIntent", () => { const result = materializeSandboxCreatePlan({ intent, fromRef: "/tmp/nemoclaw-build-1/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: tokenDefs, prepareInitialSandboxCreatePolicy: vi.fn(() => { events.push("policy"); @@ -661,7 +656,6 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "none", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: null, }); const events: string[] = []; const cleanupPolicy = vi.fn(() => { @@ -682,7 +676,6 @@ describe("resolveSandboxCreateIntent", () => { materializeSandboxCreatePlan({ intent, fromRef: "example.invalid/image@sha256:abc", - policyAuthority: "externally-managed", deferSandboxEffectsUntilPolicyVerification: true, messagingTokenDefs: tokenDefs, prepareInitialSandboxCreatePolicy: () => ({ @@ -722,12 +715,10 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "none", sandboxGpuLogMessage: null, agentName: "openclaw", - policyTier: null, }); const plan = materializeSandboxCreatePlan({ intent, fromRef: "example.invalid/image@sha256:abc", - policyAuthority: "nemoclaw-managed", deferSandboxEffectsUntilPolicyVerification: true, messagingTokenDefs: [], prepareInitialSandboxCreatePolicy: () => ({ @@ -743,44 +734,6 @@ describe("resolveSandboxCreateIntent", () => { expect(plan.createArgs).not.toContain("--provider"); }); - it("omits caller policy when external authority owns sandbox policy (#9833)", () => { - const intent = resolveSandboxCreateIntent({ - basePolicyPath: "/repo/policy.yaml", - sandboxName: "sandbox", - channels: [], - enabledChannels: [], - disabledChannelNames: new Set(), - messagingProviderRequests: [], - primaryMessagingCredentialEnvKeys: [], - reusableMessagingChannels: [], - reusableMessagingProviders: [], - hermesToolGateways: [], - sandboxGpuConfig, - gpuCreateArgs: [], - gpuRoutePlan: "none", - sandboxGpuLogMessage: null, - policyTier: null, - }); - - const plan = materializeSandboxCreatePlan({ - intent, - fromRef: "example.invalid/image@sha256:abc", - policyAuthority: "externally-managed", - messagingTokenDefs: [], - prepareInitialSandboxCreatePolicy: () => ({ - policyPath: "/tmp/policy.yaml", - appliedPresets: ["github"], - }), - runProviderPreDeleteCleanup: vi.fn(), - upsertMessagingProviders: vi.fn(() => []), - getHermesToolGatewayProviderName: vi.fn(), - }); - - expect(plan.policyAuthority).toBe("externally-managed"); - expect(plan.createArgs).not.toContain("--policy"); - expect(plan.createArgs).not.toContain("/tmp/policy.yaml"); - }); - it("materializes a raw GPU UUID as Docker and Podman CDI driver config", () => { vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); const intent = resolveSandboxCreateIntent({ @@ -802,13 +755,11 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: null, }); const plan = materializeHermesPortableCreatePlan({ intent, fromRef: "ghcr.io/nvidia/nemoclaw/hermes:test", - policyAuthority: "nemoclaw-managed", }); const configIndex = plan.createArgs.indexOf("--driver-config-json"); @@ -840,14 +791,12 @@ describe("resolveSandboxCreateIntent", () => { gpuCreateArgs: [], gpuRoutePlan: "none", sandboxGpuLogMessage: null, - policyTier: null, }); expect(() => materializeSandboxCreatePlan({ intent, fromRef: "/tmp/nemoclaw-build-1/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: [], prepareInitialSandboxCreatePolicy: vi.fn(), runProviderPreDeleteCleanup: vi.fn(), @@ -875,12 +824,10 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, agentName: "langchain-deepagents-code", - policyTier: null, }); const plan = materializeSandboxCreatePlan({ intent, fromRef: "/tmp/nemoclaw-build-1/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: [], prepareInitialSandboxCreatePolicy: vi.fn(() => ({ policyPath: "/tmp/policy.yaml", @@ -929,12 +876,10 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: null, }); const plan = materializeSandboxCreatePlan({ intent, fromRef: `ghcr.io/nvidia/nemoclaw/hermes@sha256:${"a".repeat(64)}`, - policyAuthority: "nemoclaw-managed", managedStateMount: { type: "volume", source: "nemoclaw-hermes-state-v1-hermes-box", @@ -984,14 +929,12 @@ describe("resolveSandboxCreateIntent", () => { gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: null, }); expect(() => materializeSandboxCreatePlan({ intent, fromRef: `ghcr.io/nvidia/nemoclaw/hermes@sha256:${"a".repeat(64)}`, - policyAuthority: "nemoclaw-managed", managedStateMount: { type: "volume", source: "nemoclaw-hermes-state-v1-hermes-box", @@ -1026,7 +969,6 @@ describe("resolveSandboxCreateIntent", () => { gpuCreateArgs: [], gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, - policyTier: null, }); const cleanupPolicy = vi.fn(() => true); const cleanupProviders = vi.fn(); @@ -1036,7 +978,6 @@ describe("resolveSandboxCreateIntent", () => { materializeSandboxCreatePlan({ intent, fromRef: "/tmp/nemoclaw-build-1/Dockerfile", - policyAuthority: "nemoclaw-managed", messagingTokenDefs: [], prepareInitialSandboxCreatePolicy: vi.fn(() => ({ policyPath: "/tmp/policy.yaml", @@ -1126,13 +1067,11 @@ describe("resolveSandboxCreateIntent", () => { gpuCreateArgs: [], gpuRoutePlan: "native-only", sandboxGpuLogMessage: null, - policyTier: null, }); const plan = materializeSandboxCreatePlan({ intent, fromRef: reference, - policyAuthority: "nemoclaw-managed", messagingTokenDefs: [], prepareInitialSandboxCreatePolicy: vi.fn(() => ({ policyPath: "/tmp/policy.yaml", diff --git a/src/lib/onboard/sandbox-create-plan.ts b/src/lib/onboard/sandbox-create-plan.ts index a725df8c1cb..3f8c51da58a 100644 --- a/src/lib/onboard/sandbox-create-plan.ts +++ b/src/lib/onboard/sandbox-create-plan.ts @@ -28,10 +28,8 @@ export { // the create-time policy decision. const KNOWN_POLICY_TIER_NAMES = new Set(["restricted", "balanced", "open", "personal"]); -export function resolveSandboxCreatePolicyTier( - authoritativePolicyTier?: string | null, -): string | null { - if (authoritativePolicyTier !== undefined) return authoritativePolicyTier; +export function resolveSandboxCreatePolicyTier(requestedPolicyTier?: string | null): string | null { + if (requestedPolicyTier !== undefined) return requestedPolicyTier; // Only trust the env value in non-interactive mode. Interactive flows let the // operator override the tier via the selector after sandbox creation; if the // env said balanced but the operator picks restricted, an interactive trust diff --git a/src/lib/onboard/sandbox-create/identity-boundary.ts b/src/lib/onboard/sandbox-create/identity-boundary.ts new file mode 100644 index 00000000000..f70bd21d3be --- /dev/null +++ b/src/lib/onboard/sandbox-create/identity-boundary.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizePendingSandboxCreateIdentity } from "../../state/registry-normalization"; +import type { PendingSandboxCreateIdentity } from "../../state/registry/types"; +import type { VerifiedSandboxCreateBoundary } from "../types"; + +/** Flatten one create boundary into its bounded incomplete-create identity. */ +export function pendingSandboxCreateIdentityForBoundary( + boundary: VerifiedSandboxCreateBoundary, +): PendingSandboxCreateIdentity { + return { + schemaVersion: 1, + state: "verified-create", + gatewayName: boundary.gatewayName, + gatewayPort: boundary.gatewayPort, + sandboxName: boundary.sandboxName, + lifecycleGeneration: boundary.lifecycleGeneration, + sandboxIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint, + ...(boundary.createAttemptNonce ? { createAttemptNonce: boundary.createAttemptNonce } : {}), + route: boundary.route, + }; +} + +/** Restore the process-local create boundary from one bounded identity. */ +export function sandboxCreateBoundaryFromPendingIdentity( + value: unknown, +): VerifiedSandboxCreateBoundary { + const identity = normalizePendingSandboxCreateIdentity(value); + if (!identity) throw new Error("Pending sandbox create identity is unavailable."); + return { + sandboxName: identity.sandboxName, + gatewayName: identity.gatewayName, + gatewayPort: identity.gatewayPort, + lifecycleGeneration: identity.lifecycleGeneration, + lifecycleLiveIdentityFingerprint: identity.sandboxIdentityFingerprint, + ...(identity.createAttemptNonce ? { createAttemptNonce: identity.createAttemptNonce } : {}), + route: identity.route, + }; +} diff --git a/src/lib/onboard/sandbox-create/live-policy-requirements.test.ts b/src/lib/onboard/sandbox-create/live-policy-requirements.test.ts new file mode 100644 index 00000000000..5b1e7a995a5 --- /dev/null +++ b/src/lib/onboard/sandbox-create/live-policy-requirements.test.ts @@ -0,0 +1,113 @@ +// 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 state = vi.hoisted(() => ({ + assertGateway: vi.fn(), + captureBasePolicy: vi.fn(), + inspectPolicy: vi.fn(), + inspectReadiness: vi.fn(), +})); + +vi.mock("../../adapters/openshell/policy-state", async (importOriginal) => ({ + ...(await importOriginal()), + assertOpenShellGatewayPortBinding: state.assertGateway, + captureSandboxBasePolicy: state.captureBasePolicy, + inspectOpenShellSandboxPolicyReadiness: state.inspectReadiness, + inspectSandboxPolicy: state.inspectPolicy, +})); + +import { verifyLiveCreatedSandboxPolicyRequirements } from "./live-policy-requirements"; + +const IDENTITY = "a".repeat(64); +const REQUIRED_POLICY = ` +version: 1 +network_policies: + required: + name: required + endpoints: + - host: example.com + port: 443 +`; + +function inspection(activeVersion: number) { + return { + policySource: "sandbox" as const, + effectivePolicy: { + version: 1, + network_policies: { + required: { + name: "provider-composed-drift", + endpoints: [{ host: "provider.example.com", port: 443 }], + }, + }, + }, + policyIdentity: { hash: `hash-${String(activeVersion)}`, activeVersion }, + }; +} + +function verify(sleep = vi.fn()) { + verifyLiveCreatedSandboxPolicyRequirements( + { + sandboxName: "alpha", + gatewayName: "nemoclaw", + gatewayPort: 8080, + lifecycleLiveIdentityFingerprint: IDENTITY, + policySourcePath: "/tmp/required-policy.yaml", + operation: "continue onboarding", + }, + { readFile: () => REQUIRED_POLICY, sleep }, + ); + return sleep; +} + +describe("live created sandbox policy requirements", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.captureBasePolicy.mockReturnValue(REQUIRED_POLICY); + state.inspectPolicy.mockReturnValue(inspection(7)); + }); + + it("waits for OpenShell's exact live policy version without recording ownership", () => { + state.inspectReadiness + .mockReturnValueOnce({ state: "transient", reason: "policy-version-pending" }) + .mockReturnValueOnce({ state: "ready" }); + + const sleep = verify(); + + expect(sleep).toHaveBeenCalledExactlyOnceWith(1_000); + expect(state.inspectReadiness).toHaveBeenCalledTimes(2); + expect(state.inspectReadiness).toHaveBeenLastCalledWith({ + sandboxName: "alpha", + gatewayName: "nemoclaw", + sandboxIdentityFingerprint: IDENTITY, + policyVersion: 7, + }); + expect(state.inspectPolicy).toHaveBeenCalledTimes(3); + expect(state.captureBasePolicy).toHaveBeenCalledTimes(3); + }); + + it("checks requirements against OpenShell's base policy, not provider composition", () => { + state.inspectReadiness.mockReturnValue({ state: "ready" }); + + verify(); + + expect(state.inspectPolicy).toHaveBeenCalledTimes(2); + expect(state.captureBasePolicy).toHaveBeenCalledTimes(2); + }); + + it("fails after the bounded OpenShell convergence window", () => { + state.inspectReadiness.mockReturnValue({ + state: "transient", + reason: "sandbox-not-ready", + }); + const sleep = vi.fn(); + + expect(() => verify(sleep)).toThrow( + "Refusing to continue onboarding: the exact sandbox is not Ready.", + ); + expect(state.inspectReadiness).toHaveBeenCalledTimes(5); + expect(sleep).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/lib/onboard/sandbox-create/live-policy-requirements.ts b/src/lib/onboard/sandbox-create/live-policy-requirements.ts new file mode 100644 index 00000000000..47979b96633 --- /dev/null +++ b/src/lib/onboard/sandbox-create/live-policy-requirements.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { + assertOpenShellGatewayPortBinding, + captureSandboxBasePolicy, + inspectOpenShellSandboxPolicyReadiness, + inspectSandboxPolicy, + PolicyObservationError, +} from "../../adapters/openshell/policy-state"; +import { assertPolicyRequirementContainment, parseOpenShellPolicy } from "../../policy/merge"; + +const POLICY_READINESS_MAX_OBSERVATIONS = 5; +const POLICY_READINESS_POLL_INTERVAL_MS = 1_000; + +function sleepForPolicyConvergence(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +export interface LiveCreatedSandboxPolicyRequirementsInput { + readonly sandboxName: string; + readonly gatewayName: string; + readonly gatewayPort: number; + readonly lifecycleLiveIdentityFingerprint: string; + readonly policySourcePath: string; +} + +export interface LiveCreatedSandboxPolicyRequirementsDeps { + readonly captureBasePolicy?: typeof captureSandboxBasePolicy; + readonly readFile?: (path: string, encoding: "utf8") => string; + readonly inspectPolicy?: typeof inspectSandboxPolicy; + readonly inspectPolicyReadiness?: typeof inspectOpenShellSandboxPolicyReadiness; + readonly sleep?: (milliseconds: number) => void; +} + +export interface LiveCreatedSandboxPolicyRequirementsCheck extends LiveCreatedSandboxPolicyRequirementsInput { + readonly operation: string; +} + +/** Verify a created sandbox against the current live OpenShell policy. */ +export function verifyLiveCreatedSandboxPolicyRequirements( + input: LiveCreatedSandboxPolicyRequirementsCheck, + deps: LiveCreatedSandboxPolicyRequirementsDeps = {}, +): void { + assertOpenShellGatewayPortBinding({ + gatewayName: input.gatewayName, + gatewayPort: input.gatewayPort, + }); + let requiredPolicy: ReturnType["policy"]; + try { + requiredPolicy = parseOpenShellPolicy( + (deps.readFile ?? fs.readFileSync)(input.policySourcePath, "utf8"), + ).policy; + } catch { + throw new PolicyObservationError( + `Refusing to ${input.operation}: the required sandbox policy could not be read.`, + ); + } + const inspectPolicy = deps.inspectPolicy ?? inspectSandboxPolicy; + const inspectPolicyReadiness = + deps.inspectPolicyReadiness ?? inspectOpenShellSandboxPolicyReadiness; + const captureBasePolicy = deps.captureBasePolicy ?? captureSandboxBasePolicy; + const assertBasePolicyRequirements = ( + inspection: ReturnType, + ): void => { + const basePolicy = parseOpenShellPolicy( + captureBasePolicy(input.sandboxName, input.gatewayName), + ).policy; + assertPolicyRequirementContainment( + { ...inspection, effectivePolicy: basePolicy }, + requiredPolicy, + ); + }; + let lastFailure = "the exact sandbox policy did not converge"; + let ready = false; + for (let attempt = 0; attempt < POLICY_READINESS_MAX_OBSERVATIONS; attempt += 1) { + ready = (() => { + const before = inspectPolicy({ + sandboxName: input.sandboxName, + gatewayName: input.gatewayName, + }); + try { + assertBasePolicyRequirements(before); + } catch (error) { + lastFailure = error instanceof Error ? error.message : String(error); + return false; + } + const readiness = inspectPolicyReadiness({ + sandboxName: input.sandboxName, + gatewayName: input.gatewayName, + sandboxIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, + policyVersion: before.policyIdentity.activeVersion, + }); + if (readiness.state !== "ready") { + lastFailure = + readiness.reason === "sandbox-not-ready" + ? "the exact sandbox is not Ready" + : "the observed policy version is not active"; + return false; + } + const after = inspectPolicy({ + sandboxName: input.sandboxName, + gatewayName: input.gatewayName, + }); + if ( + after.policyIdentity.hash !== before.policyIdentity.hash || + after.policyIdentity.activeVersion !== before.policyIdentity.activeVersion + ) { + lastFailure = "the live OpenShell policy changed during verification"; + return false; + } + try { + assertBasePolicyRequirements(after); + return true; + } catch (error) { + lastFailure = error instanceof Error ? error.message : String(error); + return false; + } + })(); + if (ready) break; + if (attempt + 1 < POLICY_READINESS_MAX_OBSERVATIONS) { + (deps.sleep ?? sleepForPolicyConvergence)(POLICY_READINESS_POLL_INTERVAL_MS); + } + } + if (!ready) { + throw new PolicyObservationError(`Refusing to ${input.operation}: ${lastFailure}.`); + } +} diff --git a/src/lib/onboard/sandbox-create/orchestration.test.ts b/src/lib/onboard/sandbox-create/orchestration.test.ts index 2b3bf8d4aab..6ed78383c5c 100644 --- a/src/lib/onboard/sandbox-create/orchestration.test.ts +++ b/src/lib/onboard/sandbox-create/orchestration.test.ts @@ -7,13 +7,13 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { PolicyAuthorityRefusalError } from "../../adapters/openshell/policy-authority"; +import { PolicyObservationError } from "../../adapters/openshell/policy-state"; import type { SandboxEntry } from "../../state/registry"; +import type { SandboxCreateIntent as ResolvedSandboxCreateIntent } from "../sandbox-create-intent-types"; import { runSandboxProviderPreDeleteCleanup } from "../sandbox-provider-cleanup"; import { - applyManagedSandboxRebuildPolicyCarryForward, assertApfCreateIntent, - backfillVerifiedExternalSandboxPolicyAuthority, + bindRebuildPolicyProvidersToCreateIntent, completeHermesPortableSandboxRegistration, createProviderEffectBoundary, finalizeCreatedSandboxBeforeHermesCredentialReconciliation, @@ -24,10 +24,9 @@ import { readManagedDcodeCreateSelectionDrift, readSandboxRecreateRegistryEntry, reconcileCreatedHermesCredentialEnvironment, - resolveSandboxCreatePolicyAuthority, runAuthorityBoundProviderCleanup, runAsyncWithPostCreateRecovery, - runSandboxCreateWithPolicyAuthorityChecks, + runSandboxCreateWithPolicyVerification, runWithPostCreateRecovery, } from "./orchestration"; @@ -35,11 +34,36 @@ const UNVERIFIED_RECOVERY_CONTEXT = { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, - createAttemptNonce: "c".repeat(62), - policyCreationReceipt: null, + createAttemptNonce: "a".repeat(62), } as const; +describe("rebuild policy provider handoff", () => { + it("adds exact credential-binding providers to the replacement create intent", () => { + const original = { + extraProviders: ["operator-provider"], + } as unknown as ResolvedSandboxCreateIntent; + const rebound = bindRebuildPolicyProvidersToCreateIntent( + original, + [ + "version: 1", + "network_policies:", + " managed_mcp:", + " endpoints:", + " - credential_binding:", + " provider: mcp-provider", + " duplicate_binding:", + " endpoints:", + " - credential_binding:", + " provider: operator-provider", + "", + ].join("\n"), + ); + + expect(rebound.extraProviders).toEqual(["operator-provider", "mcp-provider"]); + expect(original.extraProviders).toEqual(["operator-provider"]); + }); +}); + describe("created Hermes credential environment reconciliation", () => { const plan = { agent: "hermes" } as never; @@ -70,7 +94,7 @@ describe("created Hermes credential environment reconciliation", () => { reconcileCreatedHermesCredentialEnvironment( { sandboxName: "alpha", plan }, { - revalidatePolicyAuthority: (operation) => events.push(`policy:${operation}`), + verifyLivePolicyRequirements: (operation) => events.push(`policy:${operation}`), reconcileCredentialEnv: () => { events.push("reconcile"); return { changed: true }; @@ -109,7 +133,7 @@ describe("created Hermes credential environment reconciliation", () => { reconcileCreatedHermesCredentialEnvironment( { sandboxName: "alpha", plan }, { - revalidatePolicyAuthority: vi.fn(), + verifyLivePolicyRequirements: vi.fn(), reconcileCredentialEnv: () => ({ changed: false }), restartGateway, parseRestartCompletion: vi.fn(), @@ -126,8 +150,11 @@ describe("created Hermes credential environment reconciliation", () => { const expectedIdentity = "identity-a"; let liveIdentity = expectedIdentity; const mutations: string[] = []; - const revalidatePolicyAuthority = vi.fn(() => { - liveIdentity === expectedIdentity || (() => { throw new Error("sandbox identity changed"); })(); + const verifyLivePolicyRequirements = vi.fn(() => { + liveIdentity === expectedIdentity || + (() => { + throw new Error("sandbox identity changed"); + })(); liveIdentity = "identity-b"; }); @@ -135,7 +162,7 @@ describe("created Hermes credential environment reconciliation", () => { reconcileCreatedHermesCredentialEnvironment( { sandboxName: "alpha", plan }, { - revalidatePolicyAuthority, + verifyLivePolicyRequirements, reconcileCredentialEnv: ((_plan: never, revalidate?: (operation: string) => void) => { revalidate?.("mutating credential environment"); mutations.push(liveIdentity); @@ -157,7 +184,7 @@ describe("created Hermes credential environment reconciliation", () => { reconcileCreatedHermesCredentialEnvironment( { sandboxName: "alpha", plan }, { - revalidatePolicyAuthority: vi.fn(), + verifyLivePolicyRequirements: vi.fn(), reconcileCredentialEnv: () => ({ changed: true }), restartGateway: () => ({ status: 1, stdout: "", stderr: "failed" }), parseRestartCompletion: () => null, @@ -233,19 +260,7 @@ describe("retained create recovery persistence", () => { gatewayName: "nemoclaw-18080", gatewayPort: 18080, lifecycleGeneration: "00000000-0000-4000-8000-000000000004", - verifiedEffectivePolicyIdentity: { hash: "sha256:policy-4", activeVersion: 4 }, - createAttemptNonce: "d".repeat(62), - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw-18080", - gatewayPort: 18080, - sandboxName: "alpha", - lifecycleGeneration: "00000000-0000-4000-8000-000000000004", - sandboxIdentityFingerprint: "f".repeat(64), - policyHash: "sha256:policy-4", - policyVersion: 4, - }, + createAttemptNonce: "b".repeat(62), } as const; const markRetainedSandboxRecovery = vi.fn(() => true); const input = { @@ -436,22 +451,6 @@ describe("retained create recovery persistence", () => { }); describe("APF create policy selection", () => { - it("selects a policyless external plan only from an absent global policy (#9833)", () => { - expect(resolveSandboxCreatePolicyAuthority("nemoclaw-managed", true)).toBe( - "externally-managed", - ); - expect(resolveSandboxCreatePolicyAuthority("nemoclaw-managed", false)).toBe("nemoclaw-managed"); - expect(resolveSandboxCreatePolicyAuthority("externally-managed", false)).toBe( - "externally-managed", - ); - }); - - it("refuses APF creation when an active global policy exists (#9833)", () => { - expect(() => resolveSandboxCreatePolicyAuthority("externally-managed", true)).toThrow( - /active global policy to be absent/u, - ); - }); - it("requires APF effects to use the generic post-create gate (#9833)", () => { expect(() => assertApfCreateIntent({ @@ -506,7 +505,7 @@ describe("deferred provider effect authority", () => { it("refuses provider cleanup when a sandbox appears after verified absence (#9833)", () => { let observationCount = 0; - const revalidatePolicyAuthority = vi.fn(); + const verifyLivePolicyRequirements = vi.fn(); const runOpenshell = vi.fn(() => ({ pid: 1, output: [null, "", ""], @@ -523,19 +522,19 @@ describe("deferred provider effect authority", () => { observationCount++ === 0 ? { state: "missing", liveIdentityFingerprint: null } : { state: "ready", liveIdentityFingerprint: "f".repeat(64) }, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, runProviderPreDeleteCleanup: runSandboxProviderPreDeleteCleanup, runOpenshell, redact: (value) => value, tolerateMissingSandbox: true, }), ).toThrow(/appeared after absence was verified/u); - expect(revalidatePolicyAuthority).toHaveBeenCalledOnce(); + expect(verifyLivePolicyRequirements).toHaveBeenCalledOnce(); expect(runOpenshell).not.toHaveBeenCalled(); }); it("refuses every deferred provider attachment before a same-name replacement can receive credentials (#9833)", async () => { - const revalidatePolicyRequirements = vi.fn(); + const verifyLivePolicyRequirements = vi.fn(); const runOpenshell = vi.fn(() => ({ status: 0 })); const boundary = createProviderEffectBoundary({ deferred: true, @@ -559,26 +558,20 @@ describe("deferred provider effect authority", () => { revalidate("cleaning up providers for sandbox 'alpha'"); return ["first", "second"]; }, - revalidatePolicyAuthorityBeforeCreate: vi.fn(), + verifyLivePolicyRequirementsBeforeCreate: vi.fn(), }); const runAfterVerifiedCreate = boundary.runAfterVerifiedCreate; expect(runAfterVerifiedCreate).toBeTypeOf("function"); await expect( runAfterVerifiedCreate?.({ - registration: { - policyAuthority: "externally-managed", - policyCreationReceipt: null, - observedPolicyAuthority: "externally-managed", - policyIdentity: { hash: "b".repeat(64), activeVersion: 1 }, - }, sandboxName: "alpha", gatewayName: "nemoclaw", gatewayPort: 18790, lifecycleGeneration: "generation-1", lifecycleLiveIdentityFingerprint: "a".repeat(64), route: "direct" as never, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), ).rejects.toThrow("OpenShell cannot attach providers to the immutable identity"); @@ -586,42 +579,12 @@ describe("deferred provider effect authority", () => { expect.arrayContaining(["sandbox", "provider", "attach"]), expect.anything(), ); - expect(revalidatePolicyRequirements).toHaveBeenCalledWith( + expect(verifyLivePolicyRequirements).toHaveBeenCalledWith( "attaching deferred providers to sandbox 'alpha'", ); }); }); -describe("policy authority backfill", () => { - it("does not assign managed authority before completed sandbox registration (#9833)", () => { - const updateSandbox = vi.fn(() => true); - - backfillVerifiedExternalSandboxPolicyAuthority({ - sandboxName: "alpha", - existingEntry: { name: "alpha", pendingRouteReservation: true }, - policyAuthority: "nemoclaw-managed", - updateSandbox, - }); - - expect(updateSandbox).not.toHaveBeenCalled(); - }); - - it("records verified external authority on an unattributed existing row (#9833)", () => { - const updateSandbox = vi.fn(() => true); - - backfillVerifiedExternalSandboxPolicyAuthority({ - sandboxName: "alpha", - existingEntry: { name: "alpha" }, - policyAuthority: "externally-managed", - updateSandbox, - }); - - expect(updateSandbox).toHaveBeenCalledExactlyOnceWith("alpha", { - policyAuthority: "externally-managed", - }); - }); -}); - describe("managed MCP rebuild handoff", () => { const targetIntentFingerprint = "a".repeat(64); const recreateTransaction = { @@ -658,75 +621,6 @@ describe("managed MCP rebuild handoff", () => { }); }); -describe("authoritative rebuild policy carry-forward", () => { - it("preserves an intentionally empty managed preset selection (#9792)", () => { - const note = vi.fn(); - const applyRecreatePolicyCarryForward = vi.fn(); - const revalidatePolicyAuthority = vi.fn(); - - applyManagedSandboxRebuildPolicyCarryForward( - { - sandboxName: "alpha", - policyAuthority: "nemoclaw-managed", - nonInteractive: true, - note, - rebuildPolicyPresets: [], - revalidatePolicyAuthority, - }, - applyRecreatePolicyCarryForward, - ); - - expect(applyRecreatePolicyCarryForward).toHaveBeenCalledExactlyOnceWith( - "alpha", - true, - note, - [], - ); - }); - - it("does not carry managed presets into a live external recreation (#9833)", () => { - const applyRecreatePolicyCarryForward = vi.fn(); - const revalidatePolicyAuthority = vi.fn(); - - applyManagedSandboxRebuildPolicyCarryForward( - { - sandboxName: "alpha", - policyAuthority: "externally-managed", - nonInteractive: true, - note: vi.fn(), - rebuildPolicyPresets: ["github"], - revalidatePolicyAuthority, - }, - applyRecreatePolicyCarryForward, - ); - - expect(revalidatePolicyAuthority).not.toHaveBeenCalled(); - expect(applyRecreatePolicyCarryForward).not.toHaveBeenCalled(); - }); - - it("revalidates managed authority before live recreate policy carry-forward (#9833)", () => { - const applyRecreatePolicyCarryForward = vi.fn(); - const revalidatePolicyAuthority = vi.fn(() => { - throw new Error("policy authority changed"); - }); - - expect(() => - applyManagedSandboxRebuildPolicyCarryForward( - { - sandboxName: "alpha", - policyAuthority: "nemoclaw-managed", - nonInteractive: true, - note: vi.fn(), - rebuildPolicyPresets: ["github"], - revalidatePolicyAuthority, - }, - applyRecreatePolicyCarryForward, - ), - ).toThrow("policy authority changed"); - expect(applyRecreatePolicyCarryForward).not.toHaveBeenCalled(); - }); -}); - describe("sandbox recreate registry authority", () => { it("re-reads the durable source row for Hermes portable recreation (#10056)", () => { const durable = { name: "alpha", lifecycleGeneration: "source-generation" } as SandboxEntry; @@ -836,40 +730,40 @@ describe("Hermes portable registration adapter", () => { }); }); -describe("sandbox create policy authority checks", () => { +describe("sandbox create policy requirements checks", () => { const exactIdentity = "a".repeat(64); - const verifiedPolicyBoundary = () => ({ - verifyCreatedPolicy: vi.fn(() => "verified"), - persistVerifiedPolicy: vi.fn(), - revalidateVerifiedPolicy: vi.fn(), + const verifiedCreateBoundary = () => ({ + verifyCreatedPolicyRequirements: vi.fn(() => "verified"), + persistCreateIdentity: vi.fn(), + verifyCurrentPolicyRequirements: vi.fn(), }); const exactIdentityBoundary = () => ({ captureCreatedSandboxIdentity: vi.fn(() => exactIdentity), persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - ...verifiedPolicyBoundary(), + ...verifiedCreateBoundary(), }); it("refuses sandbox creation before mutation when the final check fails (#9833)", async () => { const create = vi.fn(async () => "created"); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: () => { - throw new Error("external policy authority must supply the selected route"); + throw new Error("live policy requirements changed before the selected route"); }, ...exactIdentityBoundary(), create, cleanupTemporarySources: vi.fn(), }), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(create).not.toHaveBeenCalled(); }); it("checks the named Ready sandbox before registration can continue (#9833)", async () => { const events: string[] = []; - const result = await runSandboxCreateWithPolicyAuthorityChecks({ + const result = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: (sandboxIsLive) => events.push(sandboxIsLive ? "ready-check" : "create-check"), create: async (verifyCreatedSandbox) => { @@ -883,12 +777,12 @@ describe("sandbox create policy authority checks", () => { }, persistCreatedSandboxIdentity: () => events.push("persist-identity"), revalidateCreatedSandboxIdentity: () => events.push("identity-check"), - verifyCreatedPolicy: () => { + verifyCreatedPolicyRequirements: () => { events.push("policy-check"); return "verified"; }, - persistVerifiedPolicy: () => events.push("persist-checkpoint"), - revalidateVerifiedPolicy: () => events.push("revalidate-checkpoint"), + persistCreateIdentity: () => events.push("persist-checkpoint"), + verifyCurrentPolicyRequirements: () => events.push("revalidate-checkpoint"), cleanupTemporarySources: vi.fn(), }); events.push("register"); @@ -914,7 +808,7 @@ describe("sandbox create policy authority checks", () => { const events: string[] = []; const revalidate = vi.fn(() => events.push("create-check")); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate, create: async (verifyCreatedSandbox) => { @@ -923,9 +817,9 @@ describe("sandbox create policy authority checks", () => { return "created"; }, ...exactIdentityBoundary(), - revalidateVerifiedPolicy: () => { + verifyCurrentPolicyRequirements: () => { events.push("ready-check"); - throw new Error("external policy authority changed"); + throw new Error("external policy requirements changed"); }, cleanupTemporarySources: () => events.push("cleanup-sources"), }).catch((caught: unknown) => caught); @@ -956,7 +850,7 @@ describe("sandbox create policy authority checks", () => { const persistRetainedSandboxRecovery = vi.fn(() => true); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -964,8 +858,8 @@ describe("sandbox create policy authority checks", () => { return "created"; }, ...exactIdentityBoundary(), - revalidateVerifiedPolicy: () => { - throw new Error("external policy authority changed"); + verifyCurrentPolicyRequirements: () => { + throw new Error("external policy requirements changed"); }, persistRetainedSandboxRecovery, cleanupTemporarySources: vi.fn(), @@ -980,12 +874,12 @@ describe("sandbox create policy authority checks", () => { ); }); - it("retains verified policy evidence when checkpoint persistence fails (#9833)", async () => { + it("retains the exact create identity when checkpoint persistence fails (#9833)", async () => { const verifiedEvidence = { policyHash: "sha256:policy-4", policyVersion: 4 } as const; const persistRetainedSandboxRecovery = vi.fn(() => true); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -993,8 +887,8 @@ describe("sandbox create policy authority checks", () => { return "created"; }, ...exactIdentityBoundary(), - verifyCreatedPolicy: () => verifiedEvidence, - persistVerifiedPolicy: () => { + verifyCreatedPolicyRequirements: () => verifiedEvidence, + persistCreateIdentity: () => { throw new Error("checkpoint write failed"); }, persistRetainedSandboxRecovery, @@ -1014,7 +908,7 @@ describe("sandbox create policy authority checks", () => { const createFailure = new Error("runtime patch failed after verification"); const persistRetainedSandboxRecovery = vi.fn(() => true); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1069,7 +963,7 @@ describe("sandbox create policy authority checks", () => { throw createFailure; }); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create, @@ -1097,7 +991,7 @@ describe("sandbox create policy authority checks", () => { const revalidate = vi.fn(); const revalidateCreatedSandboxIdentity = vi.fn(); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate, create: async (verifyCreatedSandbox) => { @@ -1107,8 +1001,8 @@ describe("sandbox create policy authority checks", () => { captureCreatedSandboxIdentity: () => exactIdentity, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity, - ...verifiedPolicyBoundary(), - revalidateVerifiedPolicy: () => { + ...verifiedCreateBoundary(), + verifyCurrentPolicyRequirements: () => { sandboxIdentity = "replacement"; throw new Error("sandbox identity changed"); }, @@ -1136,7 +1030,7 @@ describe("sandbox create policy authority checks", () => { expect(revalidateCreatedSandboxIdentity).toHaveBeenNthCalledWith( 2, exactIdentity, - "recording verified policy for sandbox 'alpha'", + "recording pending create identity for sandbox 'alpha'", ); expect(sandboxIdentity).toBe("replacement"); }); @@ -1163,9 +1057,9 @@ describe("sandbox create policy authority checks", () => { }, runVerifiedSandboxCreateEffects: null, activateDeferredProviderEffects: () => ["credential-provider"], - revalidatePolicyAuthorityBeforeCreate: vi.fn(), + verifyLivePolicyRequirementsBeforeCreate: vi.fn(), }); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1173,33 +1067,18 @@ describe("sandbox create policy authority checks", () => { return "created"; }, ...exactIdentityBoundary(), - persistVerifiedPolicy: () => { + persistCreateIdentity: () => { checkpoint.state = "verified-create"; }, runVerifiedCreateEffects: async () => { await providerBoundary.runAfterVerifiedCreate?.({ - registration: { - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "alpha", - lifecycleGeneration: "00000000-0000-4000-8000-000000000001", - sandboxIdentityFingerprint: exactIdentity, - policyHash: "policy-alpha", - policyVersion: 1, - }, - observedPolicyAuthority: "owner-unknown", - }, sandboxName: "alpha", gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "00000000-0000-4000-8000-000000000001", lifecycleLiveIdentityFingerprint: exactIdentity, route: "none", - revalidatePolicyRequirements: vi.fn(), + verifyLivePolicyRequirements: vi.fn(), }); }, cleanupTemporarySources: vi.fn(), @@ -1213,7 +1092,7 @@ describe("sandbox create policy authority checks", () => { it("reports temporary source cleanup failure with sandbox preservation (#9833)", async () => { const revalidate = vi.fn(); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate, create: async (verifyCreatedSandbox) => { @@ -1221,8 +1100,8 @@ describe("sandbox create policy authority checks", () => { return "created"; }, ...exactIdentityBoundary(), - revalidateVerifiedPolicy: () => { - throw new Error("external policy authority changed"); + verifyCurrentPolicyRequirements: () => { + throw new Error("external policy requirements changed"); }, cleanupTemporarySources: () => { throw new Error("temporary source cleanup failed"); @@ -1241,7 +1120,7 @@ describe("sandbox create policy authority checks", () => { it("runs continuation effects only after policy and identity verification (#9833)", async () => { const events: string[] = []; - const result = await runSandboxCreateWithPolicyAuthorityChecks({ + const result = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: (sandboxIsLive) => events.push(sandboxIsLive ? "policy" : "preflight"), create: async (verifyCreatedSandbox) => { @@ -1258,12 +1137,12 @@ describe("sandbox create policy authority checks", () => { }, persistCreatedSandboxIdentity: () => events.push("persist-identity"), revalidateCreatedSandboxIdentity: () => events.push("identity"), - verifyCreatedPolicy: () => { + verifyCreatedPolicyRequirements: () => { events.push("policy"); return "verified"; }, - persistVerifiedPolicy: () => events.push("checkpoint"), - revalidateVerifiedPolicy: () => events.push("checkpoint-revalidate"), + persistCreateIdentity: () => events.push("checkpoint"), + verifyCurrentPolicyRequirements: () => events.push("checkpoint-revalidate"), cleanupTemporarySources: vi.fn(), }); @@ -1285,10 +1164,10 @@ describe("sandbox create policy authority checks", () => { }); it("withholds checkpoint and effects when post-create policy verification fails (#9833)", async () => { - const persistVerifiedPolicy = vi.fn(); + const persistCreateIdentity = vi.fn(); const runVerifiedCreateEffects = vi.fn(); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1298,27 +1177,27 @@ describe("sandbox create policy authority checks", () => { captureCreatedSandboxIdentity: () => exactIdentity, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: () => { - throw new PolicyAuthorityRefusalError("policy verification failed"); + verifyCreatedPolicyRequirements: () => { + throw new PolicyObservationError("policy verification failed"); }, - persistVerifiedPolicy, - revalidateVerifiedPolicy: vi.fn(), + persistCreateIdentity, + verifyCurrentPolicyRequirements: vi.fn(), runVerifiedCreateEffects, cleanupTemporarySources: vi.fn(), }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(AggregateError); expect((error as AggregateError).message).toContain("policy verification failed"); - expect(persistVerifiedPolicy).not.toHaveBeenCalled(); + expect(persistCreateIdentity).not.toHaveBeenCalled(); expect(runVerifiedCreateEffects).not.toHaveBeenCalled(); }); it("withholds effects when durable checkpoint persistence fails (#9833)", async () => { - const revalidateVerifiedPolicy = vi.fn(); + const verifyCurrentPolicyRequirements = vi.fn(); const runVerifiedCreateEffects = vi.fn(); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1328,26 +1207,26 @@ describe("sandbox create policy authority checks", () => { captureCreatedSandboxIdentity: () => exactIdentity, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: () => "verified", - persistVerifiedPolicy: () => { + verifyCreatedPolicyRequirements: () => "verified", + persistCreateIdentity: () => { throw new Error("checkpoint persistence failed"); }, - revalidateVerifiedPolicy, + verifyCurrentPolicyRequirements, runVerifiedCreateEffects, cleanupTemporarySources: vi.fn(), }), ).rejects.toThrow("automatic sandbox cleanup was not safe"); - expect(revalidateVerifiedPolicy).not.toHaveBeenCalled(); + expect(verifyCurrentPolicyRequirements).not.toHaveBeenCalled(); expect(runVerifiedCreateEffects).not.toHaveBeenCalled(); }); it("retains the checkpoint and withholds effects when its reread fails (#9833)", async () => { - const persistVerifiedPolicy = vi.fn(); + const persistCreateIdentity = vi.fn(); const runVerifiedCreateEffects = vi.fn(); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1357,9 +1236,9 @@ describe("sandbox create policy authority checks", () => { captureCreatedSandboxIdentity: () => exactIdentity, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: () => "verified", - persistVerifiedPolicy, - revalidateVerifiedPolicy: () => { + verifyCreatedPolicyRequirements: () => "verified", + persistCreateIdentity, + verifyCurrentPolicyRequirements: () => { throw new Error("durable checkpoint missing"); }, runVerifiedCreateEffects, @@ -1367,15 +1246,15 @@ describe("sandbox create policy authority checks", () => { }), ).rejects.toThrow("automatic sandbox cleanup was not safe"); - expect(persistVerifiedPolicy).toHaveBeenCalledOnce(); + expect(persistCreateIdentity).toHaveBeenCalledOnce(); expect(runVerifiedCreateEffects).not.toHaveBeenCalled(); }); it("retains the durable checkpoint when a deferred effect fails (#9833)", async () => { - const persistVerifiedPolicy = vi.fn(); + const persistCreateIdentity = vi.fn(); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1385,9 +1264,9 @@ describe("sandbox create policy authority checks", () => { captureCreatedSandboxIdentity: () => exactIdentity, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: () => "verified", - persistVerifiedPolicy, - revalidateVerifiedPolicy: vi.fn(), + verifyCreatedPolicyRequirements: () => "verified", + persistCreateIdentity, + verifyCurrentPolicyRequirements: vi.fn(), runVerifiedCreateEffects: async () => { throw new Error("provider effect failed"); }, @@ -1395,7 +1274,7 @@ describe("sandbox create policy authority checks", () => { }), ).rejects.toThrow("automatic sandbox cleanup was not safe"); - expect(persistVerifiedPolicy).toHaveBeenCalledOnce(); + expect(persistCreateIdentity).toHaveBeenCalledOnce(); }); it("refuses continuation when identity changes during effective-policy verification (#9833)", async () => { @@ -1413,7 +1292,7 @@ describe("sandbox create policy authority checks", () => { }); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate, create: async (verifyCreatedSandbox) => { @@ -1424,7 +1303,7 @@ describe("sandbox create policy authority checks", () => { captureCreatedSandboxIdentity: () => exactIdentity, persistCreatedSandboxIdentity: vi.fn(), revalidateCreatedSandboxIdentity, - ...verifiedPolicyBoundary(), + ...verifiedCreateBoundary(), persistRetainedSandboxRecovery, cleanupTemporarySources: vi.fn(), }), @@ -1441,12 +1320,12 @@ describe("sandbox create policy authority checks", () => { it("stops before policy verification when the exact identity cannot be persisted (#9833)", async () => { const revalidateCreatedSandboxIdentity = vi.fn(); - const verifyCreatedPolicy = vi.fn(); - const persistVerifiedPolicy = vi.fn(); + const verifyCreatedPolicyRequirements = vi.fn(); + const persistCreateIdentity = vi.fn(); const runVerifiedCreateEffects = vi.fn(); await expect( - runSandboxCreateWithPolicyAuthorityChecks({ + runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async (verifyCreatedSandbox) => { @@ -1458,24 +1337,24 @@ describe("sandbox create policy authority checks", () => { throw new Error("durable identity journal unavailable"); }, revalidateCreatedSandboxIdentity, - verifyCreatedPolicy, - persistVerifiedPolicy, - revalidateVerifiedPolicy: vi.fn(), + verifyCreatedPolicyRequirements, + persistCreateIdentity, + verifyCurrentPolicyRequirements: vi.fn(), runVerifiedCreateEffects, cleanupTemporarySources: vi.fn(), }), ).rejects.toThrow("automatic sandbox cleanup was not safe"); expect(revalidateCreatedSandboxIdentity).not.toHaveBeenCalled(); - expect(verifyCreatedPolicy).not.toHaveBeenCalled(); - expect(persistVerifiedPolicy).not.toHaveBeenCalled(); + expect(verifyCreatedPolicyRequirements).not.toHaveBeenCalled(); + expect(persistCreateIdentity).not.toHaveBeenCalled(); expect(runVerifiedCreateEffects).not.toHaveBeenCalled(); }); it("fails closed when a create implementation skips the post-create gate (#9833)", async () => { const cleanupTemporarySources = vi.fn(); - const error = await runSandboxCreateWithPolicyAuthorityChecks({ + const error = await runSandboxCreateWithPolicyVerification({ sandboxName: "alpha", revalidate: vi.fn(), create: async () => "created", diff --git a/src/lib/onboard/sandbox-create/orchestration.ts b/src/lib/onboard/sandbox-create/orchestration.ts index 8bb9a29b680..32507d5f588 100644 --- a/src/lib/onboard/sandbox-create/orchestration.ts +++ b/src/lib/onboard/sandbox-create/orchestration.ts @@ -1,16 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { createHermesCredentialEnvReconciliationRuntime } from "../../actions/sandbox/runtime/hermes-lifecycle"; import type { SandboxCreateOrchestrationRuntime } from "../../onboard"; -import { - assertRecordedPolicyAuthority, - isPolicyAuthorityRefusalError, - PolicyAuthorityRefusalError, -} from "../../adapters/openshell/policy-authority"; -import type { SandboxPolicyAuthority } from "../../adapters/openshell/policy-authority"; +import { isPolicyObservationError } from "../../adapters/openshell/policy-state"; import { HERMES_PORTABLE_OPENSHELL_VERSION } from "../../adapters/openshell/resolve-shared"; import type { AgentDefinition } from "../../agent/defs"; import type { WebSearchConfig } from "../../inference/web-search"; @@ -19,11 +15,11 @@ import type { BackupResult } from "../../state/sandbox"; import type { RetainedSandboxRecoveryContext, Session } from "../../state/onboard-session"; import type { SandboxEntry } from "../../state/registry"; import type { - PendingSandboxPolicyVerification, + PendingSandboxCreateIdentity, QualifiedPendingSandboxCreateReservation, } from "../../state/registry"; import type { HermesAuthMethod } from "../hermes-auth"; -import * as policyAuthorityPreflight from "../policy-authority/preflight"; +import { getCredentialBindingProviders } from "../initial-policy"; import type { PreparedSandboxBuildContext } from "../build-context-stage"; import type { DcodeSelectionDriftReader } from "../dcode-selection-drift"; import { assertProviderlessInterceptorEnvironment } from "../entry-options"; @@ -42,35 +38,54 @@ import type { InferenceRouteReservationAuthority, SandboxCreateIntent, VerifiedSandboxCreateEffectsContext, - VerifiedSandboxPolicyBoundary, - VerifiedSandboxPolicyRegistration, + VerifiedSandboxCreateBoundary, } from "../types"; +import type { SandboxCreateIntent as ResolvedSandboxCreateIntent } from "../sandbox-create-intent-types"; import * as sandboxCreatePlanMaterialization from "../sandbox-create-plan-materialization"; import { - pendingSandboxPolicyVerificationForBoundary, - revalidateCreatedSandboxPolicyRegistration, - verifiedSandboxPolicyBoundaryFromPendingCheckpoint, - verifyCreatedApfInterceptorPolicyRegistration, - verifyCreatedSandboxPolicyRegistration, -} from "./policy-creation-receipt"; + pendingSandboxCreateIdentityForBoundary, + sandboxCreateBoundaryFromPendingIdentity, +} from "./identity-boundary"; +import { verifyLiveCreatedSandboxPolicyRequirements } from "./live-policy-requirements"; +import { materializeRebuildCreatePolicy } from "./rebuild-policy-requirements"; import { attachProvidersAfterSandboxCreation, publishAttachedProvidersBeforeDockerSandboxCreation, validateAttachedMessagingProvidersBeforeSandboxCreation, } from "./provider-publication"; -export const createOnboardPolicyAuthorityBindings = - policyAuthorityPreflight.createOnboardPolicyAuthorityBindings; - function cancelRecoveryIdentity( liveExists: boolean, - requireVerifiedPolicyGate: () => VerifiedSandboxPolicyBoundary, + requireVerifiedCreateBoundary: () => VerifiedSandboxCreateBoundary, ): { readonly lifecycleLiveIdentityFingerprint?: string } { if (liveExists) return {}; return { - lifecycleLiveIdentityFingerprint: requireVerifiedPolicyGate().lifecycleLiveIdentityFingerprint, + lifecycleLiveIdentityFingerprint: + requireVerifiedCreateBoundary().lifecycleLiveIdentityFingerprint, }; } +/** Attach every provider named by the exact rebuild policy during sandbox creation. */ +export function bindRebuildPolicyProvidersToCreateIntent( + intent: ResolvedSandboxCreateIntent, + policyContent: string, +): ResolvedSandboxCreateIntent { + const policyProviders = getCredentialBindingProviders(policyContent); + if (policyProviders.length === 0) return intent; + return { + ...intent, + extraProviders: [...new Set([...intent.extraProviders, ...policyProviders])], + }; +} + +function bindRebuildPolicySourceProvidersToCreateIntent( + intent: ResolvedSandboxCreateIntent, + policySourcePath: string | undefined, +): ResolvedSandboxCreateIntent { + return policySourcePath + ? bindRebuildPolicyProvidersToCreateIntent(intent, fs.readFileSync(policySourcePath, "utf8")) + : intent; +} + export function createOnboardCreatedSandboxRegistrationWithManagedLifecycle(input: { readonly sandboxName: string; readonly managedBootstrap: boolean; @@ -271,78 +286,10 @@ export function runWithPostCreateRecovery( } } -export type EffectiveVerifiedSandboxPolicyBoundary = VerifiedSandboxPolicyBoundary & { +export type EffectiveVerifiedSandboxCreateBoundary = VerifiedSandboxCreateBoundary & { readonly policySourcePath: string; }; -interface VerifyCreatedSandboxEffectivePolicyInput { - readonly sandboxName: string; - readonly gatewayName: string; - readonly gatewayPort: number; - readonly lifecycleGeneration: string; - readonly lifecycleLiveIdentityFingerprint: string; - readonly route: import("../docker-gpu-route").SelectedDockerGpuRoute; - readonly hermesPortable: boolean; - readonly effectivePolicySourcePath?: string; - readonly policySourcePathForRoute: () => string; - readonly apfInterceptorRequested: boolean; - readonly plannedAuthority: Exclude; - readonly operation: string; -} - -/** Bind post-create verification to the exact policy source used by this create. */ -export function verifyCreatedSandboxEffectivePolicy( - input: VerifyCreatedSandboxEffectivePolicyInput, -): EffectiveVerifiedSandboxPolicyBoundary { - if (Boolean(input.effectivePolicySourcePath) !== input.hermesPortable) { - throw new Error("Hermes portable create policy authority is incomplete."); - } - if (input.effectivePolicySourcePath && input.route === "compatibility") { - throw new Error("Hermes portable create selected an unsupported GPU route."); - } - const policySourcePath = input.effectivePolicySourcePath ?? input.policySourcePathForRoute(); - const registrationInput = { - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - lifecycleGeneration: input.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, - policySourcePath, - route: input.route, - operation: input.operation, - }; - const registration = input.apfInterceptorRequested - ? verifyCreatedApfInterceptorPolicyRegistration(registrationInput) - : verifyCreatedSandboxPolicyRegistration({ - ...registrationInput, - plannedAuthority: input.plannedAuthority, - }); - return { - registration, - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - lifecycleGeneration: input.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, - route: input.route, - policySourcePath, - }; -} - -/** Select the policyless APF create plan only when no active global policy exists. */ -export function resolveSandboxCreatePolicyAuthority( - observedAuthority: "nemoclaw-managed" | "externally-managed", - apfInterceptorRequested: boolean, -): "nemoclaw-managed" | "externally-managed" { - if (!apfInterceptorRequested) return observedAuthority; - if (observedAuthority !== "nemoclaw-managed") { - throw new Error( - "APF interceptor selection requires the active global policy to be absent before sandbox creation.", - ); - } - return "externally-managed"; -} - /** Require the generic deferred-effect gate for explicit APF creation. */ export function assertApfCreateIntent( createIntent: Pick< @@ -559,13 +506,13 @@ export async function completeHermesPortableSandboxRegistration(input: { type CreatedHermesCredentialEnvReconciliationDeps = { readonly reconcileCredentialEnv: ( plan: SandboxMessagingPlan, - revalidatePolicyAuthority: (operation: string) => void, + verifyLivePolicyRequirements: (operation: string) => void, ) => { readonly changed: boolean; }; readonly restartGateway: ( sandboxName: string, - revalidatePolicyAuthority: (operation: string) => void, + verifyLivePolicyRequirements: (operation: string) => void, ) => { readonly status: number; readonly stdout: string; @@ -580,9 +527,9 @@ type CreatedHermesCredentialEnvReconciliationDeps = { ) => unknown | null; readonly waitForGateway: ( sandboxName: string, - revalidatePolicyAuthority: (operation: string) => void, + verifyLivePolicyRequirements: (operation: string) => void, ) => boolean; - readonly revalidatePolicyAuthority: (operation: string) => void; + readonly verifyLivePolicyRequirements: (operation: string) => void; }; /** @@ -601,27 +548,30 @@ export function reconcileCreatedHermesCredentialEnvironment( return runWithPostCreateRecovery(() => { if (input.plan?.agent !== "hermes") return; - deps.revalidatePolicyAuthority( + deps.verifyLivePolicyRequirements( `reconciling Hermes messaging credentials for sandbox '${input.sandboxName}'`, ); - const reconciliation = deps.reconcileCredentialEnv(input.plan, deps.revalidatePolicyAuthority); - deps.revalidatePolicyAuthority( + const reconciliation = deps.reconcileCredentialEnv( + input.plan, + deps.verifyLivePolicyRequirements, + ); + deps.verifyLivePolicyRequirements( `confirming Hermes messaging credential reconciliation for sandbox '${input.sandboxName}'`, ); if (!reconciliation.changed) return; - const restart = deps.restartGateway(input.sandboxName, deps.revalidatePolicyAuthority); + const restart = deps.restartGateway(input.sandboxName, deps.verifyLivePolicyRequirements); if (!deps.parseRestartCompletion(restart)) { throw new Error( `Hermes messaging credential reconciliation changed the gateway environment for sandbox '${input.sandboxName}', but the managed gateway restart did not complete.`, ); } - if (!deps.waitForGateway(input.sandboxName, deps.revalidatePolicyAuthority)) { + if (!deps.waitForGateway(input.sandboxName, deps.verifyLivePolicyRequirements)) { throw new Error( `Hermes messaging credential reconciliation restarted sandbox '${input.sandboxName}', but the managed gateway did not remain healthy.`, ); } - deps.revalidatePolicyAuthority( + deps.verifyLivePolicyRequirements( `completing Hermes messaging credential reconciliation for sandbox '${input.sandboxName}'`, ); }, recordRecovery); @@ -643,7 +593,7 @@ export async function finalizeCreatedSandboxBeforeHermesCredentialReconciliation * immediately after OpenShell returns the exact created identity and before provider, * credential, service, runtime, registry, or completion effects. */ -export async function runSandboxCreateWithPolicyAuthorityChecks< +export async function runSandboxCreateWithPolicyVerification< Created, Evidence, Result = Created, @@ -654,13 +604,13 @@ export async function runSandboxCreateWithPolicyAuthorityChecks< readonly captureCreatedSandboxIdentity: (created: Created) => string; readonly persistCreatedSandboxIdentity: (created: Created, exactIdentity: string) => void; readonly revalidateCreatedSandboxIdentity: (expectedIdentity: string, operation: string) => void; - readonly verifyCreatedPolicy: (created: Created, exactIdentity: string) => Evidence; - readonly persistVerifiedPolicy: ( + readonly verifyCreatedPolicyRequirements: (created: Created, exactIdentity: string) => Evidence; + readonly persistCreateIdentity: ( created: Created, exactIdentity: string, evidence: Evidence, ) => void; - readonly revalidateVerifiedPolicy: ( + readonly verifyCurrentPolicyRequirements: ( created: Created, exactIdentity: string, evidence: Evidence, @@ -682,7 +632,7 @@ export async function runSandboxCreateWithPolicyAuthorityChecks< }): Promise { input.revalidate(false, `creating sandbox '${input.sandboxName}'`); let exactIdentity: string | null = null; - let observedPolicyEvidence: Evidence | null = null; + let verifiedCreateEvidence: Evidence | null = null; let observedCreatedSandbox: Created | null = null; let cleanupAttempted = false; let recoveryAttempted = false; @@ -699,7 +649,7 @@ export async function runSandboxCreateWithPolicyAuthorityChecks< const refuseAfterCreate = (validationError: unknown): never => { recoveryAttempted = true; const validationDetail = - validationError instanceof Error && isPolicyAuthorityRefusalError(validationError) + validationError instanceof Error && isPolicyObservationError(validationError) ? validationError.message : null; const identityGuidance = exactIdentity @@ -716,7 +666,7 @@ export async function runSandboxCreateWithPolicyAuthorityChecks< input.persistRetainedSandboxRecovery!( recoveryGuidance, exactIdentity, - observedPolicyEvidence, + verifiedCreateEvidence, observedCreatedSandbox, ), ); @@ -746,14 +696,14 @@ export async function runSandboxCreateWithPolicyAuthorityChecks< capturedIdentity, `verifying effective policy for sandbox '${input.sandboxName}'`, ); - const evidence = input.verifyCreatedPolicy(created, capturedIdentity); + const evidence = input.verifyCreatedPolicyRequirements(created, capturedIdentity); input.revalidateCreatedSandboxIdentity( capturedIdentity, - `recording verified policy for sandbox '${input.sandboxName}'`, + `recording pending create identity for sandbox '${input.sandboxName}'`, ); - observedPolicyEvidence = evidence; - input.persistVerifiedPolicy(created, capturedIdentity, evidence); - input.revalidateVerifiedPolicy( + verifiedCreateEvidence = evidence; + input.persistCreateIdentity(created, capturedIdentity, evidence); + input.verifyCurrentPolicyRequirements( created, capturedIdentity, evidence, @@ -802,14 +752,6 @@ export async function runSandboxCreateWithPolicyAuthorityChecks< return result; } -function assertHermesPortablePolicyAuthority( - hermesPortableLifecycle: boolean, - policyAuthority: string, -): void { - if (!hermesPortableLifecycle || policyAuthority === "nemoclaw-managed") return; - throw new Error("Hermes portable sandbox creation requires NemoClaw-managed policy authority."); -} - export function hasManagedMcpRebuildHandoff( createIntent: SandboxCreateIntent | null | undefined, ): boolean { @@ -831,74 +773,6 @@ function hasPreservedManagedMcpRebuildHandoff( return Boolean(preservedMcpState) && hasManagedMcpRebuildHandoff(createIntent); } -export function backfillVerifiedExternalSandboxPolicyAuthority(input: { - readonly sandboxName: string; - readonly existingEntry: SandboxEntry | null; - readonly policyAuthority: "nemoclaw-managed" | "externally-managed"; - readonly updateSandbox: ( - sandboxName: string, - updates: { policyAuthority: "nemoclaw-managed" | "externally-managed" }, - ) => boolean; -}): void { - if ( - !input.existingEntry || - input.existingEntry.policyAuthority || - input.policyAuthority !== "externally-managed" - ) { - return; - } - if (input.updateSandbox(input.sandboxName, { policyAuthority: input.policyAuthority })) return; - throw new Error(`Could not record policy authority for sandbox '${input.sandboxName}'.`); -} - -type ApplyRecreatePolicyCarryForward = ( - sandboxName: string, - nonInteractive: boolean, - note: (message: string) => void, - rebuildPolicyPresets?: readonly string[], -) => void; - -/** Carry managed rebuild policy intent only while the recorded authority remains current. */ -export function applyManagedSandboxRebuildPolicyCarryForward( - input: { - readonly sandboxName: string; - readonly policyAuthority: "nemoclaw-managed" | "externally-managed"; - readonly nonInteractive: boolean; - readonly note: (message: string) => void; - readonly rebuildPolicyPresets?: readonly string[]; - readonly revalidatePolicyAuthority: (operation: string) => void; - }, - applyRecreatePolicyCarryForward: ApplyRecreatePolicyCarryForward, -): void { - if (input.policyAuthority !== "nemoclaw-managed") return; - input.revalidatePolicyAuthority( - `carrying forward managed policy presets for sandbox '${input.sandboxName}'`, - ); - applyRecreatePolicyCarryForward( - input.sandboxName, - input.nonInteractive, - input.note, - input.rebuildPolicyPresets, - ); -} - -/** Reseed an outer rebuild after its owned delete leaves no live source branch. */ -export function applyAbsentSandboxRebuildPolicyCarryForward( - input: { - readonly sandboxName: string; - readonly liveExists: boolean; - readonly policyAuthority: "nemoclaw-managed" | "externally-managed"; - readonly nonInteractive: boolean; - readonly note: (message: string) => void; - readonly rebuildPolicyPresets?: readonly string[]; - readonly revalidatePolicyAuthority: (operation: string) => void; - }, - applyRecreatePolicyCarryForward: ApplyRecreatePolicyCarryForward, -): void { - if (input.liveExists || !Array.isArray(input.rebuildPolicyPresets)) return; - applyManagedSandboxRebuildPolicyCarryForward(input, applyRecreatePolicyCarryForward); -} - async function validatePortableManagedWorkloadSelection(input: { readonly portableLifecycle: boolean; readonly selectionNeedsValidation: boolean; @@ -940,9 +814,9 @@ export function createProviderEffectBoundary(input: { readonly preparationDeps: ProviderPreparationDeps; readonly runVerifiedSandboxCreateEffects: import("../types").VerifiedSandboxCreateEffects | null; readonly activateDeferredProviderEffects: - | ((revalidatePolicyRequirements: (operation: string) => void) => readonly string[]) + | ((verifyLivePolicyRequirements: (operation: string) => void) => readonly string[]) | null; - readonly revalidatePolicyAuthorityBeforeCreate: () => void; + readonly verifyLivePolicyRequirementsBeforeCreate: () => void; }): ProviderEffectBoundary { const validate = () => validateAttachedMessagingProvidersBeforeSandboxCreation( @@ -958,7 +832,7 @@ export function createProviderEffectBoundary(input: { return { validateBeforeCreate: validate, publishBeforeCreate: () => { - input.revalidatePolicyAuthorityBeforeCreate(); + input.verifyLivePolicyRequirementsBeforeCreate(); publish(); }, runAfterVerifiedCreate: undefined, @@ -968,21 +842,21 @@ export function createProviderEffectBoundary(input: { validateBeforeCreate: () => undefined, publishBeforeCreate: () => undefined, runAfterVerifiedCreate: async (context) => { - context.revalidatePolicyRequirements( + context.verifyLivePolicyRequirements( `starting deferred provider effects for sandbox '${input.sandboxName}'`, ); await input.runVerifiedSandboxCreateEffects?.(context); - context.revalidatePolicyRequirements( + context.verifyLivePolicyRequirements( `activating deferred providers for sandbox '${input.sandboxName}'`, ); const providerNames = - input.activateDeferredProviderEffects?.(context.revalidatePolicyRequirements) ?? []; + input.activateDeferredProviderEffects?.(context.verifyLivePolicyRequirements) ?? []; validate(); - context.revalidatePolicyRequirements( + context.verifyLivePolicyRequirements( `publishing deferred providers for sandbox '${input.sandboxName}'`, ); publish(); - context.revalidatePolicyRequirements( + context.verifyLivePolicyRequirements( `attaching deferred providers to sandbox '${input.sandboxName}'`, ); attachProvidersAfterSandboxCreation({ @@ -1002,7 +876,7 @@ type SandboxProviderCleanupAuthority = readonly observeSandbox: () => ReturnType< SandboxCreateOrchestrationRuntime["getSandboxRecreateObservation"] >; - readonly revalidatePolicyAuthority: (operation: string) => void; + readonly verifyLivePolicyRequirements: (operation: string) => void; }; export function runAuthorityBoundProviderCleanup( @@ -1022,7 +896,7 @@ export function runAuthorityBoundProviderCleanup( `Cannot clean up providers for sandbox '${input.sandboxName}': a sandbox with that name appeared after absence was verified while ${operation}.`, ); } - input.revalidatePolicyAuthority(operation); + input.verifyLivePolicyRequirements(operation); } : input.revalidateSandboxIdentity; revalidateSandboxIdentity(`cleaning up providers for sandbox '${input.sandboxName}'`); @@ -1051,8 +925,8 @@ function pendingVerifiedCreateCheckpointForSession(input: { readonly entry: SandboxEntry | null; readonly session: Session | null; readonly request: SandboxCreateIntent["recreateTransaction"]; -}): PendingSandboxPolicyVerification | null { - const checkpoint = input.entry?.pendingPolicyVerification; +}): PendingSandboxCreateIdentity | null { + const checkpoint = input.entry?.pendingCreateIdentity; if ( !checkpoint || input.entry?.pendingRouteReservation !== true || @@ -1090,13 +964,13 @@ function pendingVerifiedCreateCheckpointForSession(input: { function readAcceptedPendingVerifiedCreate(input: { readonly acceptedTarget: boolean; - readonly openingCheckpoint: PendingSandboxPolicyVerification | null; + readonly openingCheckpoint: PendingSandboxCreateIdentity | null; readonly sandboxName: string; readonly readEntry: () => SandboxEntry | null; -}): PendingSandboxPolicyVerification | null { +}): PendingSandboxCreateIdentity | null { const entry = input.acceptedTarget ? input.readEntry() : null; const checkpoint = - entry?.pendingRouteReservation === true ? (entry.pendingPolicyVerification ?? null) : null; + entry?.pendingRouteReservation === true ? (entry.pendingCreateIdentity ?? null) : null; if (input.openingCheckpoint && !isDeepStrictEqual(checkpoint, input.openingCheckpoint)) { throw new Error( `Cannot resume sandbox '${input.sandboxName}' because its verified create checkpoint changed during recovery.`, @@ -1228,7 +1102,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche isWsl, managedWorkloadOnboard, messagingChannelSetup, - nim, normalizeHermesAuthMethod, normalizeHermesToolGatewaySelections, note, @@ -1240,7 +1113,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche openshellArgv, path, planRegisteredExtraProviders, - policyPresetCarry, preparedDcodeRebuild, promptValidatedSandboxName, promptYesNoOrDefault, @@ -1261,7 +1133,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxLifecycle, sandboxMutationLock, sandboxRecreateTransaction, - sandboxRegistration, sandboxRegistryMetadata, sandboxReuse, shouldSkipPreRecreateBackup, @@ -1330,7 +1201,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxGpuConfig: effectiveSandboxGpuConfig, resourceProfile, hermesToolGateways, - baselineExclusions: sandboxRegistration.baselineExclusionsForCreate(sandboxName), ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), ...(createIntent?.policyTier !== undefined ? { policyTier: createIntent.policyTier } : {}), }, @@ -1342,25 +1212,10 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche planRegisteredExtraProviders(GATEWAY_NAME, { runOpenshell }), }, ); - const resolvedCreateIntent = preparedCreateIntent.intent; - const policyRequirementIntent: typeof resolvedCreateIntent = { - ...resolvedCreateIntent, - policy: { - ...resolvedCreateIntent.policy, - options: { - ...resolvedCreateIntent.policy.options, - additionalPresets: policyAuthorityPreflight.requiredOnboardPolicyPresets({ - additionalPresets: resolvedCreateIntent.policy.options.additionalPresets, - provider, - webSearchConfig, - agentName: agent?.name, - observabilityEnabled: createIntent?.observabilityEnabled === true, - hostLocalInferenceRouteOnly: - resolvedCreateIntent.policy.options.hostLocalInferenceRouteOnly, - }), - }, - }, - }; + const resolvedCreateIntent = bindRebuildPolicySourceProvidersToCreateIntent( + preparedCreateIntent.intent, + createIntent?.rebuildPolicySourcePath, + ); const messagingCapabilities = preparedCreateIntent.messagingCapabilities; const manageDashboard = sandboxGpuCreateFlow.shouldManageHermesPortableDashboard( dashboardRuntime.shouldManageDashboardForAgent(agent), @@ -1534,85 +1389,24 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche prepareWorkload: ensurePreparedSandboxWorkload, }); const apfInterceptorRequested = createIntent?.apfInterceptorRequested === true; - let verifiedPolicyGate: EffectiveVerifiedSandboxPolicyBoundary | null = null; - let pendingPolicyVerification: PendingSandboxPolicyVerification | null = null; + let verifiedCreateBoundary: EffectiveVerifiedSandboxCreateBoundary | null = null; + let pendingCreateIdentity: PendingSandboxCreateIdentity | null = null; let admittedCreateReservation: QualifiedPendingSandboxCreateReservation | null = null; - let verifiedPolicyRegistrationFinalized = false; - const policyAuthoritySession = onboardSession.loadSession(); - const sessionPolicyAuthority = policyAuthoritySession?.policyAuthority ?? null; - const currentSessionId = policyAuthoritySession?.sessionId ?? null; - const openingPendingPolicyVerification = pendingVerifiedCreateCheckpointForSession({ + let createEffectsFinalized = false; + const createCheckpointSession = onboardSession.loadSession(); + const openingPendingCreateIdentity = pendingVerifiedCreateCheckpointForSession({ sandboxName, gatewayName: GATEWAY_NAME, liveExists, entry: existingEntry, - session: policyAuthoritySession, + session: createCheckpointSession, request: createIntent?.recreateTransaction, }); - const qualifyPolicyAuthority = ( - sandboxIsLive = liveExists, - operation = `prepare sandbox '${sandboxName}'`, - ) => { - const recordedSandbox = sandboxIsLive ? registry.getSandbox(sandboxName) : existingEntry; - return policyAuthorityPreflight.qualifySandboxPolicyAuthority({ - sandboxName, - gatewayName: GATEWAY_NAME, - liveExists: sandboxIsLive, - recordedAuthorities: [existingEntry?.policyAuthority, sessionPolicyAuthority], - recordedSandbox, - readRecordedSandbox: registry.getSandbox, - currentSessionId, - prepareRequiredPolicy: () => - sandboxCreatePlanMaterialization.prepareSandboxCreatePolicy(policyRequirementIntent) - .initialSandboxPolicy, - operation, - }); - }; - const initialPolicyAuthority = - openingPendingPolicyVerification?.policyAuthority ?? qualifyPolicyAuthority().authority; - const resolvedPolicyAuthority = resolveSandboxCreatePolicyAuthority( - initialPolicyAuthority, - apfInterceptorRequested, - ); - const revalidatePolicyAuthority = (sandboxIsLive: boolean, operation: string): void => { - if ( - sandboxIsLive && - !verifiedPolicyRegistrationFinalized && - (openingPendingPolicyVerification || apfInterceptorRequested) - ) { - revalidateVerifiedPolicyRegistration(requireVerifiedPolicyGate(), operation); - return; + const verifyLivePolicyRequirements = (sandboxIsLive: boolean, operation: string): void => { + if (sandboxIsLive && !createEffectsFinalized && verifiedCreateBoundary) { + verifyCurrentPolicyRequirements(requireVerifiedCreateBoundary(), operation); } - const inspection = qualifyPolicyAuthority(sandboxIsLive, operation); - if (apfInterceptorRequested) { - if (sandboxIsLive) { - assertRecordedPolicyAuthority("externally-managed", inspection.authority, operation); - } else { - resolveSandboxCreatePolicyAuthority(inspection.authority, true); - } - return; - } - assertRecordedPolicyAuthority(resolvedPolicyAuthority, inspection.authority, operation); }; - assertHermesPortablePolicyAuthority( - agentCreateInput.hermesPortableLifecycle, - resolvedPolicyAuthority, - ); - runForNewSandboxCreate(Boolean(openingPendingPolicyVerification), () => { - backfillVerifiedExternalSandboxPolicyAuthority({ - sandboxName, - existingEntry, - policyAuthority: resolvedPolicyAuthority, - updateSandbox: registry.updateSandbox, - }); - }); - onboardSession.updateSession((session) => { - session.policyAuthority = - resolvedPolicyAuthority === "externally-managed" && !apfInterceptorRequested - ? "externally-managed" - : null; - if (session.policyAuthority === "externally-managed") session.policyPresets = null; - }); const recreateRegistryEntry = readSandboxRecreateRegistryEntry({ sandboxName, recreateTransaction: Boolean(createIntent?.recreateTransaction), @@ -1636,27 +1430,15 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche getSandboxRecreateObservation, note, ), - carryForward: () => - applyAbsentSandboxRebuildPolicyCarryForward( - { - sandboxName, - liveExists, - policyAuthority: resolvedPolicyAuthority, - nonInteractive: isNonInteractive(), - note, - rebuildPolicyPresets: createIntent?.rebuildPolicyPresets, - revalidatePolicyAuthority: (operation) => revalidatePolicyAuthority(false, operation), - }, - policyPresetCarry.applyRecreatePolicyCarryForward, - ), + carryForward: () => undefined, }); - const acceptedTargetPendingCheckpoint = readAcceptedPendingVerifiedCreate({ + const acceptedTargetPendingIdentity = readAcceptedPendingVerifiedCreate({ acceptedTarget: recreateRuntime.acceptedTarget, - openingCheckpoint: openingPendingPolicyVerification, + openingCheckpoint: openingPendingCreateIdentity, sandboxName, readEntry: () => registry.getSandbox(sandboxName), }); - const resumingVerifiedCreate = acceptedTargetPendingCheckpoint !== null; + const resumingVerifiedCreate = acceptedTargetPendingIdentity !== null; const restoreReusedSandboxDashboard = async (selectionVerified: boolean): Promise => { ({ chatUiUrl } = await sandboxReuse.restoreReusedSandboxDashboardState({ sandboxName, @@ -1674,7 +1456,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche hermesDashboardForwarding, updateReusedSandboxMetadata, releaseDashboardPort: dashboardPortReservationScope.release, - revalidatePolicyRequirements: (operation) => revalidatePolicyAuthority(true, operation), + verifyLivePolicyRequirements: (operation) => verifyLivePolicyRequirements(true, operation), })); }; if (recreateRuntime.acceptedTarget && !resumingVerifiedCreate) { @@ -1714,7 +1496,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche toolDisclosure: effectiveToolDisclosure, dcodeAutoApprovalMode: createIntent?.dcodeAutoApprovalMode ?? null, observabilityEnabled: createIntent?.observabilityEnabled === true, - policyTier: createIntent?.policyTier ?? null, }, }); let pendingStateRestoreBackupPath: string | null = null, @@ -1855,13 +1636,12 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche if (actionableSelectionDrift) { note(" [non-interactive] Recreating sandbox due to provider/model drift."); } else { - revalidatePolicyAuthority(true, `reusing sandbox '${sandboxName}'`); - policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); + verifyLivePolicyRequirements(true, `reusing sandbox '${sandboxName}'`); // Upsert messaging providers even on reuse so credential changes take // effect without requiring a full sandbox recreation. upsertMessagingProviders(messagingTokenDefs, { - revalidatePolicyRequirements: (operation) => - revalidatePolicyAuthority(true, operation), + verifyLivePolicyRequirements: (operation) => + verifyLivePolicyRequirements(true, operation), }); if (selectionDrift.unknown) { note( @@ -1906,11 +1686,10 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche console.log(` Sandbox '${sandboxName}' already exists.`); console.log(" Choosing 'n' will delete the existing sandbox and create a new one."); if (await promptYesNoOrDefault(" Reuse existing sandbox?", null, true)) { - revalidatePolicyAuthority(true, `reusing sandbox '${sandboxName}'`); - policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); + verifyLivePolicyRequirements(true, `reusing sandbox '${sandboxName}'`); upsertMessagingProviders(messagingTokenDefs, { - revalidatePolicyRequirements: (operation) => - revalidatePolicyAuthority(true, operation), + verifyLivePolicyRequirements: (operation) => + verifyLivePolicyRequirements(true, operation), }); await restoreReusedSandboxDashboard(!selectionDrift.unknown); return sandboxName; @@ -1993,18 +1772,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche baseImageResolutionContext, previousEntry?.imageTag, ); - applyManagedSandboxRebuildPolicyCarryForward( - { - sandboxName, - policyAuthority: resolvedPolicyAuthority, - nonInteractive: isNonInteractive(), - note, - rebuildPolicyPresets: createIntent?.rebuildPolicyPresets, - revalidatePolicyAuthority: (operation) => revalidatePolicyAuthority(true, operation), - }, - policyPresetCarry.applyRecreatePolicyCarryForward, - ); - const noRestorePending = pendingStateRestore === null && pendingStateRestoreBackupPath === null; if ( @@ -2026,16 +1793,16 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche hermesStateVolumeLifecycle = prepareHermesStateVolumeLifecycle(preparedSandboxWorkload); note(` Deleting and recreating sandbox '${sandboxName}'...`); - revalidatePolicyAuthority(true, `recreating sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(true, `recreating sandbox '${sandboxName}'`); if (recreateRuntime.beginDelete() === "source") { runAuthorityBoundProviderCleanup({ sandboxName, - revalidateSandboxIdentity: (operation) => revalidatePolicyAuthority(true, operation), + revalidateSandboxIdentity: (operation) => verifyLivePolicyRequirements(true, operation), runProviderPreDeleteCleanup: runSandboxProviderPreDeleteCleanup, runOpenshell, redact, }); - revalidatePolicyAuthority(true, `deleting sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(true, `deleting sandbox '${sandboxName}'`); runOpenshell( [ "sandbox", @@ -2080,11 +1847,11 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche ); } runForNewSandboxCreate(resumingVerifiedCreate, () => { - revalidatePolicyAuthority(false, `creating sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(false, `creating sandbox '${sandboxName}'`); sandboxCreatePlanMaterialization.applyOrdinaryExtraProviderReconciliation( agentCreateInput.hermesPortableLifecycle, () => { - revalidatePolicyAuthority(false, `updating providers for sandbox '${sandboxName}'`); + verifyLivePolicyRequirements(false, `updating providers for sandbox '${sandboxName}'`); applyExtraProviderReconciliation({ extraProviders: resolvedCreateIntent.extraProviders, staleExtraProviders: resolvedCreateIntent.staleExtraProviders ?? [], @@ -2098,7 +1865,6 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche () => managedWorkloadOnboard.prepareHermesPortableOnboardSandboxLaunch({ intent: resolvedCreateIntent, - policyAuthority: resolvedPolicyAuthority, fromRef: preparedSandboxWorkload.source.kind === "legacy-dockerfile" ? preparedSandboxWorkload.source.dockerfilePath @@ -2159,11 +1925,11 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche }, plan: { intent: resolvedCreateIntent, - policyAuthority: resolvedPolicyAuthority, + policylessCreate: apfInterceptorRequested, deferSandboxEffectsUntilPolicyVerification: createIntent?.deferSandboxEffectsUntilPolicyVerification === true, rebindMessagingTokenDefs: async () => { - revalidatePolicyAuthority( + verifyLivePolicyRequirements( false, `registering credentials for sandbox '${sandboxName}'`, ); @@ -2194,18 +1960,18 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche : { observeSandbox: () => getSandboxRecreateObservation(sandboxName, GATEWAY_NAME), - revalidatePolicyAuthority: (operation: string) => - revalidatePolicyAuthority(false, operation), + verifyLivePolicyRequirements: (operation: string) => + verifyLivePolicyRequirements(false, operation), }), }); }, upsertMessagingProviders: (tokenDefs, options) => upsertMessagingProviders(tokenDefs, { ...options, - revalidatePolicyRequirements: (operation) => + verifyLivePolicyRequirements: (operation) => ( - options.revalidatePolicyRequirements ?? - ((targetOperation) => revalidatePolicyAuthority(false, targetOperation)) + options.verifyLivePolicyRequirements ?? + ((targetOperation) => verifyLivePolicyRequirements(false, targetOperation)) )(operation), }), getHermesToolGatewayProviderName: (targetSandbox) => @@ -2249,8 +2015,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche }), ); const { - initialSandboxPolicy, - policyTier: resolvedCreatePolicyTier, + initialSandboxPolicy: materializedInitialSandboxPolicy, messagingProviders, gpuRoutePlan, compatibilityPolicyPath, @@ -2261,8 +2026,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche dashboardRemoteBindPrepared, legacyBuildContext, launch: { - createArgv, - effectiveDashboardPort, + createArgv: materializedCreateArgv, intendedSandboxStartupCommand, managedBootstrapIdentity, managedStartupRootApplyRequest, @@ -2271,6 +2035,17 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxStartupCommand, }, } = preparedOnboardLaunch; + const initialSandboxPolicy = createIntent?.rebuildPolicySourcePath + ? materializeRebuildCreatePolicy({ + livePolicyPath: createIntent.rebuildPolicySourcePath, + currentPolicy: materializedInitialSandboxPolicy, + }) + : materializedInitialSandboxPolicy; + const createArgv = createIntent?.rebuildPolicySourcePath + ? materializedCreateArgv.map((value, index, argv) => + index > 0 && argv[index - 1] === "--policy" ? initialSandboxPolicy.policyPath : value, + ) + : materializedCreateArgv; const restoreBackupPath = pendingStateRestore?.manifest?.backupPath ?? pendingStateRestoreBackupPath; onboardSessionBootstrap.verifyReadOnlyHostMountSources(resolvedCreateIntent.hostMounts); @@ -2325,7 +2100,8 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche route: import("../docker-gpu-route").SelectedDockerGpuRoute, ): string => { const policySourcePath = - route === "compatibility" ? compatibilityPolicyPath : initialSandboxPolicy.policyPath; + createIntent?.rebuildPolicySourcePath ?? + (route === "compatibility" ? compatibilityPolicyPath : initialSandboxPolicy.policyPath); if (!policySourcePath) { throw new Error("Sandbox creation has no exact policy source for its selected route."); } @@ -2346,17 +2122,17 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche { allowNotReadyWithMatchingIdentity: managedBootstrapCreateFinished }, ); }; - const requireVerifiedPolicyGate = (): NonNullable => { - if (!verifiedPolicyGate) { - throw new Error("Sandbox creation has no verified post-create policy boundary."); + const requireVerifiedCreateBoundary = (): NonNullable => { + if (!verifiedCreateBoundary) { + throw new Error("Sandbox creation has no verified post-create requirements boundary."); } - return verifiedPolicyGate; + return verifiedCreateBoundary; }; - const requirePendingPolicyVerification = (): PendingSandboxPolicyVerification => { - if (!pendingPolicyVerification) { - throw new Error("Sandbox creation has no durable verified policy checkpoint."); + const requirePendingCreateIdentity = (): PendingSandboxCreateIdentity => { + if (!pendingCreateIdentity) { + throw new Error("Sandbox creation has no pending create identity."); } - return pendingPolicyVerification; + return pendingCreateIdentity; }; const requireCreateReservation = (): QualifiedPendingSandboxCreateReservation => { if (!admittedCreateReservation) { @@ -2411,87 +2187,19 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche registry.getSandbox(sandboxName), ); }; - /** Rebind only the current reserved GPU create, then CAS-persist its exact live receipt. */ - const revalidateActiveVerifiedCreateBoundary = ( - boundary: EffectiveVerifiedSandboxPolicyBoundary, - checkpoint: PendingSandboxPolicyVerification, - operation: string, - ): { - readonly boundary: EffectiveVerifiedSandboxPolicyBoundary; - readonly checkpoint: PendingSandboxPolicyVerification; - } => { - if (!isDeepStrictEqual(pendingSandboxPolicyVerificationForBoundary(boundary), checkpoint)) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the verified create policy boundary no longer matches its durable checkpoint.`, - "owner-unknown", - ); - } - registry.requireCurrentPendingSandboxPolicyVerification( - requireCreateReservation(), - checkpoint, - ); - revalidateCreatedSandboxIdentity(boundary.lifecycleLiveIdentityFingerprint, operation); - const registration = - boundary.route !== "none" && boundary.registration.policyAuthority === "nemoclaw-managed" - ? verifyCreatedSandboxPolicyRegistration( - { - sandboxName, - gatewayName: GATEWAY_NAME, - gatewayPort: GATEWAY_PORT, - lifecycleGeneration: createdSandboxLifecycle.generation, - lifecycleLiveIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint, - policySourcePath: boundary.policySourcePath, - route: boundary.route, - operation, - plannedAuthority: "nemoclaw-managed", - }, - { sleep: sleepSeconds }, - ) - : revalidateCreatedSandboxPolicyRegistration({ - sandboxName, - gatewayName: GATEWAY_NAME, - gatewayPort: GATEWAY_PORT, - lifecycleGeneration: createdSandboxLifecycle.generation, - lifecycleLiveIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint, - policySourcePath: boundary.policySourcePath, - route: boundary.route, - operation, - registration: boundary.registration, - }); - revalidateCreatedSandboxIdentity(boundary.lifecycleLiveIdentityFingerprint, operation); - if (isDeepStrictEqual(registration, boundary.registration)) { - return { boundary, checkpoint }; - } - const refreshedBoundary = { ...boundary, registration }; - const refreshedCheckpoint = pendingSandboxPolicyVerificationForBoundary(refreshedBoundary); - registry.recordPendingSandboxPolicyVerification( - requireCreateReservation(), - refreshedCheckpoint, - { expected: checkpoint }, - ); - revalidateCreatedSandboxIdentity(boundary.lifecycleLiveIdentityFingerprint, operation); - registry.requireCurrentPendingSandboxPolicyVerification( - requireCreateReservation(), - refreshedCheckpoint, - ); - return { boundary: refreshedBoundary, checkpoint: refreshedCheckpoint }; - }; const resumeVerifiedCreateInput = (() => { - const checkpoint = acceptedTargetPendingCheckpoint; + const checkpoint = acceptedTargetPendingIdentity; if (!checkpoint) return null; if (agentCreateInput.hermesPortableLifecycle) { throw new Error("Hermes portable onboarding cannot resume an ordinary verified create."); } const policySourcePath = policySourcePathForRoute(checkpoint.route); const boundary = { - ...verifiedSandboxPolicyBoundaryFromPendingCheckpoint(checkpoint), + ...sandboxCreateBoundaryFromPendingIdentity(checkpoint), policySourcePath, }; admittedCreateReservation = admitCreateReservation(); - registry.requireCurrentPendingSandboxPolicyVerification( - admittedCreateReservation, - checkpoint, - ); + registry.requireCurrentPendingSandboxCreateIdentity(admittedCreateReservation, checkpoint); durableCreatedSandboxIdentity = createdSandboxLifecycle.recordExactIdentity( checkpoint.sandboxIdentityFingerprint, ); @@ -2499,55 +2207,52 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche checkpoint.sandboxIdentityFingerprint, `resuming sandbox creation for '${sandboxName}'`, ); - const resumed = revalidateActiveVerifiedCreateBoundary( - boundary, - checkpoint, - `resume sandbox creation for '${sandboxName}'`, + verifyLiveCreatedSandboxPolicyRequirements({ + sandboxName, + gatewayName: GATEWAY_NAME, + gatewayPort: GATEWAY_PORT, + lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, + policySourcePath, + operation: `resume sandbox creation for '${sandboxName}'`, + }); + revalidateCreatedSandboxIdentity( + checkpoint.sandboxIdentityFingerprint, + `resuming sandbox creation for '${sandboxName}'`, ); - pendingPolicyVerification = resumed.checkpoint; - verifiedPolicyGate = resumed.boundary; + registry.requireCurrentPendingSandboxCreateIdentity(admittedCreateReservation, checkpoint); + pendingCreateIdentity = checkpoint; + verifiedCreateBoundary = boundary; return { - route: resumed.checkpoint.route, - liveIdentityFingerprint: resumed.checkpoint.sandboxIdentityFingerprint, - createAttemptNonce: resumed.checkpoint.createAttemptNonce, + route: checkpoint.route, + liveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, + ...(checkpoint.createAttemptNonce + ? { createAttemptNonce: checkpoint.createAttemptNonce } + : {}), }; })(); - const revalidateVerifiedPolicyRegistration = ( - boundary: EffectiveVerifiedSandboxPolicyBoundary, + const verifyCurrentPolicyRequirements = ( + boundary: EffectiveVerifiedSandboxCreateBoundary, operation: string, ): SandboxEntry => { - const activeBoundary = requireVerifiedPolicyGate(); - if ( - boundary.sandboxName !== activeBoundary.sandboxName || - boundary.gatewayName !== activeBoundary.gatewayName || - boundary.gatewayPort !== activeBoundary.gatewayPort || - boundary.lifecycleGeneration !== activeBoundary.lifecycleGeneration || - boundary.lifecycleLiveIdentityFingerprint !== - activeBoundary.lifecycleLiveIdentityFingerprint || - boundary.route !== activeBoundary.route - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the verified create policy boundary changed during onboarding.`, - "owner-unknown", - ); - } - const refreshed = revalidateActiveVerifiedCreateBoundary( - activeBoundary, - requirePendingPolicyVerification(), + revalidateCreatedSandboxIdentity(boundary.lifecycleLiveIdentityFingerprint, operation); + verifyLiveCreatedSandboxPolicyRequirements({ + sandboxName, + gatewayName: GATEWAY_NAME, + gatewayPort: GATEWAY_PORT, + lifecycleLiveIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint, + policySourcePath: boundary.policySourcePath, operation, - ); - pendingPolicyVerification = refreshed.checkpoint; - verifiedPolicyGate = refreshed.boundary; - return registry.requireCurrentPendingSandboxPolicyVerification( + }); + revalidateCreatedSandboxIdentity(boundary.lifecycleLiveIdentityFingerprint, operation); + return registry.requireCurrentPendingSandboxCreateIdentity( requireCreateReservation(), - requirePendingPolicyVerification(), + requirePendingCreateIdentity(), ); }; const retainedSandboxRecoveryContext = ( - boundary: VerifiedSandboxPolicyBoundary | null, + boundary: VerifiedSandboxCreateBoundary | null, createAttemptNonceOverride: string | null = null, ): RetainedSandboxRecoveryContext => { - const checkpoint = boundary ? pendingSandboxPolicyVerificationForBoundary(boundary) : null; const createAttemptNonce = boundary?.createAttemptNonce ?? createAttemptNonceOverride; if (!createAttemptNonce) { throw new Error("Retained sandbox recovery requires exact create-attempt authority."); @@ -2556,20 +2261,13 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, lifecycleGeneration: createdSandboxLifecycle.generation, - verifiedEffectivePolicyIdentity: checkpoint - ? { hash: checkpoint.policyHash, activeVersion: checkpoint.policyVersion } - : null, createAttemptNonce, - policyCreationReceipt: - boundary?.registration.policyAuthority === "nemoclaw-managed" - ? boundary.registration.policyCreationReceipt - : null, }; }; const recordPostCreateRecovery = ( stage: "registry publication" | "onboarding finalization", ): void => { - const boundary = requireVerifiedPolicyGate(); + const boundary = requireVerifiedCreateBoundary(); postCreateRecoveryRetryOwner.record(() => persistPostCreateRecovery({ stage, @@ -2593,7 +2291,10 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxName, message, ...(exactIdentity ? { sandboxIdentityFingerprint: exactIdentity } : {}), - recoveryContext: retainedSandboxRecoveryContext(verifiedPolicyGate, createAttemptNonce), + recoveryContext: retainedSandboxRecoveryContext( + verifiedCreateBoundary, + createAttemptNonce, + ), }, onboardSession.markRetainedSandboxRecovery, ), @@ -2613,24 +2314,24 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxName, }); if (Boolean(effectivePolicySourcePath) !== Boolean(hermesPortableAuthority)) { - throw new Error("Hermes portable create policy authority is incomplete."); + throw new Error("Hermes portable create policy source is incomplete."); } admittedCreateReservation = admitCreateReservation(); - return runSandboxCreateWithPolicyAuthorityChecks< + return runSandboxCreateWithPolicyVerification< import("../sandbox-gpu-create-flow").CreatedSandboxIdentity, - EffectiveVerifiedSandboxPolicyBoundary, + EffectiveVerifiedSandboxCreateBoundary, import("../sandbox-gpu-create-flow").SandboxGpuCreateFlowResult >({ sandboxName, revalidate: (sandboxIsLive, operation) => - revalidatePolicyAuthority(resumeVerifiedCreateInput ? true : sandboxIsLive, operation), + verifyLivePolicyRequirements(resumeVerifiedCreateInput ? true : sandboxIsLive, operation), captureCreatedSandboxIdentity: ( identity: import("../sandbox-gpu-create-flow").CreatedSandboxIdentity, ) => identity.liveIdentityFingerprint, persistCreatedSandboxIdentity: (_identity, exactIdentity) => persistCreatedSandboxIdentity(exactIdentity), revalidateCreatedSandboxIdentity, - verifyCreatedPolicy: ( + verifyCreatedPolicyRequirements: ( identity: import("../sandbox-gpu-create-flow").CreatedSandboxIdentity, ) => { if (effectivePolicySourcePath && identity.route === "compatibility") { @@ -2648,24 +2349,8 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche route: identity.route, operation: `verify effective policy for sandbox '${sandboxName}'`, }; - const registration = resumeVerifiedCreateInput - ? revalidateCreatedSandboxPolicyRegistration({ - ...registrationInput, - registration: requireVerifiedPolicyGate().registration, - }) - : apfInterceptorRequested - ? verifyCreatedApfInterceptorPolicyRegistration(registrationInput, { - sleep: sleepSeconds, - }) - : verifyCreatedSandboxPolicyRegistration( - { - ...registrationInput, - plannedAuthority: resolvedPolicyAuthority, - }, - { sleep: sleepSeconds }, - ); + verifyLiveCreatedSandboxPolicyRequirements(registrationInput); return { - registration, sandboxName, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, @@ -2676,23 +2361,17 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche policySourcePath, }; }, - persistVerifiedPolicy: (_identity, _exactIdentity, boundary) => { + persistCreateIdentity: (_identity, _exactIdentity, boundary) => { requireDurableCreatedSandboxIdentity(boundary.lifecycleLiveIdentityFingerprint); - const checkpoint = pendingSandboxPolicyVerificationForBoundary(boundary); - registry.recordPendingSandboxPolicyVerification(requireCreateReservation(), checkpoint, { - ...(pendingPolicyVerification ? { expected: pendingPolicyVerification } : {}), + const checkpoint = pendingSandboxCreateIdentityForBoundary(boundary); + registry.recordPendingSandboxCreateIdentity(requireCreateReservation(), checkpoint, { + ...(pendingCreateIdentity ? { expected: pendingCreateIdentity } : {}), }); - pendingPolicyVerification = checkpoint; - verifiedPolicyGate = boundary; - if (apfInterceptorRequested) { - onboardSession.updateSession((session) => { - session.policyAuthority = "externally-managed"; - session.policyPresets = null; - }); - } + pendingCreateIdentity = checkpoint; + verifiedCreateBoundary = boundary; }, - revalidateVerifiedPolicy: (_identity, _exactIdentity, boundary, operation) => { - revalidateVerifiedPolicyRegistration(boundary, operation); + verifyCurrentPolicyRequirements: (_identity, _exactIdentity, boundary, operation) => { + verifyCurrentPolicyRequirements(boundary, operation); }, persistRetainedSandboxRecovery: (message, exactIdentity, boundary, created) => persistRetainedSandboxRecoveryMessage( @@ -2713,8 +2392,8 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche ? async (_identity, _exactIdentity, boundary) => { const context: VerifiedSandboxCreateEffectsContext = { ...boundary, - revalidatePolicyRequirements: (operation) => - revalidateVerifiedPolicyRegistration(boundary, operation), + verifyLivePolicyRequirements: (operation) => + verifyCurrentPolicyRequirements(boundary, operation), }; await runDeferredProviderEffects(context); } @@ -2764,7 +2443,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche await verifyCreatedSandbox(identity); }, revalidateVerifiedSandboxBeforeEffect: (operation) => - revalidateVerifiedPolicyRegistration(requireVerifiedPolicyGate(), operation), + verifyCurrentPolicyRequirements(requireVerifiedCreateBoundary(), operation), ...agentCreateInput, }, { @@ -2838,15 +2517,13 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche { initialSandboxPolicy, compatibilityPolicyPath, - policyTier: resolvedCreatePolicyTier, - policyAuthority: resolvedPolicyAuthority, - getVerifiedPolicyBoundary: requireVerifiedPolicyGate, + getVerifiedCreateBoundary: requireVerifiedCreateBoundary, getVerifiedCreateRegistrationAuthority: () => ({ reservation: requireCreateReservation(), - checkpoint: requirePendingPolicyVerification(), + checkpoint: requirePendingCreateIdentity(), }), - revalidatePolicyAuthority: (operation) => { - revalidateVerifiedPolicyRegistration(requireVerifiedPolicyGate(), operation); + verifyLivePolicyRequirements: (operation) => { + verifyCurrentPolicyRequirements(requireVerifiedCreateBoundary(), operation); }, dashboardRemoteBindPrepared, }, @@ -2878,7 +2555,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche createdLifecycle: createdSandboxLifecycle, getRecordedRegistration: () => requireDurableCreatedSandboxIdentity( - requireVerifiedPolicyGate().lifecycleLiveIdentityFingerprint, + requireVerifiedCreateBoundary().lifecycleLiveIdentityFingerprint, ), createRegistration: createOnboardCreatedSandboxRegistration, registration: { @@ -2913,8 +2590,8 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche preparationDeps: providerPreparationDeps, runVerifiedSandboxCreateEffects, activateDeferredProviderEffects, - revalidatePolicyAuthorityBeforeCreate: () => - revalidatePolicyAuthority( + verifyLivePolicyRequirementsBeforeCreate: () => + verifyLivePolicyRequirements( false, `publishing providers before creating sandbox gateway '${GATEWAY_NAME}'`, ), @@ -2973,8 +2650,8 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche ), readRegistry: () => registry.getSandbox(sandboxName), revalidatePendingCreateRegistry: () => - revalidateVerifiedPolicyRegistration( - requireVerifiedPolicyGate(), + verifyCurrentPolicyRequirements( + requireVerifiedCreateBoundary(), `requalify verified create checkpoint for sandbox '${sandboxName}'`, ), compareAndSetRegistryGatewayPort: registry.compareAndSetSandboxGatewayPort, @@ -3027,7 +2704,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche () => completeCreatedSandboxRegistration(created, null), () => recordPostCreateRecovery("registry publication"), ); - verifiedPolicyRegistrationFinalized = true; + createEffectsFinalized = true; return registration; }, () => @@ -3038,7 +2715,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche }, createHermesCredentialEnvReconciliationRuntime( (args, options) => runOpenshell([...args], options), - (operation) => revalidatePolicyAuthority(true, operation), + (operation) => verifyLivePolicyRequirements(true, operation), ), () => recordPostCreateRecovery("onboarding finalization"), ), @@ -3060,7 +2737,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche runtimeFields: sandboxRuntimeFields, messagingProviders, liveExists, - ...cancelRecoveryIdentity(liveExists, requireVerifiedPolicyGate), + ...cancelRecoveryIdentity(liveExists, requireVerifiedCreateBoundary), }, { setDefault: registry.setDefault, @@ -3072,17 +2749,18 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxCancelRollback.arm( name, identity, - retainedSandboxRecoveryContext(requireVerifiedPolicyGate()), + retainedSandboxRecoveryContext(requireVerifiedCreateBoundary()), ), markCancellationRecovery: (name) => onboardSession.markCancellationRecovery( name, undefined, - retainedSandboxRecoveryContext(requireVerifiedPolicyGate()), + retainedSandboxRecoveryContext(requireVerifiedCreateBoundary()), ), dockerInfoFormat, runCapture, - revalidatePolicyAuthority: (operation) => revalidatePolicyAuthority(true, operation), + verifyLivePolicyRequirements: (operation) => + verifyLivePolicyRequirements(true, operation), }, ); }, diff --git a/src/lib/onboard/sandbox-create/policy-creation-receipt.test.ts b/src/lib/onboard/sandbox-create/policy-creation-receipt.test.ts deleted file mode 100644 index 4b62e4dddf2..00000000000 --- a/src/lib/onboard/sandbox-create/policy-creation-receipt.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as openshellRuntimeModule from "../../adapters/openshell/runtime"; -import { parseOpenShellPolicy } from "../../policy/merge"; -import { - type CreatedSandboxPolicyReceiptDeps, - pendingSandboxPolicyVerificationForBoundary, - revalidateCreatedSandboxPolicyRegistration, - verifiedSandboxPolicyBoundaryFromPendingCheckpoint, - verifyCreatedApfInterceptorPolicyRegistration, - verifyCreatedSandboxPolicyRegistration, - verifyCreatedSandboxPolicyCreationReceipt, -} from "./policy-creation-receipt"; - -const POLICY = "version: 1\nnetwork_policies:\n github:\n endpoints: []\n"; -const REPLACEMENT_POLICY = - "version: 1\nnetwork_policies:\n github:\n endpoints:\n - host: replacement.example\n"; -const NATIVE_GPU_POLICY = `version: 1 -filesystem_policy: - include_workdir: true - read_only: - - /usr - - /lib - - /etc - - /app - - /var/log - - /dev/urandom - read_write: - - /tmp -network_policies: - github: - endpoints: [] -`; -const ENRICHED_NATIVE_GPU_POLICY = NATIVE_GPU_POLICY.replace( - " read_write:\n - /tmp\n", - ` read_write: - - /tmp - - /proc - - /dev/nvidiactl - - /dev/nvidia0 -`, -); -const PROXY_ONLY_NATIVE_GPU_POLICY = NATIVE_GPU_POLICY.replace( - " - /dev/urandom\n", - " - /dev/urandom\n - /proc\n", -); -const COMPATIBILITY_GPU_POLICY = NATIVE_GPU_POLICY.replace( - " read_write:\n - /tmp\n", - " read_write:\n - /tmp\n - /proc\n", -); -const ENRICHED_COMPATIBILITY_GPU_POLICY = COMPATIBILITY_GPU_POLICY.replace( - " - /proc\n", - " - /proc\n - /dev/nvidiactl\n - /dev/nvidia0\n", -); -const INPUT = { - sandboxName: "alpha", - gatewayName: "nemoclaw", - gatewayPort: 8080, - lifecycleGeneration: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - lifecycleLiveIdentityFingerprint: "b".repeat(64), - policySourcePath: "/private/policy.yaml", - route: "none" as const, -}; -const MANAGED_REGISTRATION = { - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt: { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: INPUT.gatewayName, - gatewayPort: INPUT.gatewayPort, - sandboxName: INPUT.sandboxName, - lifecycleGeneration: INPUT.lifecycleGeneration, - sandboxIdentityFingerprint: INPUT.lifecycleLiveIdentityFingerprint, - policyHash: "sha256:effective", - policyVersion: 4, - }, - observedPolicyAuthority: "owner-unknown" as const, -}; - -function metadata(overrides: Partial> = {}): { - status: number; - output: string; - stdout: string; - stderr: string; -} { - const stdout = JSON.stringify({ - scope: "sandbox", - sandbox: "alpha", - status: "effective", - policy_source: "sandbox", - active_version: 4, - hash: "sha256:effective", - policy: { - version: 1, - network_policies: { github: { endpoints: [] } }, - }, - ...overrides, - }); - return { - status: 0, - output: stdout, - stdout, - stderr: "", - }; -} - -function gatewayInfo(): { status: number; output: string; stdout: string; stderr: string } { - const output = "Gateway endpoint: http://127.0.0.1:8080\n"; - return { status: 0, output, stdout: output, stderr: "" }; -} - -function readyPolicy() { - return { state: "ready" as const }; -} - -function readyReadOnlyPolicyDeps(): CreatedSandboxPolicyReceiptDeps { - return { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }; -} - -const READ_ONLY_REGISTRATION_CASES = [ - { - label: "APF-selected", - policySource: "sandbox", - verify: (deps: CreatedSandboxPolicyReceiptDeps) => - verifyCreatedApfInterceptorPolicyRegistration( - { ...INPUT, operation: "verify APF-selected policy" }, - deps, - ), - }, - { - label: "externally managed", - policySource: "global", - verify: (deps: CreatedSandboxPolicyReceiptDeps) => - verifyCreatedSandboxPolicyRegistration( - { - ...INPUT, - operation: "verify externally managed policy", - plannedAuthority: "externally-managed", - }, - deps, - ), - }, -] as const; - -describe("created sandbox policy receipt", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("restores the exact managed verified boundary from its pending checkpoint (#9833)", () => { - const boundary = { - sandboxName: INPUT.sandboxName, - gatewayName: INPUT.gatewayName, - gatewayPort: INPUT.gatewayPort, - lifecycleGeneration: INPUT.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: INPUT.lifecycleLiveIdentityFingerprint, - route: INPUT.route, - registration: { - policyAuthority: "nemoclaw-managed" as const, - observedPolicyAuthority: "owner-unknown" as const, - policyCreationReceipt: { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: INPUT.gatewayName, - gatewayPort: INPUT.gatewayPort, - sandboxName: INPUT.sandboxName, - lifecycleGeneration: INPUT.lifecycleGeneration, - sandboxIdentityFingerprint: INPUT.lifecycleLiveIdentityFingerprint, - policyHash: "sha256:effective", - policyVersion: 4, - }, - }, - }; - const checkpoint = pendingSandboxPolicyVerificationForBoundary(boundary); - - const restored = verifiedSandboxPolicyBoundaryFromPendingCheckpoint(checkpoint); - - expect(restored).toEqual(boundary); - expect(pendingSandboxPolicyVerificationForBoundary(restored)).toEqual(checkpoint); - }); - - it.each(["externally-managed", "owner-unknown"] as const)( - "restores the exact %s read-only boundary from its pending checkpoint (#9833)", - (observedPolicyAuthority) => { - const boundary = { - sandboxName: INPUT.sandboxName, - gatewayName: INPUT.gatewayName, - gatewayPort: INPUT.gatewayPort, - lifecycleGeneration: INPUT.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: INPUT.lifecycleLiveIdentityFingerprint, - route: "native" as const, - registration: { - policyAuthority: "externally-managed" as const, - policyCreationReceipt: null, - observedPolicyAuthority, - policyIdentity: { hash: "sha256:effective", activeVersion: 4 }, - }, - }; - const checkpoint = pendingSandboxPolicyVerificationForBoundary(boundary); - - const restored = verifiedSandboxPolicyBoundaryFromPendingCheckpoint(checkpoint); - - expect(restored).toEqual(boundary); - expect(pendingSandboxPolicyVerificationForBoundary(restored)).toEqual(checkpoint); - }, - ); - - it("rejects a missing or malformed pending checkpoint before restoring authority (#9833)", () => { - expect(() => verifiedSandboxPolicyBoundaryFromPendingCheckpoint(undefined)).toThrow( - /without a complete verified policy checkpoint/u, - ); - expect(() => - verifiedSandboxPolicyBoundaryFromPendingCheckpoint({ - schemaVersion: 1, - state: "verified-create", - policyAuthority: "externally-managed", - }), - ).toThrow(/invalid pending policy verification/u); - }); - - it("binds the exact supplied policy to the verified create identity (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce({ status: 0, output: POLICY, stdout: POLICY, stderr: "" }) - .mockReturnValueOnce(metadata()); - const receipt = verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }); - - expect(receipt).toEqual({ - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "alpha", - lifecycleGeneration: INPUT.lifecycleGeneration, - sandboxIdentityFingerprint: INPUT.lifecycleLiveIdentityFingerprint, - policyHash: "sha256:effective", - policyVersion: 4, - }); - expect(JSON.stringify(receipt)).not.toMatch(/github|credential|endpoints/u); - }); - - it("refuses a live base policy that differs from the create source (#9833)", () => { - const captureOpenshell = vi - .spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce( - metadata({ policy: parseOpenShellPolicy("version: 1\nnetwork_policies: {}\n").policy }), - ) - .mockReturnValueOnce({ - status: 0, - output: "version: 1\nnetwork_policies: {}\n", - stdout: "version: 1\nnetwork_policies: {}\n", - stderr: "", - }) - .mockReturnValueOnce( - metadata({ policy: parseOpenShellPolicy("version: 1\nnetwork_policies: {}\n").policy }), - ); - expect(() => - verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }), - ).toThrow(/live base policy does not match/u); - expect(captureOpenshell).toHaveBeenCalledTimes(4); - }); - - it.each([ - { - route: "native" as const, - intendedPolicy: NATIVE_GPU_POLICY, - livePolicy: ENRICHED_NATIVE_GPU_POLICY, - }, - { - route: "native" as const, - intendedPolicy: NATIVE_GPU_POLICY, - livePolicy: PROXY_ONLY_NATIVE_GPU_POLICY, - }, - { - route: "compatibility" as const, - intendedPolicy: COMPATIBILITY_GPU_POLICY, - livePolicy: ENRICHED_COMPATIBILITY_GPU_POLICY, - }, - ])( - "binds documented $route-GPU policy enrichment to the create receipt (#9833)", - ({ route, intendedPolicy, livePolicy }) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce( - metadata({ policy: parseOpenShellPolicy(livePolicy).policy }), - ) - .mockReturnValueOnce({ - status: 0, - output: livePolicy, - stdout: livePolicy, - stderr: "", - }) - .mockReturnValueOnce( - metadata({ policy: parseOpenShellPolicy(livePolicy).policy }), - ); - - expect( - verifyCreatedSandboxPolicyCreationReceipt( - { ...INPUT, route }, - { - readFile: vi.fn(() => intendedPolicy) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }, - ), - ).toMatchObject({ - policyHash: "sha256:effective", - policyVersion: 4, - }); - }, - ); - - it.each([ - { - label: "the create route does not use GPU injection", - input: INPUT, - livePolicy: ENRICHED_NATIVE_GPU_POLICY, - }, - { - label: "the native live policy contains an arbitrary added path", - input: { ...INPUT, route: "native" as const }, - livePolicy: ENRICHED_NATIVE_GPU_POLICY.replace("/dev/nvidia0", "/home"), - }, - { - label: "the proxy-only native live policy contains a GPU device path", - input: { ...INPUT, route: "native" as const }, - livePolicy: PROXY_ONLY_NATIVE_GPU_POLICY.replace( - " read_write:\n - /tmp\n", - " read_write:\n - /tmp\n - /dev/nvidia0\n", - ), - }, - { - label: "the compatibility live policy contains an arbitrary added path", - input: { ...INPUT, route: "compatibility" as const }, - livePolicy: ENRICHED_COMPATIBILITY_GPU_POLICY.replace("/dev/nvidia0", "/home"), - }, - ])("refuses native-GPU policy enrichment when $label (#9833)", ({ input, livePolicy }) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy: parseOpenShellPolicy(livePolicy).policy })) - .mockReturnValueOnce({ status: 0, output: livePolicy, stdout: livePolicy, stderr: "" }) - .mockReturnValueOnce(metadata({ policy: parseOpenShellPolicy(livePolicy).policy })); - - expect(() => - verifyCreatedSandboxPolicyCreationReceipt(input, { - readFile: vi.fn(() => NATIVE_GPU_POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }), - ).toThrow(/live base policy does not match/u); - }); - - it("does not claim a verified global policy as NemoClaw-created (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy_source: "global" })) - .mockReturnValueOnce({ status: 0, output: POLICY, stdout: POLICY, stderr: "" }) - .mockReturnValueOnce(metadata({ policy_source: "global" })); - expect(() => - verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }), - ).toThrow(/does not report.*sandbox-scoped/u); - }); - - it("refuses incomplete OpenShell policy identity without exposing policy contents (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValue(metadata({ hash: "" })); - let error: unknown; - try { - verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }); - } catch (caught) { - error = caught; - } - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("policy authority inspection failed"); - expect((error as Error).message).not.toContain("github"); - }); - - it("refuses policy identity drift after authoritative sandbox readiness (#9833)", () => { - const events: string[] = []; - const sleep = vi.fn(() => events.push("poll")); - const inspectPolicyReadiness = vi - .fn() - .mockImplementationOnce(() => { - events.push("policy-version-pending"); - return { state: "transient", reason: "policy-version-pending" } as const; - }) - .mockImplementationOnce(() => { - events.push("policy-ready"); - return { state: "ready" } as const; - }); - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockImplementationOnce(() => { - events.push("policy-initial"); - return metadata(); - }) - .mockImplementationOnce(() => { - events.push("base-policy"); - return { status: 0, output: POLICY, stdout: POLICY, stderr: "" }; - }) - .mockImplementationOnce(() => { - events.push("policy-later"); - return metadata({ hash: "sha256:replacement", active_version: 5 }); - }); - - expect(() => - verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness, - sleep, - }), - ).toThrow(/policy identity changed/u); - expect(events).toEqual([ - "policy-initial", - "policy-version-pending", - "poll", - "policy-ready", - "base-policy", - "policy-later", - ]); - expect(sleep).toHaveBeenCalledExactlyOnceWith(1); - }); - - it("refuses replacement policy bytes between stable identity observations (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce({ - status: 0, - output: REPLACEMENT_POLICY, - stdout: REPLACEMENT_POLICY, - stderr: "", - }) - .mockReturnValueOnce(metadata()); - - expect(() => - verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => REPLACEMENT_POLICY) as never, - inspectPolicyReadiness: readyPolicy, - sleep: vi.fn(), - }), - ).toThrow(/policy evidence changed during receipt verification/u); - }); - - it("fails closed when the exact sandbox never activates the policy version (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce({ status: 0, output: POLICY, stdout: POLICY, stderr: "" }); - const inspectPolicyReadiness = vi.fn(() => ({ - state: "transient" as const, - reason: "policy-version-pending" as const, - })); - const sleep = vi.fn(); - - expect(() => - verifyCreatedSandboxPolicyCreationReceipt(INPUT, { - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness, - sleep, - }), - ).toThrow(/did not activate the verified policy version/u); - expect(inspectPolicyReadiness).toHaveBeenCalledTimes(5); - expect(sleep).toHaveBeenCalledTimes(4); - }); - - it("records a contained APF-selected sandbox policy as external without provenance (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce(metadata()); - - expect( - verifyCreatedApfInterceptorPolicyRegistration( - { ...INPUT, operation: "verify APF-selected policy" }, - readyReadOnlyPolicyDeps(), - ), - ).toEqual({ - policyAuthority: "externally-managed", - policyCreationReceipt: null, - observedPolicyAuthority: "owner-unknown", - policyIdentity: { hash: "sha256:effective", activeVersion: 4 }, - }); - }); - - it("verifies externally managed policy through the production entrypoint (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy_source: "global" })) - .mockReturnValueOnce(metadata({ policy_source: "global" })); - - expect( - verifyCreatedSandboxPolicyRegistration( - { - ...INPUT, - operation: "verify externally managed policy", - plannedAuthority: "externally-managed", - }, - readyReadOnlyPolicyDeps(), - ), - ).toEqual({ - policyAuthority: "externally-managed", - policyCreationReceipt: null, - observedPolicyAuthority: "externally-managed", - policyIdentity: { hash: "sha256:effective", activeVersion: 4 }, - }); - }); - - it("revalidates APF-selected owner-unknown containment without changing attribution (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()); - - const registration = { - policyAuthority: "externally-managed" as const, - policyCreationReceipt: null, - observedPolicyAuthority: "owner-unknown" as const, - policyIdentity: { hash: "sha256:effective", activeVersion: 4 }, - }; - expect( - revalidateCreatedSandboxPolicyRegistration( - { - ...INPUT, - operation: "continue APF-selected onboarding", - registration, - }, - { readFile: vi.fn(() => POLICY) as never }, - ), - ).toBe(registration); - }); - - it("does not expose receipt refresh through the registration revalidation API (#9833)", () => { - const replacementMetadata = metadata({ - hash: "sha256:replacement", - active_version: 5, - }); - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(replacementMetadata); - - expect(() => - revalidateCreatedSandboxPolicyRegistration( - { - ...INPUT, - route: "native", - operation: "continue verified sandbox creation", - registration: MANAGED_REGISTRATION, - }, - readyReadOnlyPolicyDeps(), - ), - ).toThrow(/creation receipt no longer matches/u); - }); - - it("does not refresh a managed receipt outside its verified create transaction (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ hash: "sha256:replacement", active_version: 5 })); - - expect(() => - revalidateCreatedSandboxPolicyRegistration({ - ...INPUT, - operation: "mutate a completed sandbox", - registration: MANAGED_REGISTRATION, - }), - ).toThrow(/creation receipt no longer matches/u); - }); - - it.each(READ_ONLY_REGISTRATION_CASES)( - "waits for the exact $label sandbox policy version before registration (#9833)", - ({ policySource, verify }) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy_source: policySource })) - .mockReturnValueOnce(metadata({ policy_source: policySource })); - const inspectPolicyReadiness = vi - .fn() - .mockReturnValueOnce({ - state: "transient" as const, - reason: "policy-version-pending" as const, - }) - .mockReturnValueOnce({ state: "ready" as const }); - const sleep = vi.fn(); - - expect( - verify({ - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness, - sleep, - }), - ).toMatchObject({ policyAuthority: "externally-managed" }); - expect(inspectPolicyReadiness).toHaveBeenCalledTimes(2); - expect(sleep).toHaveBeenCalledExactlyOnceWith(1); - }, - ); - - it.each(READ_ONLY_REGISTRATION_CASES)( - "refuses $label registration while the exact sandbox remains non-Ready (#9833)", - ({ policySource, verify }) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy_source: policySource })); - const inspectPolicyReadiness = vi.fn(() => ({ - state: "transient" as const, - reason: "sandbox-not-ready" as const, - })); - const sleep = vi.fn(); - - expect(() => - verify({ - readFile: vi.fn(() => POLICY) as never, - inspectPolicyReadiness, - sleep, - }), - ).toThrow(/did not reach Ready during policy verification/u); - expect(inspectPolicyReadiness).toHaveBeenCalledTimes(5); - expect(sleep).toHaveBeenCalledTimes(4); - }, - ); - - it("refuses a global policy source for APF-selected creation (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy_source: "global" })); - - expect(() => - verifyCreatedApfInterceptorPolicyRegistration( - { ...INPUT, operation: "verify APF-selected policy" }, - readyReadOnlyPolicyDeps(), - ), - ).toThrow(/does not match the selected read-only policy source/u); - }); - - it("refuses an APF-selected policy that omits required entries (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata({ policy: { version: 1, network_policies: {} } })); - - expect(() => - verifyCreatedApfInterceptorPolicyRegistration( - { ...INPUT, operation: "verify APF-selected policy" }, - readyReadOnlyPolicyDeps(), - ), - ).toThrow(/verified policy must supply the exact required entries/u); - }); - - it.each([ - ["hash", { hash: "sha256:replacement" }], - ["active version", { active_version: 5 }], - ])("refuses an APF-selected policy with a changed %s (#9833)", (_field, change) => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce(metadata(change)); - - expect(() => - verifyCreatedApfInterceptorPolicyRegistration( - { ...INPUT, operation: "verify APF-selected policy" }, - readyReadOnlyPolicyDeps(), - ), - ).toThrow(/effective sandbox policy changed during verification/u); - }); - - it("refuses changed APF-selected policy contents under an unchanged reported identity (#9833)", () => { - vi.spyOn(openshellRuntimeModule, "captureResolvedOpenshell") - .mockReturnValueOnce(gatewayInfo()) - .mockReturnValueOnce(metadata()) - .mockReturnValueOnce(metadata({ policy: { version: 1, network_policies: {} } })); - - expect(() => - verifyCreatedApfInterceptorPolicyRegistration( - { ...INPUT, operation: "verify APF-selected policy" }, - readyReadOnlyPolicyDeps(), - ), - ).toThrow(/verified policy must supply the exact required entries/u); - }); -}); diff --git a/src/lib/onboard/sandbox-create/policy-creation-receipt.ts b/src/lib/onboard/sandbox-create/policy-creation-receipt.ts deleted file mode 100644 index 9e1df18a93b..00000000000 --- a/src/lib/onboard/sandbox-create/policy-creation-receipt.ts +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import { isDeepStrictEqual } from "node:util"; - -import { - assertExternalPolicyRequirements, - assertObservedPolicyRequirements, - assertOpenShellGatewayPortBinding, - captureSandboxBasePolicy, - inspectOpenShellSandboxPolicyReadiness, - inspectSandboxPolicyAuthority, - PolicyAuthorityRefusalError, - type SandboxPolicyAuthority, -} from "../../adapters/openshell/policy-authority"; -import { waitUntil } from "../../core/wait"; -import type { NemoClawPolicyCreationReceipt } from "../../policy/merge"; -import { normalizePendingSandboxPolicyVerification } from "../../state/registry-normalization"; -import type { PendingSandboxPolicyVerification } from "../../state/registry/types"; -import { - assertNemoClawPolicyCreationReceiptMatches, - parseNemoClawPolicyCreationReceipt, - parseOpenShellPolicy, - withoutProviderComposedPolicies, -} from "../../policy/merge"; -import type { SelectedDockerGpuRoute } from "../docker-gpu-route"; -import { isOpenShellGpuBaselineEnrichment } from "../sandbox-gpu-route-policy"; -import type { VerifiedSandboxPolicyBoundary, VerifiedSandboxPolicyRegistration } from "../types"; - -export interface CreatedSandboxPolicyReceiptInput { - readonly sandboxName: string; - readonly gatewayName: string; - readonly gatewayPort: number; - readonly lifecycleGeneration: string; - readonly lifecycleLiveIdentityFingerprint: string; - readonly policySourcePath: string; - readonly route: SelectedDockerGpuRoute; -} - -export interface CreatedSandboxPolicyReceiptDeps { - readonly readFile?: typeof fs.readFileSync; - readonly inspectPolicyReadiness?: typeof inspectOpenShellSandboxPolicyReadiness; - readonly sleep?: (seconds: number) => void; -} - -const POLICY_READINESS_MAX_OBSERVATIONS = 5; -const POLICY_READINESS_POLL_INTERVAL_SECONDS = 1; - -export interface CreatedSandboxPolicyRegistrationInput extends CreatedSandboxPolicyReceiptInput { - readonly plannedAuthority: Exclude; - readonly operation: string; -} - -/** Flatten one in-memory verified boundary into its non-authorizing durable checkpoint. */ -export function pendingSandboxPolicyVerificationForBoundary( - boundary: VerifiedSandboxPolicyBoundary, -): PendingSandboxPolicyVerification { - const common = { - schemaVersion: 1 as const, - state: "verified-create" as const, - gatewayName: boundary.gatewayName, - gatewayPort: boundary.gatewayPort, - sandboxName: boundary.sandboxName, - lifecycleGeneration: boundary.lifecycleGeneration, - sandboxIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint, - ...(boundary.createAttemptNonce ? { createAttemptNonce: boundary.createAttemptNonce } : {}), - route: boundary.route, - }; - const registration = boundary.registration; - if (registration.policyAuthority === "nemoclaw-managed") { - return { - ...common, - policyAuthority: "nemoclaw-managed", - observedPolicyAuthority: "owner-unknown", - policyHash: registration.policyCreationReceipt.policyHash, - policyVersion: registration.policyCreationReceipt.policyVersion, - policyCreationReceipt: registration.policyCreationReceipt, - }; - } - return { - ...common, - policyAuthority: "externally-managed", - observedPolicyAuthority: registration.observedPolicyAuthority, - policyHash: registration.policyIdentity.hash, - policyVersion: registration.policyIdentity.activeVersion, - }; -} - -/** Restore only the non-authorizing policy boundary captured by a durable create checkpoint. */ -export function verifiedSandboxPolicyBoundaryFromPendingCheckpoint( - value: unknown, -): VerifiedSandboxPolicyBoundary { - const checkpoint = normalizePendingSandboxPolicyVerification(value); - if (!checkpoint) { - throw new PolicyAuthorityRefusalError( - "Cannot resume sandbox creation without a complete verified policy checkpoint.", - ); - } - const common = { - sandboxName: checkpoint.sandboxName, - gatewayName: checkpoint.gatewayName, - gatewayPort: checkpoint.gatewayPort, - lifecycleGeneration: checkpoint.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - ...(checkpoint.createAttemptNonce ? { createAttemptNonce: checkpoint.createAttemptNonce } : {}), - route: checkpoint.route, - }; - if (checkpoint.policyAuthority === "nemoclaw-managed") { - return { - ...common, - registration: { - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: checkpoint.policyCreationReceipt, - observedPolicyAuthority: "owner-unknown", - }, - }; - } - return { - ...common, - registration: { - policyAuthority: "externally-managed", - policyCreationReceipt: null, - observedPolicyAuthority: checkpoint.observedPolicyAuthority, - policyIdentity: { - hash: checkpoint.policyHash, - activeVersion: checkpoint.policyVersion, - }, - }, - }; -} - -function refusal(reason: string): never { - throw new PolicyAuthorityRefusalError( - `Cannot record NemoClaw policy ownership: ${reason}. The sandbox remains owner-unknown and policy mutation is disabled.`, - ); -} - -function basePolicyFromEffectivePolicy( - policy: ReturnType["policy"], -): ReturnType["policy"] { - const networkPolicies = policy.network_policies; - if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { - return policy; - } - return { - ...policy, - network_policies: withoutProviderComposedPolicies(networkPolicies as never), - }; -} - -function waitForCreatedSandboxPolicyReadiness( - input: CreatedSandboxPolicyReceiptInput, - policyVersion: number, - deps: CreatedSandboxPolicyReceiptDeps, - reject: (reason: string) => never = refusal, -): void { - const inspectReadiness = deps.inspectPolicyReadiness ?? inspectOpenShellSandboxPolicyReadiness; - const sleep = deps.sleep; - const lastObservation = { - reason: "policy-version-pending" as "sandbox-not-ready" | "policy-version-pending", - }; - const ready = waitUntil( - () => { - const readiness = inspectReadiness({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - sandboxIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, - policyVersion, - }); - if (readiness.state === "ready") return true; - lastObservation.reason = readiness.reason; - return false; - }, - { - maxAttempts: POLICY_READINESS_MAX_OBSERVATIONS, - initialIntervalMs: POLICY_READINESS_POLL_INTERVAL_SECONDS * 1_000, - maxIntervalMs: POLICY_READINESS_POLL_INTERVAL_SECONDS * 1_000, - backoffFactor: 1, - sleep: (milliseconds) => { - if (!sleep) reject("the bounded policy readiness check could not continue"); - sleep(milliseconds / 1_000); - }, - }, - ); - if (!ready) { - reject( - lastObservation.reason === "sandbox-not-ready" - ? "the exact sandbox did not reach Ready during policy verification" - : "the exact sandbox did not activate the verified policy version", - ); - } -} - -/** - * Bind one successful create to its exact sandbox and effective policy. - * Policy bytes are compared in memory and never enter the receipt or error. - */ -export function verifyCreatedSandboxPolicyCreationReceipt( - input: CreatedSandboxPolicyReceiptInput, - deps: CreatedSandboxPolicyReceiptDeps = {}, -): NemoClawPolicyCreationReceipt { - assertOpenShellGatewayPortBinding({ - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - }); - const readFile = deps.readFile ?? fs.readFileSync; - let intendedPolicy: ReturnType["policy"]; - try { - intendedPolicy = parseOpenShellPolicy(readFile(input.policySourcePath, "utf8")).policy; - } catch { - refusal("the intended base policy could not be read"); - } - - const before = inspectSandboxPolicyAuthority({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - waitForCreatedSandboxPolicyReadiness(input, before.policyIdentity.activeVersion, deps); - let liveBasePolicy: ReturnType["policy"]; - try { - liveBasePolicy = parseOpenShellPolicy( - captureSandboxBasePolicy(input.sandboxName, input.gatewayName), - ).policy; - } catch { - refusal("the live base policy could not be compared"); - } - const observedBasePolicy = basePolicyFromEffectivePolicy(before.effectivePolicy); - if (!isDeepStrictEqual(observedBasePolicy, liveBasePolicy)) { - refusal("the policy evidence changed during receipt verification"); - } - const after = inspectSandboxPolicyAuthority({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - if (before.authority !== "owner-unknown" || after.authority !== "owner-unknown") { - refusal("OpenShell does not report the verified policy as sandbox-scoped"); - } - if ( - before.policyIdentity.hash !== after.policyIdentity.hash || - before.policyIdentity.activeVersion !== after.policyIdentity.activeVersion - ) { - refusal("the effective policy identity changed during receipt verification"); - } - if ( - !isDeepStrictEqual(intendedPolicy, liveBasePolicy) && - !( - input.route !== "none" && - isOpenShellGpuBaselineEnrichment(intendedPolicy, liveBasePolicy, input.route) - ) - ) { - refusal("the live base policy does not match the policy supplied by this create transaction"); - } - try { - return parseNemoClawPolicyCreationReceipt({ - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - sandboxName: input.sandboxName, - lifecycleGeneration: input.lifecycleGeneration, - sandboxIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, - policyHash: after.policyIdentity.hash, - policyVersion: after.policyIdentity.activeVersion, - }); - } catch { - refusal("the verified sandbox or policy identity is incomplete"); - } -} - -function readRequiredPolicy( - policySourcePath: string, - operation: string, - deps: CreatedSandboxPolicyReceiptDeps, -): ReturnType["policy"] { - try { - return parseOpenShellPolicy((deps.readFile ?? fs.readFileSync)(policySourcePath, "utf8")) - .policy; - } catch { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the required sandbox policy could not be read.`, - ); - } -} - -function verifyReadOnlyPolicyBoundary( - input: Omit, - deps: CreatedSandboxPolicyReceiptDeps, - observedAuthority: "externally-managed" | "owner-unknown", -): VerifiedSandboxPolicyRegistration { - assertOpenShellGatewayPortBinding({ - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - }); - const requiredPolicy = readRequiredPolicy(input.policySourcePath, input.operation, deps); - const before = inspectSandboxPolicyAuthority({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - if (before.authority !== observedAuthority) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the created sandbox policy authority does not match the selected read-only policy source.`, - before.authority, - ); - } - const assertRequirements = - observedAuthority === "owner-unknown" - ? assertObservedPolicyRequirements - : assertExternalPolicyRequirements; - assertRequirements({ - inspection: before, - requiredPolicy, - operation: input.operation, - sandboxName: input.sandboxName, - }); - waitForCreatedSandboxPolicyReadiness( - input, - before.policyIdentity.activeVersion, - deps, - (reason) => { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: ${reason}.`, - before.authority, - ); - }, - ); - const after = inspectSandboxPolicyAuthority({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - if ( - after.authority !== before.authority || - after.policyIdentity.hash !== before.policyIdentity.hash || - after.policyIdentity.activeVersion !== before.policyIdentity.activeVersion - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the effective sandbox policy changed during verification.`, - after.authority, - ); - } - assertRequirements({ - inspection: after, - requiredPolicy, - operation: input.operation, - sandboxName: input.sandboxName, - }); - return { - policyAuthority: "externally-managed", - policyCreationReceipt: null, - observedPolicyAuthority: observedAuthority, - policyIdentity: { ...before.policyIdentity }, - }; -} - -/** Verify a policyless APF-selected create without claiming APF provenance. */ -export function verifyCreatedApfInterceptorPolicyRegistration( - input: Omit, - deps: CreatedSandboxPolicyReceiptDeps = {}, -): VerifiedSandboxPolicyRegistration { - return verifyReadOnlyPolicyBoundary(input, deps, "owner-unknown"); -} - -/** Prove the post-create policy before any unrelated create effects run. */ -export function verifyCreatedSandboxPolicyRegistration( - input: CreatedSandboxPolicyRegistrationInput, - deps: CreatedSandboxPolicyReceiptDeps = {}, -): VerifiedSandboxPolicyRegistration { - if (input.plannedAuthority === "nemoclaw-managed") { - return { - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: verifyCreatedSandboxPolicyCreationReceipt(input, deps), - observedPolicyAuthority: "owner-unknown", - }; - } - return verifyReadOnlyPolicyBoundary(input, deps, "externally-managed"); -} - -type CreatedSandboxPolicyRevalidationInput = Omit< - CreatedSandboxPolicyRegistrationInput, - "plannedAuthority" -> & { - readonly registration: VerifiedSandboxPolicyRegistration; -}; - -function revalidateCreatedSandboxPolicyRegistrationInternal( - input: CreatedSandboxPolicyRevalidationInput, - deps: CreatedSandboxPolicyReceiptDeps, -): VerifiedSandboxPolicyRegistration { - assertOpenShellGatewayPortBinding({ - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - }); - const before = inspectSandboxPolicyAuthority({ - sandboxName: input.sandboxName, - gatewayName: input.gatewayName, - }); - const registration = input.registration; - if (before.authority !== registration.observedPolicyAuthority) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the effective sandbox policy authority changed after verification.`, - before.authority, - ); - } - if (registration.policyAuthority === "nemoclaw-managed") { - try { - assertNemoClawPolicyCreationReceiptMatches(registration.policyCreationReceipt, { - origin: "sandbox-create", - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - sandboxName: input.sandboxName, - lifecycleGeneration: input.lifecycleGeneration, - sandboxIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, - policyHash: before.policyIdentity.hash, - policyVersion: before.policyIdentity.activeVersion, - }); - } catch (error) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the NemoClaw policy creation receipt no longer matches the live sandbox policy.`, - "owner-unknown", - { cause: error }, - ); - } - return registration; - } - if ( - before.policyIdentity.hash !== registration.policyIdentity.hash || - before.policyIdentity.activeVersion !== registration.policyIdentity.activeVersion - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${input.operation}: the effective sandbox policy identity changed after verification.`, - before.authority, - ); - } - const requiredPolicy = readRequiredPolicy(input.policySourcePath, input.operation, deps); - const assertRequirements = - registration.observedPolicyAuthority === "owner-unknown" - ? assertObservedPolicyRequirements - : assertExternalPolicyRequirements; - assertRequirements({ - inspection: before, - requiredPolicy, - operation: input.operation, - sandboxName: input.sandboxName, - }); - return registration; -} - -/** Revalidate one in-memory gate result against the exact live policy identity. */ -export function revalidateCreatedSandboxPolicyRegistration( - input: CreatedSandboxPolicyRevalidationInput, - deps: CreatedSandboxPolicyReceiptDeps = {}, -): VerifiedSandboxPolicyRegistration { - return revalidateCreatedSandboxPolicyRegistrationInternal(input, deps); -} diff --git a/src/lib/onboard/sandbox-create/rebuild-policy-requirements.test.ts b/src/lib/onboard/sandbox-create/rebuild-policy-requirements.test.ts new file mode 100644 index 00000000000..880ae702d95 --- /dev/null +++ b/src/lib/onboard/sandbox-create/rebuild-policy-requirements.test.ts @@ -0,0 +1,97 @@ +// 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 YAML from "yaml"; +import { describe, expect, it } from "vitest"; + +import { + materializeRebuildCreatePolicy, + mergeRebuildPolicyRequirements, +} from "./rebuild-policy-requirements"; + +const LIVE_POLICY = ` +version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr] + read_write: [/sandbox, /tmp] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +network_policies: + managed_inference: + name: managed_inference + endpoints: [{host: inference.local, port: 443}] + host_edit: + name: host_edit + endpoints: [{host: host.example.com, port: 443}] +`; + +const CURRENT_POLICY = ` +version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /run/nemoclaw/managed-startup-runtime.env] + read_write: [/sandbox, /tmp, /dev/pts, /run/nemoclaw/runtime-state-mutation-startup] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +network_policies: + managed_inference: + name: managed_inference + endpoints: [{host: replacement.example.com, port: 443}] + current_required: + name: current_required + endpoints: [{host: required.example.com, port: 443}] +`; + +describe("rebuild create policy requirements", () => { + it("preserves host policy choices while adding missing replacement requirements", () => { + const merged = YAML.parse(mergeRebuildPolicyRequirements(LIVE_POLICY, CURRENT_POLICY)) as { + filesystem_policy: { read_only: string[]; read_write: string[] }; + network_policies: Record }>; + }; + + expect(merged.filesystem_policy.read_only).toEqual([ + "/usr", + "/run/nemoclaw/managed-startup-runtime.env", + ]); + expect(merged.filesystem_policy.read_write).toEqual([ + "/sandbox", + "/tmp", + "/dev/pts", + "/run/nemoclaw/runtime-state-mutation-startup", + ]); + expect(merged.network_policies.host_edit).toBeDefined(); + expect(merged.network_policies.current_required).toBeDefined(); + expect(merged.network_policies.managed_inference?.endpoints[0]?.host).toBe("inference.local"); + }); + + it("materializes one private ephemeral policy and removes only that generation", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-requirements-test-")); + const livePath = path.join(root, "live.yaml"); + const currentPath = path.join(root, "current.yaml"); + fs.writeFileSync(livePath, LIVE_POLICY, { mode: 0o600 }); + fs.writeFileSync(currentPath, CURRENT_POLICY, { mode: 0o600 }); + try { + const policy = materializeRebuildCreatePolicy({ + livePolicyPath: livePath, + currentPolicy: { policyPath: currentPath, appliedPresets: [] }, + }); + expect(fs.statSync(policy.policyPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(policy.policyPath, "utf8")).toContain("host_edit"); + expect(policy.cleanup?.()).toBe(true); + expect(fs.existsSync(policy.policyPath)).toBe(false); + expect(fs.existsSync(livePath)).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/onboard/sandbox-create/rebuild-policy-requirements.ts b/src/lib/onboard/sandbox-create/rebuild-policy-requirements.ts new file mode 100644 index 00000000000..f61abadc2ba --- /dev/null +++ b/src/lib/onboard/sandbox-create/rebuild-policy-requirements.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import YAML from "yaml"; + +import { parseOpenShellPolicy } from "../../policy/merge"; +import type { InitialSandboxPolicy } from "../initial-policy"; +import { cleanupTempDir, createExactTempFileCleanup, secureTempFile } from "../temp-files"; + +const REBUILD_CREATE_POLICY_PREFIX = "nemoclaw-rebuild-create-policy"; + +function isPolicyMapping(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function clonePolicyValue(value: T): T { + return structuredClone(value); +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new Error(`Cannot prepare rebuild policy: ${label} must be a string array.`); + } + return [...value]; +} + +function unionStrings(existing: string[], required: string[]): string[] { + return [...existing, ...required.filter((entry) => !existing.includes(entry))]; +} + +/** + * Preserve the complete live OpenShell base policy while adding only current + * replacement-image requirements that are absent from an older sandbox. + */ +export function mergeRebuildPolicyRequirements( + livePolicySource: string, + currentPolicySource: string, +): string { + const live = clonePolicyValue(parseOpenShellPolicy(livePolicySource).policy) as Record< + string, + unknown + >; + const current = parseOpenShellPolicy(currentPolicySource).policy as Record; + + const liveFilesystem = live.filesystem_policy; + const currentFilesystem = current.filesystem_policy; + if (currentFilesystem !== undefined) { + if (!isPolicyMapping(currentFilesystem)) { + throw new Error( + "Cannot prepare rebuild policy: current filesystem_policy must be a mapping.", + ); + } + if (liveFilesystem !== undefined && !isPolicyMapping(liveFilesystem)) { + throw new Error("Cannot prepare rebuild policy: live filesystem_policy must be a mapping."); + } + const mergedFilesystem = clonePolicyValue( + isPolicyMapping(liveFilesystem) ? liveFilesystem : {}, + ); + for (const field of ["read_only", "read_write"] as const) { + const required = currentFilesystem[field]; + if (required === undefined) continue; + const existing = mergedFilesystem[field]; + mergedFilesystem[field] = unionStrings( + existing === undefined ? [] : stringArray(existing, `live filesystem_policy.${field}`), + stringArray(required, `current filesystem_policy.${field}`), + ); + } + for (const [key, value] of Object.entries(currentFilesystem)) { + if (!Object.hasOwn(mergedFilesystem, key)) mergedFilesystem[key] = clonePolicyValue(value); + } + live.filesystem_policy = mergedFilesystem; + } + + const currentNetwork = current.network_policies; + if (currentNetwork !== undefined) { + if (!isPolicyMapping(currentNetwork)) { + throw new Error("Cannot prepare rebuild policy: current network_policies must be a mapping."); + } + const liveNetwork = live.network_policies; + if (liveNetwork !== undefined && !isPolicyMapping(liveNetwork)) { + throw new Error("Cannot prepare rebuild policy: live network_policies must be a mapping."); + } + const mergedNetwork = clonePolicyValue(isPolicyMapping(liveNetwork) ? liveNetwork : {}); + for (const [key, value] of Object.entries(currentNetwork)) { + if (!Object.hasOwn(mergedNetwork, key)) mergedNetwork[key] = clonePolicyValue(value); + } + live.network_policies = mergedNetwork; + } + + for (const [key, value] of Object.entries(current)) { + if ( + key !== "version" && + key !== "filesystem_policy" && + key !== "network_policies" && + !Object.hasOwn(live, key) + ) { + live[key] = clonePolicyValue(value); + } + } + return YAML.stringify(live); +} + +export function materializeRebuildCreatePolicy(input: { + readonly livePolicyPath: string; + readonly currentPolicy: InitialSandboxPolicy; +}): InitialSandboxPolicy { + const policyPath = secureTempFile(REBUILD_CREATE_POLICY_PREFIX, ".yaml"); + try { + const source = mergeRebuildPolicyRequirements( + fs.readFileSync(input.livePolicyPath, "utf8"), + input.currentPolicy.sourceBytes?.toString("utf8") ?? + fs.readFileSync(input.currentPolicy.policyPath, "utf8"), + ); + fs.writeFileSync(policyPath, source, { encoding: "utf8", flag: "wx", mode: 0o600 }); + const cleanup = createExactTempFileCleanup(policyPath, REBUILD_CREATE_POLICY_PREFIX); + return { + ...input.currentPolicy, + policyPath, + sourceBytes: Buffer.from(source), + cleanup, + cleanupExact: cleanup, + }; + } catch (error) { + cleanupTempDir(policyPath, REBUILD_CREATE_POLICY_PREFIX); + throw error; + } +} diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 8b5b9bc7190..7fc48e8bd39 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -267,7 +267,7 @@ export interface SandboxGpuCreateFlowInput { * readiness, GPU, service, dashboard, or registry effects continue. */ verifyCreatedSandboxBeforeEffects?: (identity: CreatedSandboxIdentity) => void | Promise; - /** Re-read the exact durable policy checkpoint before each post-create effect. */ + /** Re-read the exact pending create identity before each post-create effect. */ revalidateVerifiedSandboxBeforeEffect?: (operation: string) => void; } diff --git a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts index 9204e0deab8..da861fcbdc7 100644 --- a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts +++ b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts @@ -732,10 +732,12 @@ export function createSandboxGpuCreateAttemptRunner( : resolveOpenShellSandboxId(input.sandboxName, deps.runCaptureOpenshell); } catch (error) { if (createAttemptNonce) persistIdentitySettlementRecovery(); + const diagnostic = + error instanceof Error ? ` ${error.message}` : " Identity settlement failed."; throw new Error( createFailure?.kind === "sandbox_create_incomplete" - ? "Managed bootstrap incomplete create did not return one exact durable sandbox identity after Ready." - : "Managed bootstrap create did not return one exact durable sandbox identity after Ready.", + ? `Managed bootstrap incomplete create did not return one exact durable sandbox identity after Ready.${diagnostic}` + : `Managed bootstrap create did not return one exact durable sandbox identity after Ready.${diagnostic}`, { cause: error }, ); } diff --git a/src/lib/onboard/sandbox-lifecycle.test.ts b/src/lib/onboard/sandbox-lifecycle.test.ts index 85be4fcf097..573accc81cd 100644 --- a/src/lib/onboard/sandbox-lifecycle.test.ts +++ b/src/lib/onboard/sandbox-lifecycle.test.ts @@ -122,7 +122,7 @@ describe("sandbox recreate reservation ownership", () => { name: "alpha", pendingRouteReservation: true as const, ...(reservationSessionId ? { reservationSessionId } : {}), - pendingPolicyVerification: {} as never, + pendingCreateIdentity: {} as never, }; expect(() => removeSandboxUnlessSessionReservation(entry, "alpha")).toThrow( diff --git a/src/lib/onboard/sandbox-recreate-transaction.test.ts b/src/lib/onboard/sandbox-recreate-transaction.test.ts index 611ba48a243..f0f7ee69402 100644 --- a/src/lib/onboard/sandbox-recreate-transaction.test.ts +++ b/src/lib/onboard/sandbox-recreate-transaction.test.ts @@ -928,18 +928,6 @@ describe("source registry fingerprint", () => { gatewayPort: 8080, lifecycleGeneration, lifecycleLiveIdentityFingerprint, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "alpha", - lifecycleGeneration, - sandboxIdentityFingerprint: lifecycleLiveIdentityFingerprint, - policyHash: "policy-alpha", - policyVersion: 1, - }, }); const sourceEntry = registry.getSandbox("alpha") as SandboxEntry; const journaled = fingerprintSandboxRegistryEntry(sourceEntry); @@ -960,16 +948,10 @@ describe("source registry fingerprint", () => { ).toBe(true); const reserved = registry.getSandbox("alpha") as SandboxEntry; expect(reserved.hostLocalInferenceReceipt).toBe(hostLocalInferenceReceipt); - expect(reserved.policyAuthority).toBe("nemoclaw-managed"); - expect(reserved.policyCreationReceipt).toEqual( - expect.objectContaining({ lifecycleGeneration, policyHash: "policy-alpha" }), - ); expect(fingerprintSandboxRegistryEntry(reserved)).toBe(journaled); registry.restoreSandboxEntry(sourceEntry); - expect(registry.getSandbox("alpha")?.policyCreationReceipt).toEqual( - sourceEntry.policyCreationReceipt, - ); + expect(registry.getSandbox("alpha")).toEqual(sourceEntry); } finally { await fs.rm(home, { recursive: true, force: true }); } @@ -1033,47 +1015,20 @@ describe("source registry fingerprint", () => { } }); - it("survives owned MCP policy preparation while retaining policy authority", () => { + it("survives MCP cleanup-state preparation", () => { const sourceEntry: SandboxEntry = { ...SOURCE_ENTRY, lifecycleGeneration: TARGET_GENERATION, lifecycleLiveIdentityFingerprint: SOURCE_ID, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw-31818", - gatewayPort: 31818, - sandboxName: "alpha", - lifecycleGeneration: TARGET_GENERATION, - sandboxIdentityFingerprint: SOURCE_ID, - policyHash: "policy-before", - policyVersion: 1, - }, - policies: ["mcp-search"], - customPolicies: [{ name: "mcp-search", content: "network_policies: {}" }], mcp: { bridges: {}, managedServerNames: ["search"] }, }; const journaled = fingerprintSandboxRegistryEntry(sourceEntry); const preparedEntry: SandboxEntry = { ...sourceEntry, - policyCreationReceipt: { - ...sourceEntry.policyCreationReceipt!, - policyHash: "policy-after", - policyVersion: 2, - }, - policies: [], - customPolicies: [], mcp: { bridges: {}, managedServerNames: [] }, }; expect(fingerprintSandboxRegistryEntry(preparedEntry)).toBe(journaled); - expect( - fingerprintSandboxRegistryEntry({ - ...preparedEntry, - policyAuthority: "externally-managed", - }), - ).not.toBe(journaled); }); it("changes when the row records another sandbox", async () => { diff --git a/src/lib/onboard/sandbox-recreate-transaction.ts b/src/lib/onboard/sandbox-recreate-transaction.ts index c286f5824ac..33a22cd2777 100644 --- a/src/lib/onboard/sandbox-recreate-transaction.ts +++ b/src/lib/onboard/sandbox-recreate-transaction.ts @@ -198,15 +198,7 @@ const ROUTE_RESERVATION_FIELDS: readonly (keyof SandboxEntry)[] = [ "gatewayName", "gatewayPort", ]; -// Rebuild may update these independently receipt-bound projections before delete. -// The source fingerprint still binds policyAuthority and every sandbox, gateway, -// lifecycle, agent, and workload ownership field. -const RECEIPT_BOUND_PROJECTION_FIELDS: readonly (keyof SandboxEntry)[] = [ - "policyCreationReceipt", - "policies", - "customPolicies", - "mcp", -]; +const RECEIPT_BOUND_PROJECTION_FIELDS: readonly (keyof SandboxEntry)[] = ["mcp"]; export function fingerprintSandboxRegistryEntry(entry: SandboxEntry): string { const durable: Record = { ...entry }; diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index 5b9b5bd74f1..206e98feb91 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -22,14 +22,9 @@ import { encodeManagedStartupProfile } from "./managed-startup/profile"; const requireDist = createRequire(import.meta.url); const onboardSession = requireDist("../state/onboard-session.js"); -const { - assertBaselineExclusionsMatchCreateIntent, - baselineExclusionsForCreate, - buildCreatedSandboxRegistryEntry, - creationFidelity, - registerCreatedSandbox, - selection, -} = requireDist("./sandbox-registration.ts") as typeof import("./sandbox-registration"); +const { buildCreatedSandboxRegistryEntry, registerCreatedSandbox, selection } = requireDist( + "./sandbox-registration.ts", +) as typeof import("./sandbox-registration"); const runtimeFields = { gpuEnabled: true, @@ -82,7 +77,6 @@ function createdRegistryEntryInput( agent: null, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -94,14 +88,6 @@ function createdRegistryEntryInput( } describe("buildCreatedSandboxRegistryEntry", () => { - it("copies policy authority into completed sandbox registration (#9833)", () => { - const entry = buildCreatedSandboxRegistryEntry( - createdRegistryEntryInput({ policyAuthority: "externally-managed" }), - ); - - expect(entry.policyAuthority).toBe("externally-managed"); - }); - it("records explicit OpenClaw identity for a managed workload receipt (#9356)", () => { const workload = managedWorkloadReceipt("openclaw"); const entry = buildCreatedSandboxRegistryEntry( @@ -187,7 +173,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { agent: null, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -200,60 +185,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { loadSession.mockRestore(); }); - it("blocks create intent while a baseline policy transaction needs repair (#7178)", () => { - const registry = requireDist("../state/registry.js"); - const transitionSpy = vi.spyOn(registry, "getBaselineExclusionTransition").mockReturnValue({ - id: "tx-1", - operation: "exclude", - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "approved", - }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(() => baselineExclusionsForCreate("alpha")).toThrow( - /policy exclude.*needs repair before sandbox creation/i, - ); - - transitionSpy.mockRestore(); - }); - - it("rejects a resolved create intent when durable baseline exclusions changed (#7194)", () => { - const registry = requireDist("../state/registry.js"); - const transitionSpy = vi - .spyOn(registry, "getBaselineExclusionTransition") - .mockReturnValue(null); - const exclusionsSpy = vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "b".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]); - try { - expect(() => - assertBaselineExclusionsMatchCreateIntent("alpha", [ - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]), - ).toThrow(/changed while sandbox creation was being prepared/i); - } finally { - exclusionsSpy.mockRestore(); - transitionSpy.mockRestore(); - } - }); - it("records the final created sandbox metadata with configured messaging channels", () => { const plannedMessagingState = { schemaVersion: 1 as const, @@ -284,10 +215,8 @@ describe("buildCreatedSandboxRegistryEntry", () => { agentVersionKnown: true, imageTag: "nemoclaw-demo:123", openclawImagePluginInstalls, - appliedPolicies: ["discord", "slack"], observabilityEnabled: true, dcodeAutoApprovalMode: "thread-opt-in", - policyTier: "restricted", webSearchEnabled: true, fromDockerfile: "/tmp/Dockerfile.custom", hermesAuthMethod: "api_key", @@ -314,11 +243,9 @@ describe("buildCreatedSandboxRegistryEntry", () => { preferredInferenceApi: "openai-completions", imageTag: "nemoclaw-demo:123", openclawImagePluginInstalls, - policies: ["discord", "slack"], toolDisclosure: "progressive", observabilityEnabled: true, dcodeAutoApprovalMode: "thread-opt-in", - policyTier: "restricted", webSearchEnabled: true, fromDockerfile: "/tmp/Dockerfile.custom", hermesAuthMethod: "api_key", @@ -369,7 +296,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { agent: null, agentVersionKnown: false, imageTag: null, - appliedPolicies: [], plannedMessagingState: { schemaVersion: 1 as const, plan: { sandboxName: "other" }, @@ -438,7 +364,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { agent: null, agentVersionKnown: true, imageTag: "nemoclaw-demo:replacement", - appliedPolicies: [], toolDisclosure: "direct", plannedMessagingState: undefined, preservedMcpState, @@ -455,52 +380,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.toolDisclosure).toBe("direct"); }); - it("carries complete baseline exclusion records through consecutive registrations", () => { - const baselineExclusions = [ - { - version: 1 as const, - agent: "openclaw", - key: "nous_research", - digest: "abc", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: null, - }, - ]; - const fidelity = creationFidelity(null, null, null, false, baselineExclusions); - const common = { - sandboxName: "demo", - inferenceSelection: { - model: "llama", - provider: "compatible-endpoint", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - compatibleEndpointReasoning: null, - compatibleEndpointReasoningEffort: null, - nimContainer: null, - }, - runtimeFields, - agent: null, - agentVersionKnown: true, - imageTag: null, - appliedPolicies: [], - plannedMessagingState: undefined, - hermesToolGateways: [], - hermesDashboardState: { enabled: false as const, config: null }, - dashboardPort: 18789, - gatewayName: "nemoclaw", - gatewayPort: 8080, - }; - - const first = buildCreatedSandboxRegistryEntry({ ...common, ...fidelity }); - const secondFidelity = creationFidelity(null, null, null, false, first.baselineExclusions); - const second = buildCreatedSandboxRegistryEntry({ ...common, ...secondFidelity }); - - expect(second.baselineExclusions).toEqual(baselineExclusions); - expect(second.baselineExclusions).not.toBe(first.baselineExclusions); - expect(second.baselineExclusions?.[0]).not.toBe(first.baselineExclusions?.[0]); - }); - it("normalizes invalid preferred inference API values", () => { const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", @@ -518,7 +397,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { agent: null, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -547,7 +425,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { agent: null, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], toolDisclosure: "direct", plannedMessagingState: undefined, hermesToolGateways: [], @@ -730,7 +607,6 @@ describe("registerCreatedSandbox", () => { agent: agentDefs.loadAgent("hermes"), agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -775,7 +651,6 @@ describe("registerCreatedSandbox", () => { agent: agentDefs.loadAgent("hermes"), agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -824,7 +699,6 @@ describe("registerCreatedSandbox", () => { shared: false, }, openclawImagePluginInstalls: [], - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -897,7 +771,6 @@ describe("registerCreatedSandbox", () => { agent: null, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -935,7 +808,6 @@ describe("registerCreatedSandbox", () => { agent: null, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 45d79f9aa70..d59a4e2253c 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isDeepStrictEqual } from "node:util"; - import type { AgentDefinition } from "../agent/defs"; import type { InferenceEndpointSource, @@ -16,12 +14,7 @@ import { import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import * as onboardSession from "../state/onboard-session"; import type { OpenClawImagePluginInstall } from "../state/openclaw-plugin-restore"; -import type { - BaselineExclusionEntry, - SandboxEntry, - SandboxMcpState, - SandboxMessagingState, -} from "../state/registry"; +import type { SandboxEntry, SandboxMcpState, SandboxMessagingState } from "../state/registry"; import * as registry from "../state/registry"; import { cloneSandboxHostLocalInferenceProvenance, @@ -75,12 +68,9 @@ export interface CreatedSandboxRegistryEntryInput { hostLocalInferenceReceipt?: SandboxEntry["hostLocalInferenceReceipt"]; hostLocalInferenceProvenance?: SandboxEntry["hostLocalInferenceProvenance"]; openclawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; - appliedPolicies: string[]; toolDisclosure?: ToolDisclosure; observabilityEnabled?: boolean; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; - policyTier?: SandboxEntry["policyTier"]; - baselineExclusions?: readonly BaselineExclusionEntry[]; webSearchEnabled?: boolean; webSearchProvider?: SandboxEntry["webSearchProvider"]; fromDockerfile?: string | null; @@ -103,8 +93,6 @@ export interface CreatedSandboxRegistryEntryInput { lifecycleLiveIdentityFingerprint?: string; gatewayName: string; gatewayPort: number; - policyAuthority?: SandboxEntry["policyAuthority"]; - policyCreationReceipt?: SandboxEntry["policyCreationReceipt"]; hostMounts?: readonly import("../state/registry/types").SandboxHostMount[]; } @@ -130,7 +118,6 @@ export function creationFidelity( fromDockerfile: string | null, hermesAuthMethod: "oauth" | "api_key" | null, dashboardRemoteBindPrepared?: boolean, - baselineExclusions?: readonly BaselineExclusionEntry[], ): Pick< SandboxEntry, | "webSearchEnabled" @@ -138,7 +125,6 @@ export function creationFidelity( | "fromDockerfile" | "hermesAuthMethod" | "dashboardRemoteBindPrepared" - | "baselineExclusions" > { return { webSearchEnabled: webSearchConfig?.fetchEnabled === true, @@ -146,41 +132,9 @@ export function creationFidelity( fromDockerfile, hermesAuthMethod, dashboardRemoteBindPrepared: dashboardRemoteBindPrepared === true, - baselineExclusions: baselineExclusions?.map((exclusion) => ({ ...exclusion })), }; } -/** Snapshot complete exclusion records before a destructive create removes registry state. */ -export function baselineExclusionsForCreate(sandboxName: string): BaselineExclusionEntry[] { - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (transition) { - const key = transition.exclusion.key; - throw new Error( - `Baseline policy ${transition.operation} for '${key}' needs repair before sandbox creation. Re-run 'policy ${transition.operation} ${key}' first.`, - ); - } - return registry.getBaselineExclusions(sandboxName).map((exclusion) => ({ ...exclusion })); -} - -/** - * Re-read exclusion intent at the destructive create edge and prove it still - * matches the already-resolved policy plan. The sandbox mutation lock is the - * caller's serialization boundary; this comparison catches stale plans and - * any direct registry writer that bypassed that lock. - */ -export function assertBaselineExclusionsMatchCreateIntent( - sandboxName: string, - planned: readonly BaselineExclusionEntry[], -): BaselineExclusionEntry[] { - const current = baselineExclusionsForCreate(sandboxName); - if (!isDeepStrictEqual(current, [...planned])) { - throw new Error( - `Baseline policy exclusions for '${sandboxName}' changed while sandbox creation was being prepared. Retry so the replacement policy uses current registry intent.`, - ); - } - return current; -} - export function selection( sandboxName: string, provider: string, @@ -292,18 +246,11 @@ export function buildCreatedSandboxRegistryEntry( })), } : {}), - policies: input.appliedPolicies, - ...(input.policyAuthority !== undefined ? { policyAuthority: input.policyAuthority } : {}), - ...(input.policyCreationReceipt !== undefined - ? { policyCreationReceipt: input.policyCreationReceipt } - : {}), - baselineExclusions: input.baselineExclusions?.map((exclusion) => ({ ...exclusion })), toolDisclosure: input.toolDisclosure ?? DEFAULT_TOOL_DISCLOSURE, observabilityEnabled: input.observabilityEnabled === true, ...(input.dcodeAutoApprovalMode !== undefined ? { dcodeAutoApprovalMode: input.dcodeAutoApprovalMode } : {}), - ...(input.policyTier !== undefined ? { policyTier: input.policyTier } : {}), webSearchEnabled: input.webSearchEnabled === true, webSearchProvider: input.webSearchEnabled === true ? (input.webSearchProvider ?? "brave") : null, diff --git a/src/lib/onboard/sandbox-registry-metadata.test.ts b/src/lib/onboard/sandbox-registry-metadata.test.ts index c5f05767b44..cea713f379d 100644 --- a/src/lib/onboard/sandbox-registry-metadata.test.ts +++ b/src/lib/onboard/sandbox-registry-metadata.test.ts @@ -242,7 +242,7 @@ describe("sandbox registry metadata", () => { }); it("rechecks authority between reused metadata and default registry writes (#9833)", async () => { - tmpDir = mkdtempSync(join(tmpdir(), "nemoclaw-reuse-policy-authority-")); + tmpDir = mkdtempSync(join(tmpdir(), "nemoclaw-reuse-policy-requirements-")); process.env.HOME = tmpDir; vi.resetModules(); @@ -261,11 +261,11 @@ describe("sandbox registry metadata", () => { ); const helpers = await makeHelpers("docker"); - const revalidatePolicyAuthority = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("policy authority changed"); + throw new Error("policy requirements changed"); }); expect(() => @@ -277,14 +277,14 @@ describe("sandbox registry metadata", () => { 18789, true, null, - revalidatePolicyAuthority, + verifyLivePolicyRequirements, ), - ).toThrow("policy authority changed"); + ).toThrow("policy requirements changed"); const persisted = JSON.parse(readFileSync(registryFile, "utf8")); expect(persisted.sandboxes.alpha.model).toBe("new-model"); expect(persisted.defaultSandbox).toBe("beta"); - expect(revalidatePolicyAuthority).toHaveBeenCalledTimes(2); + expect(verifyLivePolicyRequirements).toHaveBeenCalledTimes(2); }); it("persists a reused terminal sandbox without a dashboard port for host allocation (#7020)", async () => { diff --git a/src/lib/onboard/sandbox-registry-metadata.ts b/src/lib/onboard/sandbox-registry-metadata.ts index 25d87a2d5c1..2cc1cb05f9c 100644 --- a/src/lib/onboard/sandbox-registry-metadata.ts +++ b/src/lib/onboard/sandbox-registry-metadata.ts @@ -37,7 +37,7 @@ export interface SandboxRegistryMetadataHelpers { dashboardPort: number, selectionVerified?: boolean, sandboxGpuConfig?: SandboxGpuConfig | null, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): void; } @@ -110,12 +110,12 @@ export function createSandboxRegistryMetadataHelpers( dashboardPort: number, selectionVerified = true, sandboxGpuConfig: SandboxGpuConfig | null = null, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): void { const existingEntry = registry.getSandbox(sandboxName); const agentFields = getSandboxAgentRegistryFields(agent, false); const selectionUpdates = selectionVerified ? { model, provider } : {}; - revalidatePolicyAuthority?.(`record reused sandbox metadata for '${sandboxName}'`); + verifyLivePolicyRequirements?.(`record reused sandbox metadata for '${sandboxName}'`); registry.updateSandbox(sandboxName, { ...selectionUpdates, dashboardPort, @@ -123,7 +123,7 @@ export function createSandboxRegistryMetadataHelpers( agentVersion: existingEntry?.agentVersion ?? null, ...(sandboxGpuConfig ? getSandboxRuntimeRegistryFields(sandboxGpuConfig) : {}), }); - revalidatePolicyAuthority?.(`make reused sandbox '${sandboxName}' the default`); + verifyLivePolicyRequirements?.(`make reused sandbox '${sandboxName}' the default`); registry.setDefault(sandboxName); } diff --git a/src/lib/onboard/sandbox-reuse.test.ts b/src/lib/onboard/sandbox-reuse.test.ts index ec1179c34c0..4d8c5136e4c 100644 --- a/src/lib/onboard/sandbox-reuse.test.ts +++ b/src/lib/onboard/sandbox-reuse.test.ts @@ -141,19 +141,19 @@ describe("applyReusedSandboxDashboardState", () => { }); it("passes the receipt check into dashboard forwarding after release (#9833)", async () => { - const revalidatePolicyRequirements = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("external policy authority must supply the dashboard entry"); + throw new Error("live policy requirements changed before the dashboard entry"); }); const ensureDashboardForward = vi.fn( ( _sandboxName: string, _chatUiUrl: string, - options?: { revalidatePolicyAuthority?: (operation: string) => void }, + options?: { verifyLivePolicyRequirements?: (operation: string) => void }, ) => { - options?.revalidatePolicyAuthority?.("start the dashboard forward"); + options?.verifyLivePolicyRequirements?.("start the dashboard forward"); return 18790; }, ); @@ -189,9 +189,9 @@ describe("applyReusedSandboxDashboardState", () => { }, updateSandbox, updateReusedSandboxMetadata, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(ensureDashboardForward).toHaveBeenCalledOnce(); expect(env.CHAT_UI_URL).toBeUndefined(); @@ -201,13 +201,13 @@ describe("applyReusedSandboxDashboardState", () => { }); it("rechecks after Hermes forwarding before reuse metadata (#9833)", () => { - const revalidatePolicyRequirements = vi + const verifyLivePolicyRequirements = vi .fn<(operation: string) => void>() .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => undefined) .mockImplementationOnce(() => { - throw new Error("external policy authority must supply the dashboard entry"); + throw new Error("live policy requirements changed before the dashboard entry"); }); const ensureForState = vi.fn(); const updateReusedSandboxMetadata = vi.fn(); @@ -239,9 +239,9 @@ describe("applyReusedSandboxDashboardState", () => { }, updateSandbox, updateReusedSandboxMetadata, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }), - ).toThrow(/external policy authority must supply/u); + ).toThrow(/live policy requirements changed before/u); expect(ensureForState).toHaveBeenCalledOnce(); expect(updateReusedSandboxMetadata).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts index 348a675854b..34b8585c7ba 100644 --- a/src/lib/onboard/sandbox-reuse.ts +++ b/src/lib/onboard/sandbox-reuse.ts @@ -84,7 +84,7 @@ export interface ReusedSandboxDashboardForwarding { state: HermesDashboardOnboardState, sandboxName: string, rollback?: boolean, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): void; } @@ -104,11 +104,11 @@ export interface ReusedSandboxDashboardStateInput { ensureDashboardForward( sandboxName: string, chatUiUrl: string, - options?: { revalidatePolicyAuthority?: (operation: string) => void }, + options?: { verifyLivePolicyRequirements?: (operation: string) => void }, ): number; hermesDashboardForwarding: ReusedSandboxDashboardForwarding; updateSandbox?(sandboxName: string, updates: Partial): unknown; - revalidatePolicyRequirements?(operation: string): void; + verifyLivePolicyRequirements?(operation: string): void; updateReusedSandboxMetadata( sandboxName: string, agent: AgentDefinition | null | undefined, @@ -117,7 +117,7 @@ export interface ReusedSandboxDashboardStateInput { dashboardPort: number, selectionVerified: boolean, sandboxGpuConfig: SandboxGpuConfig, - revalidatePolicyAuthority?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): void; } @@ -141,36 +141,36 @@ export function applyReusedSandboxDashboardState( `Sandbox '${input.sandboxName}' was created without remote dashboard exposure. Re-run onboarding with NEMOCLAW_DASHBOARD_BIND=0.0.0.0 and --recreate-sandbox before opening a remote bind.`, ); } - input.revalidatePolicyRequirements?.( + input.verifyLivePolicyRequirements?.( `restore dashboard state for sandbox '${input.sandboxName}'`, ); const dashboardPort = manageDashboard - ? input.revalidatePolicyRequirements + ? input.verifyLivePolicyRequirements ? input.ensureDashboardForward(input.sandboxName, input.chatUiUrl, { - revalidatePolicyAuthority: input.revalidatePolicyRequirements, + verifyLivePolicyRequirements: input.verifyLivePolicyRequirements, }) : input.ensureDashboardForward(input.sandboxName, input.chatUiUrl) : 0; const chatUiUrl = manageDashboard ? `http://127.0.0.1:${dashboardPort}` : input.chatUiUrl; if (manageDashboard) { - input.revalidatePolicyRequirements?.(`record dashboard URL for sandbox '${input.sandboxName}'`); + input.verifyLivePolicyRequirements?.(`record dashboard URL for sandbox '${input.sandboxName}'`); input.env.CHAT_UI_URL = chatUiUrl; } const hermesDashboardState = manageDashboard ? input.hermesDashboardForwarding.resolveStateForPort(dashboardPort) : { enabled: false, config: null }; if (manageDashboard) { - input.revalidatePolicyRequirements?.( + input.verifyLivePolicyRequirements?.( `restore Hermes dashboard state for sandbox '${input.sandboxName}'`, ); input.hermesDashboardForwarding.ensureForState( hermesDashboardState, input.sandboxName, false, - input.revalidatePolicyRequirements, + input.verifyLivePolicyRequirements, ); } - input.revalidatePolicyRequirements?.(`update reused sandbox metadata for '${input.sandboxName}'`); + input.verifyLivePolicyRequirements?.(`update reused sandbox metadata for '${input.sandboxName}'`); input.updateReusedSandboxMetadata( input.sandboxName, input.agent, @@ -179,9 +179,9 @@ export function applyReusedSandboxDashboardState( dashboardPort, input.selectionVerified, input.sandboxGpuConfig, - input.revalidatePolicyRequirements, + input.verifyLivePolicyRequirements, ); - input.revalidatePolicyRequirements?.( + input.verifyLivePolicyRequirements?.( `record reused dashboard state for sandbox '${input.sandboxName}'`, ); (input.updateSandbox ?? registry.updateSandbox)(input.sandboxName, { diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index aa278545e68..4d9f095d8c7 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -92,10 +92,10 @@ function createDeps( } describe("prepareOnboardSession", () => { - it("stops resume before side effects when saved policy authority is invalid (#9833)", async () => { + it("stops resume before side effects when saved policy requirements is invalid (#9833)", async () => { const loadSession = vi.fn((): Session | null => { throw new Error( - "Refusing to load the onboarding session: the saved policy authority is invalid.", + "Refusing to load the onboarding session: the saved policy requirements is invalid.", ); }); const { deps } = createDeps(null, { loadSession }); @@ -112,7 +112,7 @@ describe("prepareOnboardSession", () => { }, deps, ), - ).rejects.toThrow(/saved policy authority is invalid/u); + ).rejects.toThrow(/saved policy requirements is invalid/u); expect(loadSession).toHaveBeenCalledOnce(); expect(deps.requireHostMountRuntimeSupport).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts index 6f9b514be08..2477840783d 100644 --- a/src/lib/onboard/session-updates.ts +++ b/src/lib/onboard/session-updates.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxPolicyAuthority } from "../adapters/openshell/policy-authority"; import type { ServingProfileProvenance } from "../inference/serving/types"; import type { WebSearchConfig } from "../inference/web-search"; import type { SandboxMessagingPlan } from "../messaging/manifest"; @@ -24,8 +23,6 @@ export interface OnboardSessionUpdateInput { webSearchConfig?: WebSearchConfig | null; toolDisclosure?: ToolDisclosure | string; observabilityEnabled?: boolean; - policyPresets?: string[] | null; - policyAuthority?: SandboxPolicyAuthority | null; messagingPlan?: SandboxMessagingPlan | null; hermesToolGateways?: string[] | null; /** Ephemeral vLLM checkpoint proof consumed by Station provider binding; never persisted. */ @@ -89,8 +86,6 @@ export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): Sessi if (typeof updates.observabilityEnabled === "boolean") { normalized.observabilityEnabled = updates.observabilityEnabled; } - if (updates.policyPresets !== undefined) normalized.policyPresets = updates.policyPresets; - if (updates.policyAuthority !== undefined) normalized.policyAuthority = updates.policyAuthority; if (updates.messagingPlan !== undefined) normalized.messagingPlan = updates.messagingPlan; if (updates.hermesToolGateways !== undefined) normalized.hermesToolGateways = updates.hermesToolGateways; diff --git a/src/lib/onboard/setup-inference-gateway-scope.test.ts b/src/lib/onboard/setup-inference-gateway-scope.test.ts index 7ea7656edd5..97975cb9949 100644 --- a/src/lib/onboard/setup-inference-gateway-scope.test.ts +++ b/src/lib/onboard/setup-inference-gateway-scope.test.ts @@ -287,7 +287,6 @@ describe("gateway-scoped inference route readers", () => { provider: "openai-api", model: "gpt-test", gpuEnabled: false, - policies: [], }, ], })); diff --git a/src/lib/onboard/setup-inference-policy-authority.test.ts b/src/lib/onboard/setup-inference-policy-authority.test.ts deleted file mode 100644 index c7ed74f017d..00000000000 --- a/src/lib/onboard/setup-inference-policy-authority.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { createSetupInference, type SetupInferenceDeps } from "./setup-inference"; - -const refuseAuthorityChange = (): never => { - throw new Error("authority changed"); -}; - -function createSetupDeps(): SetupInferenceDeps { - return { - checkGatewayRouteCompatibility: vi.fn(() => ({ ok: true as const })), - withSandboxMutationLock: async (_name: string, operation: () => Promise | T) => - await operation(), - withGatewayRouteMutationLock: async (_name: string, operation: () => Promise | T) => - await operation(), - step: vi.fn(), - getGatewayName: () => "nemoclaw", - runOpenshell: vi.fn((args: string[]) => - args.includes("export") - ? { - status: 1, - stdout: "", - stderr: "Error: status: 'NotFound', message: \"provider profile not found\"", - } - : { status: 0, stdout: "", stderr: "" }, - ), - updateSandbox: vi.fn(() => true), - upsertProvider: vi.fn(() => ({ ok: true as const })), - verifyInferenceRoute: vi.fn(), - verifyOnboardInferenceSmoke: vi.fn(), - resolveEndpointHost: vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]), - isNonInteractive: () => true, - isRoutedInferenceProvider: () => false, - hermesProviderAuth: { HERMES_PROVIDER_NAME: "hermes-provider" }, - REMOTE_PROVIDER_CONFIG: { - custom: { - label: "Compatible endpoint", - providerName: "compatible-endpoint", - providerType: "openai", - credentialEnv: "COMPATIBLE_API_KEY", - endpointUrl: "https://endpoint.example/v1", - helpUrl: null, - modelMode: "input", - defaultModel: "model-a", - }, - }, - hydrateCredentialEnv: vi.fn(() => "secret"), - promptValidationRecovery: vi.fn(), - classifyApplyFailure: vi.fn(), - localInferenceTimeoutSecs: 60, - bedrockRuntimeOnboard: { - setupBedrockRuntimeInference: vi.fn(async () => ({ handled: false as const })), - }, - openrouterRuntimeOnboard: { - setupOpenRouterRuntimeInference: vi.fn(async () => ({ handled: false as const })), - }, - redact: (value: string) => value, - compactText: (value: string) => value, - log: vi.fn(), - error: vi.fn(), - exitProcess: vi.fn((code: number): never => { - throw new Error(`exit ${String(code)}`); - }), - } as unknown as SetupInferenceDeps; -} - -describe("onboard inference policy authority mutation edges", () => { - it("rejects sandbox-bound setup without an authority revalidation callback (#9833)", async () => { - const deps = createSetupDeps(); - - await expect( - createSetupInference(deps)( - "sandbox-a", - "model-a", - "compatible-endpoint", - "https://endpoint.example/v1", - "COMPATIBLE_API_KEY", - ), - ).rejects.toThrow("Sandbox inference setup requires policy authority revalidation."); - - expect(deps.checkGatewayRouteCompatibility).not.toHaveBeenCalled(); - expect(deps.upsertProvider).not.toHaveBeenCalled(); - expect(deps.updateSandbox).not.toHaveBeenCalled(); - expect(deps.verifyOnboardInferenceSmoke).not.toHaveBeenCalled(); - expect(deps.log).not.toHaveBeenCalled(); - }); - - it("withholds success when authority changes after inference smoke (#9833)", async () => { - const deps = createSetupDeps(); - - await expect( - createSetupInference(deps)( - "sandbox-a", - "model-a", - "compatible-endpoint", - "https://endpoint.example/v1", - "COMPATIBLE_API_KEY", - null, - [], - { - endpointPinnedAddresses: ["93.184.216.34"], - revalidatePolicyRequirements: (operation) => - operation === "report successful inference provider setup" - ? refuseAuthorityChange() - : undefined, - }, - ), - ).rejects.toThrow("authority changed"); - - expect(deps.verifyOnboardInferenceSmoke).toHaveBeenCalledOnce(); - expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining("Inference route set")); - }); - - it("withholds success when authority changes before superseded Ollama cleanup (#9833)", async () => { - const deps = createSetupDeps(); - deps.getSandbox = vi.fn(() => ({ - name: "sandbox-a", - provider: "ollama-local", - model: "old-model", - })) as SetupInferenceDeps["getSandbox"]; - deps.listSandboxes = vi.fn(() => ({ - defaultSandbox: "sandbox-a", - sandboxes: [{ name: "sandbox-a", provider: "ollama-local", model: "old-model" }], - })) as unknown as SetupInferenceDeps["listSandboxes"]; - deps.withOllamaModelOwnershipLock = (operation) => operation(); - deps.unloadOllamaModels = vi.fn(); - - await expect( - createSetupInference(deps)( - "sandbox-a", - "new-model", - "compatible-endpoint", - "https://endpoint.example/v1", - "COMPATIBLE_API_KEY", - null, - [], - { - endpointPinnedAddresses: ["93.184.216.34"], - revalidatePolicyRequirements: (operation) => - operation === "release the superseded Ollama model" - ? refuseAuthorityChange() - : undefined, - }, - ), - ).rejects.toThrow("authority changed"); - - expect(deps.unloadOllamaModels).not.toHaveBeenCalled(); - expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining("Inference route set")); - }); -}); diff --git a/src/lib/onboard/setup-inference-route-containment.test.ts b/src/lib/onboard/setup-inference-route-containment.test.ts index 6a489b231a7..66b76054638 100644 --- a/src/lib/onboard/setup-inference-route-containment.test.ts +++ b/src/lib/onboard/setup-inference-route-containment.test.ts @@ -6,7 +6,7 @@ import { checkGatewayRouteCompatibility } from "../inference/gateway-route-compa import type { SandboxEntry } from "../state/registry"; import { createSetupInference, type SetupInferenceDeps } from "./setup-inference"; -const revalidatePolicyRequirements = () => undefined; +const verifyLivePolicyRequirements = () => undefined; describe("onboard shared gateway route containment", () => { afterEach(() => vi.unstubAllEnvs()); @@ -116,7 +116,7 @@ describe("onboard shared gateway route containment", () => { "COMPATIBLE_API_KEY", null, [], - { revalidatePolicyRequirements }, + { verifyLivePolicyRequirements }, ).then( () => null, (error: Error) => error.message, @@ -224,7 +224,7 @@ describe("onboard shared gateway route containment", () => { "ROUTER_KEY", null, [], - { revalidatePolicyRequirements }, + { verifyLivePolicyRequirements }, ), ).resolves.toEqual({ ok: true }); @@ -295,7 +295,7 @@ describe("onboard shared gateway route containment", () => { "KEY_B", null, [], - { preferredInferenceApi: "openai-completions", revalidatePolicyRequirements }, + { preferredInferenceApi: "openai-completions", verifyLivePolicyRequirements }, ), ).rejects.toThrow("exit 1"); @@ -342,7 +342,7 @@ describe("onboard shared gateway route containment", () => { [], { reservationSessionId: "session-current", - revalidatePolicyRequirements, + verifyLivePolicyRequirements, isRecordedProviderRecoveryAuthorized: () => { events.push("recovery-authority"); return false; @@ -448,7 +448,7 @@ describe("onboard shared gateway route containment", () => { "ROUTER_KEY", null, [], - { endpointSource: "inference-set", revalidatePolicyRequirements }, + { endpointSource: "inference-set", verifyLivePolicyRequirements }, ); await vi.waitFor(() => expect(verifyOnboardInferenceSmoke).toHaveBeenCalledOnce()); expect(reservations).toEqual([ @@ -469,7 +469,7 @@ describe("onboard shared gateway route containment", () => { "ROUTER_KEY", null, [], - { revalidatePolicyRequirements }, + { verifyLivePolicyRequirements }, ); const resultsPending = Promise.allSettled([firstSetup, secondSetup]); expect(runOpenshell).toHaveBeenCalledTimes(1); @@ -560,7 +560,7 @@ describe("onboard shared gateway route containment", () => { { skipHostInferenceSmoke: true, reservationSessionId: "session-gamma", - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }, ), ).resolves.toEqual({ ok: true }); diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 3f44ba39afd..997cfef04ed 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -208,7 +208,7 @@ export type SetupInferenceDeps = ProviderBranchDeps & { baseUrl: string | null, env: NodeJS.ProcessEnv | undefined, gatewayName: string, - options?: { revalidatePolicyRequirements?(operation: string): void }, + options?: { verifyLivePolicyRequirements?(operation: string): void }, ) => ReturnType; verifyInferenceRoute: (gatewayName: string, provider: string, model: string) => void; providerExistsInGateway: (name: string, gatewayName: string) => boolean; @@ -285,12 +285,12 @@ export function createGatewayScopedOpenshellRunner void, + verifyLivePolicyRequirements?: (operation: string) => void, ): CommonDeps["upsertProvider"] { return (name, type, credentialEnv, baseUrl, env) => - revalidatePolicyRequirements + verifyLivePolicyRequirements ? upsertProvider(name, type, credentialEnv, baseUrl, env, gatewayName, { - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }) : upsertProvider(name, type, credentialEnv, baseUrl, env, gatewayName); } @@ -362,7 +362,7 @@ export function selectGatewayForFollowupOrExit( function resolveLocalInferenceRouteApplier( deps: SetupInferenceDeps, runOpenshell: SetupInferenceDeps["runOpenshell"], - revalidatePolicyRequirements: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) { return ( deps.applyLocalInferenceRoute ?? @@ -375,7 +375,7 @@ function resolveLocalInferenceRouteApplier( recovery, credentialEnv, helpUrl, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), classifyApplyFailure: deps.classifyApplyFailure, compactText: deps.compactText, @@ -549,7 +549,7 @@ function releaseSupersededOllamaModel( nextModel: string, result: SetupInferenceResult, deps: SetupInferenceDeps, - revalidatePolicyRequirements: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): void { // A reselection retry left the recorded route untouched, so the sandbox // still owns its model. @@ -562,7 +562,7 @@ function releaseSupersededOllamaModel( const superseded = supersededOllamaModel(previous, nextModel, peers); if (!superseded) return; try { - revalidatePolicyRequirements("release the superseded Ollama model"); + verifyLivePolicyRequirements?.("release the superseded Ollama model"); } catch (error) { authorityRefusal = error; return; @@ -591,13 +591,9 @@ export function createSetupInference( hermesToolGateways: string[] = [], options: ProviderInferenceSetupOptions = {}, ): Promise { - if (sandboxName && !options.revalidatePolicyRequirements) { - throw new Error("Sandbox inference setup requires policy authority revalidation."); - } - const revalidatePolicyRequirements = (operation: string): void => { - if (!sandboxName) return; - options.revalidatePolicyRequirements?.(operation); - }; + const verifyLivePolicyRequirements = sandboxName + ? options.verifyLivePolicyRequirements + : undefined; const gatewayName = options.gatewayName ?? deps.getGatewayName(); const endpointSource = options.endpointSource === undefined ? "onboard" : options.endpointSource; @@ -616,7 +612,7 @@ export function createSetupInference( const mutateGatewayRoute = (): Promise => // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: provider onboarding centralizes route and two-phase transaction ordering. withInferenceMutationLocks(async () => { - revalidatePolicyRequirements("change the inference provider route"); + verifyLivePolicyRequirements?.("change the inference provider route"); if ( options.isRecordedProviderRecoveryAuthorized && !options.isRecordedProviderRecoveryAuthorized() @@ -681,14 +677,14 @@ export function createSetupInference( } endpointPinnedAddresses = preflight.addresses; endpointTrustedPrivateCapability = preflight.trustedPrivateCapability; - revalidatePolicyRequirements("change the inference provider route after DNS validation"); + verifyLivePolicyRequirements?.("change the inference provider route after DNS validation"); } const runExactGatewayOpenshell = createGatewayScopedOpenshellRunner( deps.runOpenshell, gatewayName, ); const runGatewayOpenshell: typeof runExactGatewayOpenshell = (...args) => { - revalidatePolicyRequirements("change the OpenShell inference provider route"); + verifyLivePolicyRequirements?.("change the OpenShell inference provider route"); return runExactGatewayOpenshell(...args); }; let hostLocalRoute: HostLocalInferenceStartupRoute | null = null; @@ -706,7 +702,7 @@ export function createSetupInference( let hostLocalInferenceRuntimeProviderId: string | undefined; const reserveRoute = (name: string, selectedProvider: string, selectedModel: string) => { if (routeReserved) return true; - revalidatePolicyRequirements("reserve the sandbox inference route"); + verifyLivePolicyRequirements?.("reserve the sandbox inference route"); const reserved = deps.updateSandbox(name, { provider: selectedProvider, model: selectedModel, @@ -732,7 +728,7 @@ export function createSetupInference( const defaultUpsertProvider = bindGatewayUpsertProvider( deps.upsertProvider, gatewayName, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); const providerExitProcess: CommonDeps["exitProcess"] = hostLocalSelection ? (code: number): never => { @@ -746,7 +742,7 @@ export function createSetupInference( : deps.error; const profiledUpsertProvider = bindOpenAiProviderProfile( (...args) => { - revalidatePolicyRequirements("register the inference provider"); + verifyLivePolicyRequirements?.("register the inference provider"); const selectedUpsertProvider = hostLocalGatewayMutation?.upsertProvider ?? defaultUpsertProvider; return selectedUpsertProvider(...args); @@ -784,7 +780,7 @@ export function createSetupInference( if (options.hostLocalInference) { try { - revalidatePolicyRequirements("prepare the host-local inference runtime"); + verifyLivePolicyRequirements?.("prepare the host-local inference runtime"); hostLocalRoute = resolveHostLocalInferenceRoute( sandboxName, model, @@ -806,7 +802,7 @@ export function createSetupInference( ); hostLocalInferenceRuntimeProviderId = options.hostLocalInference.runtimeProviderId; } - revalidatePolicyRequirements("prepare the host-local inference provider route"); + verifyLivePolicyRequirements?.("prepare the host-local inference provider route"); hostLocalGatewayMutation = await options.hostLocalInference.prepareGatewayMutation({ gatewayName, sandboxName: sandboxName!, @@ -902,7 +898,7 @@ export function createSetupInference( recovery, selectedCredentialEnv, helpUrl, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), classifyApplyFailure: deps.classifyApplyFailure, LOCAL_INFERENCE_TIMEOUT_SECS: deps.localInferenceTimeoutSecs, @@ -937,7 +933,7 @@ export function createSetupInference( } : deps, runGatewayOpenshell, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), run: deps.run, VLLM_LOCAL_CREDENTIAL_ENV: deps.vllmLocalCredentialEnv, @@ -988,7 +984,7 @@ export function createSetupInference( } : deps, runGatewayOpenshell, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), getOllamaWarmupCommand: deps.getOllamaWarmupCommand, run: deps.run, @@ -1083,7 +1079,7 @@ export function createSetupInference( } }; validatePreparedReceipt(); - revalidatePolicyRequirements("commit the host-local inference provider route"); + verifyLivePolicyRequirements?.("commit the host-local inference provider route"); await hostLocalGatewayMutation.commit(); // The awaited gateway commit is the only async gap between provider // proof and publication. Close it before registry or receipt entry. @@ -1100,7 +1096,7 @@ export function createSetupInference( throw new Error("Host-local inference lost sandbox route reservation authority."); } } - revalidatePolicyRequirements("publish the host-local inference provider receipt"); + verifyLivePolicyRequirements?.("publish the host-local inference provider receipt"); const committed = normalizeHostLocalInferenceReceipt(hostLocalRoute.prepared.commit()); if ( serializeHostLocalInferenceReceipt(committed) !== @@ -1111,7 +1107,7 @@ export function createSetupInference( ); } } - revalidatePolicyRequirements("report successful inference provider setup"); + verifyLivePolicyRequirements?.("report successful inference provider setup"); shouldLogSuccessfulRoute = true; return { ok: true as const }; } catch (error) { @@ -1169,7 +1165,7 @@ export function createSetupInference( model, result, deps, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if (shouldLogSuccessfulRoute && "ok" in result) { deps.log(` ✓ Inference route set: ${provider} / ${model}`); diff --git a/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts b/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts index c63ca5f5eb8..7de2ea27530 100644 --- a/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts +++ b/src/lib/onboard/setup-nim-flow-vllm-resume.test.ts @@ -69,7 +69,7 @@ describe("createSetupNim vLLM resume", () => { expect(prompt).not.toHaveBeenCalled(); }); - it("refuses checkpoint-first vLLM installation when policy authority changes (#9833)", async () => { + it("refuses checkpoint-first vLLM installation when policy requirements changes (#9833)", async () => { const profile = { name: "DGX Spark" } as VllmProfile; const checkpointVllmInstallModel = vi.fn(); const installEffect = vi.fn(); @@ -99,8 +99,8 @@ describe("createSetupNim vLLM resume", () => { handleVllmSelection, }), ); - const revalidatePolicyRequirements = vi.fn(() => { - throw new Error("external policy authority must supply local inference"); + const verifyLivePolicyRequirements = vi.fn(() => { + throw new Error("live policy requirements changed before local inference"); }); await expect( @@ -114,11 +114,11 @@ describe("createSetupNim vLLM resume", () => { undefined, undefined, undefined, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); - expect(revalidatePolicyRequirements).toHaveBeenCalledWith( + expect(verifyLivePolicyRequirements).toHaveBeenCalledWith( expect.objectContaining({ provider: "vllm-local", model: "nvidia/resumed-model", diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts index e7213841e47..4d914dc99bd 100644 --- a/src/lib/onboard/setup-nim-flow.test.ts +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -1212,7 +1212,7 @@ describe("createSetupNim", () => { expect(installManagedLlamaCpp).toHaveBeenCalledWith(selection, { sandboxName: "spark-agent", gatewayPort: 8091, - revalidatePolicyRequirements: expect.any(Function), + verifyLivePolicyRequirements: expect.any(Function), runtimeProvider, }); expect(getRuntimeProvider).toHaveBeenCalledOnce(); diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index 3d6537af421..ea601f06470 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -72,11 +72,11 @@ export { resumeManagedLlamaCppRuntime }; /** Bind managed llama.cpp resume to the selected runtime provider. */ export function bindManagedLlamaCppResume(gatewayPort: number) { - return (sandboxName: string, revalidatePolicyRequirements?: (operation: string) => void) => + return (sandboxName: string, verifyLivePolicyRequirements?: (operation: string) => void) => resumeManagedLlamaCppRuntime(sandboxName, { gatewayPort, runtimeProvider: resolveCurrentRuntimeProviderBundle(), - revalidatePolicyRequirements, + verifyLivePolicyRequirements, }); } @@ -112,7 +112,7 @@ export type SetupNim = ( assertRouteCompatible?: (route: ProviderInferenceProbeRoute) => GatewayRouteDiscoveryConstraints, canProbeRoute?: (provider: string) => boolean, recoverySessionId?: string | null, - revalidatePolicyRequirements?: (route: ProviderInferenceProbeRoute, operation: string) => void, + verifyLivePolicyRequirements?: (route: ProviderInferenceProbeRoute, operation: string) => void, ) => Promise; export interface SetupNimFlowDeps { @@ -742,7 +742,7 @@ function policyCheckedVllmInstallRecovery( ...recovery, checkpointInstallIntent: (modelId: string) => { seedVllmInstallRoute(modelId); - state.revalidatePolicyRequirements?.("record managed vLLM install intent"); + state.verifyLivePolicyRequirements?.("record managed vLLM install intent"); checkpointInstallIntent(modelId); }, }; @@ -772,7 +772,7 @@ export function createSetupNim( ) => GatewayRouteDiscoveryConstraints, canProbeRoute?: (provider: string) => boolean, recoverySessionId?: string | null, - revalidatePolicyRequirements?: (route: ProviderInferenceProbeRoute, operation: string) => void, + verifyLivePolicyRequirements?: (route: ProviderInferenceProbeRoute, operation: string) => void, ): Promise { deps.step(3, 8, "Configuring inference provider"); @@ -840,8 +840,8 @@ export function createSetupNim( assertRouteCompatible?.(route()); return constraints; }; - state.revalidatePolicyRequirements = (operation) => - revalidatePolicyRequirements?.(route(), operation); + state.verifyLivePolicyRequirements = (operation) => + verifyLivePolicyRequirements?.(route(), operation); return state; }; @@ -1039,7 +1039,7 @@ export function createSetupNim( if (isEndpointProviderSelection(deps, selected.key)) { const state = createSelectionState(); prepareEndpointProviderPolicyRoute(deps, selected, state); - state.revalidatePolicyRequirements?.( + state.verifyLivePolicyRequirements?.( `configure inference provider ${JSON.stringify(state.provider)}`, ); const result = await handleEndpointProviderSelection({ @@ -1101,14 +1101,14 @@ export function createSetupNim( state.credentialEnv = LLAMA_CPP_CREDENTIAL_ENV; state.preferredInferenceApi = "openai-completions"; state.assertRouteCompatible?.(); - state.revalidatePolicyRequirements?.("install managed llama.cpp runtime"); + state.verifyLivePolicyRequirements?.("install managed llama.cpp runtime"); const installed = await (deps.installManagedLlamaCpp ?? installManagedLlamaCpp)( resolved.selection, { sandboxName, gatewayPort: deps.getGatewayPort(), runtimeProvider: deps.getRuntimeProvider(), - revalidatePolicyRequirements: state.revalidatePolicyRequirements, + verifyLivePolicyRequirements: state.verifyLivePolicyRequirements, }, ); if (!installed.ok) { @@ -1250,7 +1250,7 @@ export function createSetupNim( ...vllmRecovery, beforeInstall: (modelId) => { seedVllmInstallRoute(modelId); - vllmState.revalidatePolicyRequirements?.("install managed vLLM runtime"); + vllmState.verifyLivePolicyRequirements?.("install managed vLLM runtime"); }, }); if (!result.ok) { diff --git a/src/lib/onboard/setup-nim-ollama.test.ts b/src/lib/onboard/setup-nim-ollama.test.ts index f8355c2a91f..be11797fe1a 100644 --- a/src/lib/onboard/setup-nim-ollama.test.ts +++ b/src/lib/onboard/setup-nim-ollama.test.ts @@ -213,10 +213,10 @@ describe("createSetupNimOllamaHandlers", () => { expect(install).not.toHaveBeenCalled(); }); - it("stops before local Ollama install effects when policy authority changes (#9833)", async () => { + it("stops before local Ollama install effects when policy requirements changes (#9833)", async () => { const selection = makeState(); - selection.revalidatePolicyRequirements = () => { - throw new Error("external policy authority must supply local inference"); + selection.verifyLivePolicyRequirements = () => { + throw new Error("live policy requirements changed before local inference"); }; const install = vi.fn(() => ({ ok: true })); const start = vi.fn(() => ({ kind: "ready" as const })); @@ -233,7 +233,7 @@ describe("createSetupNimOllamaHandlers", () => { hasUpgradableOllama: false, binaryNeedsUpgrade: false, }), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(install).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); @@ -290,10 +290,10 @@ describe("createSetupNimOllamaHandlers", () => { expect(restart).not.toHaveBeenCalled(); }); - it("stops before Windows Ollama install effects when policy authority changes (#9833)", async () => { + it("stops before Windows Ollama install effects when policy requirements changes (#9833)", async () => { const selection = makeState(); - selection.revalidatePolicyRequirements = () => { - throw new Error("external policy authority must supply local inference"); + selection.verifyLivePolicyRequirements = () => { + throw new Error("live policy requirements changed before local inference"); }; const install = vi.fn(async () => ({ ok: true, path: "C:/Ollama/ollama.exe" })); const start = vi.fn(() => true); @@ -314,7 +314,7 @@ describe("createSetupNimOllamaHandlers", () => { null, selection, ), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(install).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/setup-nim-ollama.ts b/src/lib/onboard/setup-nim-ollama.ts index faaf1ac44d6..de45a73ffd8 100644 --- a/src/lib/onboard/setup-nim-ollama.ts +++ b/src/lib/onboard/setup-nim-ollama.ts @@ -217,10 +217,10 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): { if (!proceed) return "retry-selection"; if (isSwitch) { - state.revalidatePolicyRequirements?.("switch to the Windows Ollama runtime"); + state.verifyLivePolicyRequirements?.("switch to the Windows Ollama runtime"); deps.switchToWindowsOllamaHost(); } else if (isInstall) { - state.revalidatePolicyRequirements?.("install the Windows Ollama runtime"); + state.verifyLivePolicyRequirements?.("install the Windows Ollama runtime"); const installResult = await deps.installOllamaOnWindowsHost(); if (!installResult.ok) { console.error( @@ -231,7 +231,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): { } if (!deps.awaitWindowsOllamaReady()) { console.log(" Installer did not leave a reachable Ollama daemon; restarting it..."); - state.revalidatePolicyRequirements?.("start the Windows Ollama runtime"); + state.verifyLivePolicyRequirements?.("start the Windows Ollama runtime"); if (!deps.setupWindowsOllamaWith0000Binding({ installedPath: installResult.path })) { deps.printWindowsOllamaTimeoutDiagnostics(); if (deps.isNonInteractive()) deps.process.exit(1); @@ -240,7 +240,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): { } console.log(` ✓ Using Ollama on host.docker.internal:${deps.OLLAMA_PORT}`); } else { - state.revalidatePolicyRequirements?.("start the Windows Ollama runtime"); + state.verifyLivePolicyRequirements?.("start the Windows Ollama runtime"); if ( !deps.setupWindowsOllamaWith0000Binding({ announceStop: isRestart, @@ -276,7 +276,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): { // Linux systemd service. Applying the loopback override targets an // unrelated local ollama.service and exits 1 (#8596, regression of #4208). if (!isWindowsHostOllama) { - state.revalidatePolicyRequirements?.("configure the local Ollama runtime"); + state.verifyLivePolicyRequirements?.("configure the local Ollama runtime"); const overrideState = deps.ensureOllamaLoopbackSystemdOverride({ isNonInteractive: deps.isNonInteractive, contextWindowFloor: state.ollamaContextWindowFloor, @@ -290,7 +290,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): { deps.process.exit(1); } } - state.revalidatePolicyRequirements?.("start the local Ollama runtime"); + state.verifyLivePolicyRequirements?.("start the local Ollama runtime"); const startup = deps.runOllamaStartupOrGate({ ollamaReady, ollamaPort: deps.OLLAMA_PORT, @@ -334,7 +334,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): { } const lockedModel = preflightOllamaRoute(state, requestedModel, recoveredModel); const isUpgrade = ollamaInstallMenu.hasUpgradableOllama; - state.revalidatePolicyRequirements?.("install the local Ollama runtime"); + state.verifyLivePolicyRequirements?.("install the local Ollama runtime"); const installResult = deps.process.platform === "darwin" ? deps.installOllamaOnMacOS({ diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index 2ef65da29ec..25d23f4a471 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -62,7 +62,7 @@ export type SetupNimSelectionState = { /** Attempt-wide shared-gateway guard, invoked after identity selection and before probes. */ assertRouteCompatible?: () => GatewayRouteDiscoveryConstraints; /** Receipt-bound policy check invoked immediately before provider or runtime mutations. */ - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }; /** Revalidate the current provider selection before a policy-dependent mutation. */ @@ -70,22 +70,22 @@ export function assertSelectionMutationAuthority( state: SetupNimSelectionState, operation: string, ): void { - state.revalidatePolicyRequirements?.(operation); + state.verifyLivePolicyRequirements?.(operation); } /** Carry the attempt's exact mutation guard through a blocking credential prompt. */ export function credentialMutationGuardFor( state: SetupNimSelectionState, ): ((operation: string) => void) | undefined { - return state.revalidatePolicyRequirements; + return state.verifyLivePolicyRequirements; } export function withCredentialMutationGuard( state: SetupNimSelectionState, options: T, -): T & { revalidatePolicyRequirements?: (operation: string) => void } { +): T & { verifyLivePolicyRequirements?: (operation: string) => void } { const guard = credentialMutationGuardFor(state); - return guard ? { ...options, revalidatePolicyRequirements: guard } : options; + return guard ? { ...options, verifyLivePolicyRequirements: guard } : options; } export type CloudFallbackConfig = { @@ -241,7 +241,7 @@ type ProbeOptions = { extraHeaders?: readonly string[]; capabilityCache?: OnboardInferenceCapabilityCache; provider?: string; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }; type ValidationResult = @@ -268,7 +268,7 @@ type RemoteModelValidatorDeps = { credentialEnv: string, helpUrl: string | null, capabilityCache?: OnboardInferenceCapabilityCache, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; validateCustomAnthropicSelection: ( label: string, @@ -278,7 +278,7 @@ type RemoteModelValidatorDeps = { helpUrl: string | null, options?: { intendedApi?: "anthropic-messages" | "openai-completions"; - revalidatePolicyRequirements?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; }, ) => Promise; validateAnthropicSelectionWithRetryMessage: ( @@ -288,7 +288,7 @@ type RemoteModelValidatorDeps = { credentialEnv: string, retryMessage: string, helpUrl: string | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ) => Promise; validateOpenAiLikeSelection: ( label: string, diff --git a/src/lib/onboard/tool-disclosure-flow.test.ts b/src/lib/onboard/tool-disclosure-flow.test.ts index 5381c5fe66c..6f3dd11fc53 100644 --- a/src/lib/onboard/tool-disclosure-flow.test.ts +++ b/src/lib/onboard/tool-disclosure-flow.test.ts @@ -154,47 +154,6 @@ describe("onboard tool-disclosure flow", () => { expect(mocks.removeSandbox).not.toHaveBeenCalled(); }); - it("keeps baseline-exclusion retry metadata when absent replacement creation fails (#7194)", () => { - const baselineExclusions = [ - { - version: 1 as const, - agent: "openclaw", - key: "openclaw_docs", - digest: "baseline-digest", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - appliedAgentVersion: "2026.6.10", - }, - ]; - const retryEntry = { - name: "alpha", - toolDisclosure: "progressive" as const, - baselineExclusions, - }; - let registryEntry: typeof retryEntry | null = retryEntry; - mocks.removeSandbox.mockImplementation(() => { - registryEntry = null; - }); - - expect(() => { - prepareSandboxToolDisclosure( - "alpha", - null, - true, - () => ({ - existingEntry: registryEntry, - preservedMcpState: undefined, - liveExists: false, - }), - "progressive", - ); - throw new Error("injected create failure"); - }).toThrow("injected create failure"); - - expect(registryEntry?.baselineExclusions).toEqual(baselineExclusions); - expect(mocks.updateSession).toHaveBeenCalledOnce(); - expect(mocks.removeSandbox).not.toHaveBeenCalled(); - }); - it("resolves schema-5 tool disclosure without reading or writing session state (#9203)", () => { const result = prepareHermesPortableToolDisclosure("direct"); diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index e0f978b09f7..289ee99ffef 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -88,28 +88,12 @@ export interface SandboxCreateIntent { readonly recreateJournalTargetIntentFingerprint?: string; /** Validated non-secret Hermes environment assignments carried by a rebuild. */ readonly rebuildPreservedEnv?: readonly import("../state/preserved-env").PreservedEnvFile[]; - /** Built-in policy presets owned by the outer authoritative rebuild lifecycle. */ - readonly rebuildPolicyPresets?: readonly string[]; + /** Bounded live OpenShell policy handoff for one active rebuild. */ + readonly rebuildPolicySourcePath?: string; } -/** Policy authority proved inside one exact post-create sandbox identity gate. */ -export type VerifiedSandboxPolicyRegistration = - | { - readonly policyAuthority: "nemoclaw-managed"; - readonly policyCreationReceipt: import("../policy/merge").NemoClawPolicyCreationReceipt; - readonly observedPolicyAuthority: "owner-unknown"; - } - | { - readonly policyAuthority: "externally-managed"; - readonly policyCreationReceipt: null; - /** Generic evidence seam; the default #10115 verifier produces only global authority. */ - readonly observedPolicyAuthority: "externally-managed" | "owner-unknown"; - readonly policyIdentity: import("../policy/merge").OpenShellPolicyIdentity; - }; - -/** Exact sandbox and policy result retained from the immediate create gate. */ -export interface VerifiedSandboxPolicyBoundary { - readonly registration: VerifiedSandboxPolicyRegistration; +/** Exact sandbox identity retained from the immediate create boundary. */ +export interface VerifiedSandboxCreateBoundary { readonly sandboxName: string; readonly gatewayName: string; readonly gatewayPort: number; @@ -119,12 +103,12 @@ export interface VerifiedSandboxPolicyBoundary { readonly route: import("./docker-gpu-route").SelectedDockerGpuRoute; } -/** Exact context made available only after effective-policy verification. */ -export interface VerifiedSandboxCreateEffectsContext extends VerifiedSandboxPolicyBoundary { - readonly revalidatePolicyRequirements: (operation: string) => void; +/** Exact context made available after OpenShell confirms the create requirements. */ +export interface VerifiedSandboxCreateEffectsContext extends VerifiedSandboxCreateBoundary { + readonly verifyLivePolicyRequirements: (operation: string) => void; } -/** Ephemeral effects that may run only inside the exact post-create policy gate. */ +/** Ephemeral effects that may run only after OpenShell confirms the create requirements. */ export type VerifiedSandboxCreateEffects = ( context: VerifiedSandboxCreateEffectsContext, ) => Promise; @@ -176,8 +160,8 @@ export type OnboardOptions = { managedWorkloadRebuild?: import("./workload/rebuild").ManagedWorkloadRebuildHandoff; /** Internal validated non-secret Hermes environment assignments carried by a rebuild. */ rebuildPreservedEnv?: readonly import("../state/preserved-env").PreservedEnvFile[]; - /** Internal authoritative policy selection carried across sandbox recreation. */ - rebuildPolicyPresets?: readonly string[]; + /** Bounded live OpenShell policy handoff for one active rebuild. */ + rebuildPolicySourcePath?: string; /** Internal hint for resolving the sandbox base image without repeating remote discovery. */ baseImageResolutionHint?: | import("../sandbox-base-image").SandboxBaseImageResolutionMetadata diff --git a/src/lib/onboard/validation-recovery-prompt.test.ts b/src/lib/onboard/validation-recovery-prompt.test.ts index 4970b6dce2e..45820770d9f 100644 --- a/src/lib/onboard/validation-recovery-prompt.test.ts +++ b/src/lib/onboard/validation-recovery-prompt.test.ts @@ -148,7 +148,7 @@ describe("validation recovery credential prompt", () => { expectHelpBeforeSelectionReturn(log); }); - it("rechecks policy authority after recovery input before credential persistence (#9833)", async () => { + it("rechecks policy requirements after recovery input before credential persistence (#9833)", async () => { vi.stubEnv("OPENAI_API_KEY", "sk-existing"); vi.spyOn(console, "log").mockImplementation(() => undefined); const { helpers, prompt } = createRecoveryPrompt(["retry", "sk-replacement"]); @@ -160,10 +160,10 @@ describe("validation recovery credential prompt", () => { "OPENAI_API_KEY", null, () => { - throw new Error("external policy authority must supply the selected route"); + throw new Error("live policy requirements changed before the selected route"); }, ), - ).rejects.toThrow(/external policy authority must supply/u); + ).rejects.toThrow(/live policy requirements changed before/u); expect(prompt).toHaveBeenCalledTimes(2); expect(process.env.OPENAI_API_KEY).toBe("sk-existing"); diff --git a/src/lib/onboard/validation-recovery-prompt.ts b/src/lib/onboard/validation-recovery-prompt.ts index 538d61137a6..4b59c5ceb28 100644 --- a/src/lib/onboard/validation-recovery-prompt.ts +++ b/src/lib/onboard/validation-recovery-prompt.ts @@ -22,14 +22,14 @@ export interface ValidationRecoveryPromptHelpers { label: string, helpUrl?: string | null, validator?: ((value: string) => string | null) | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise; promptValidationRecovery( label: string, recovery: ProbeRecovery, credentialEnv?: string | null, helpUrl?: string | null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise<"credential" | "selection" | "retry" | "model">; } @@ -41,7 +41,7 @@ export function createValidationRecoveryPromptHelpers( label: string, helpUrl: string | null = null, validator: ((value: string) => string | null) | null = null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise { if (helpUrl) { console.log(""); @@ -67,7 +67,7 @@ export function createValidationRecoveryPromptHelpers( console.error(validationError); continue; } - revalidatePolicyRequirements?.(`save ${label}`); + verifyLivePolicyRequirements?.(`save ${label}`); saveCredential(envName, key); process.env[envName] = key; console.log(""); @@ -82,7 +82,7 @@ export function createValidationRecoveryPromptHelpers( recovery: ProbeRecovery, credentialEnv: string | null = null, helpUrl: string | null = null, - revalidatePolicyRequirements?: (operation: string) => void, + verifyLivePolicyRequirements?: (operation: string) => void, ): Promise<"credential" | "selection" | "retry" | "model"> { if (deps.isNonInteractive()) { process.exit(1); @@ -124,7 +124,7 @@ export function createValidationRecoveryPromptHelpers( `${label} API key`, helpUrl, validator, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if (result.kind === "selection") { console.log(" Returning to provider selection."); @@ -147,7 +147,7 @@ export function createValidationRecoveryPromptHelpers( `${label} API key`, helpUrl, validator, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, ); if (result.kind === "selection") { console.log(" Returning to provider selection."); diff --git a/src/lib/onboard/vm-dns-monkeypatch.test.ts b/src/lib/onboard/vm-dns-monkeypatch.test.ts index 1e1a2ad0129..6417ad5ee0c 100644 --- a/src/lib/onboard/vm-dns-monkeypatch.test.ts +++ b/src/lib/onboard/vm-dns-monkeypatch.test.ts @@ -59,17 +59,17 @@ describe("applyOnboardVmDnsMonkeypatch", () => { ok: true, status: "applied" as const, })); - const revalidatePolicyAuthority = vi.fn(() => { - throw new Error("policy authority changed"); + const verifyLivePolicyRequirements = vi.fn(() => { + throw new Error("policy requirements changed"); }); expect(() => applyOnboardVmDnsMonkeypatch( "demo", { openshellDriver: "vm" }, - { apply, log, revalidatePolicyAuthority }, + { apply, log, verifyLivePolicyRequirements }, ), - ).toThrow("policy authority changed"); + ).toThrow("policy requirements changed"); expect(apply).toHaveBeenCalledOnce(); expect(log).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/vm-dns-monkeypatch.ts b/src/lib/onboard/vm-dns-monkeypatch.ts index fb9e8f55e45..d5b4a2860f4 100644 --- a/src/lib/onboard/vm-dns-monkeypatch.ts +++ b/src/lib/onboard/vm-dns-monkeypatch.ts @@ -9,7 +9,7 @@ import { type OnboardVmDnsMonkeypatchDeps = { apply?: typeof applyOpenShellVmDnsMonkeypatch; log?: (message: string) => void; - revalidatePolicyAuthority?: (operation: string) => void; + verifyLivePolicyRequirements?: (operation: string) => void; warn?: (message: string) => void; }; @@ -28,11 +28,11 @@ export function applyOnboardVmDnsMonkeypatch( openshellDriver: runtime.openshellDriver, }, { - revalidatePolicyAuthority: deps.revalidatePolicyAuthority, + verifyLivePolicyRequirements: deps.verifyLivePolicyRequirements, }, ); if (vmDnsPatch.ok) { - deps.revalidatePolicyAuthority?.( + deps.verifyLivePolicyRequirements?.( `report VM DNS monkeypatch result for sandbox '${sandboxName}'`, ); } diff --git a/src/lib/policy/README.md b/src/lib/policy/README.md index 210d553cbbd..a22f97f9583 100644 --- a/src/lib/policy/README.md +++ b/src/lib/policy/README.md @@ -3,43 +3,22 @@ # Policy -Policy modules own sandbox network-policy preset loading, tier resolution, and -policy application helpers. They may orchestrate OpenShell policy commands while -legacy flows are being migrated, but pure selection/planning helpers should move -under `src/lib/domain/**` when they can be isolated. - -## Policy authority - -The policy module reads the effective OpenShell policy through the sandbox's recorded gateway. -NemoClaw records the first qualified authority before another policy read or set. -NemoClaw refuses the operation when it cannot write that record. - -Immediately before each policy set, NemoClaw reads authority again and compares it with the record. -NemoClaw refuses the policy set when: - -- NemoClaw cannot determine authority. -- Recorded and observed authority differ. -- An external authority owns the policy. - -For external authority, preset requests only verify the effective policy. -NemoClaw requires the effective policy to contain exactly the requested preset entries before it reports success. -NemoClaw does not set policy or record preset or custom-policy attribution. -The external authority must supply a missing or changed entry. - -If policy authority becomes external while Shields is down, NemoClaw keeps the -saved restrictive policy snapshot and refuses to set policy. The external -policy authority must make the effective policy for the named sandbox match the -saved restrictive snapshot and current managed MCP entries without changing -policy authority. `shields status` identifies that required policy by its -canonical JSON SHA-256 digest and network policy keys. The first status can -report no artifact. Run `nemoclaw shields up` once to create and -report the complete recovery artifact. The artifact contains no credential -values. It contains the saved restrictive policy and current managed MCP policy -entries, which may include credential bindings. Apply the artifact as the complete policy through the external authority; do not reconstruct it from the digest -or key list. Then rerun `nemoclaw shields up`. NemoClaw verifies that the -effective policy equals the artifact and locks configuration. If policy changes during the -lock, NemoClaw records the verified config lock and keeps Shields down until -policy recovery succeeds. - -A legacy sandbox record retains the first qualified `policyAuthority` after a later operation fails. -An inspection that cannot determine authority does not change the record. +OpenShell is the sole durable source of truth for sandbox policy. NemoClaw +provides convenience commands that read the current OpenShell policy, compose a +requested delta, submit it to OpenShell, and verify the resulting live state. + +NemoClaw does not persist policy ownership, policy receipts, desired tiers, +applied preset lists, custom policy copies, baseline exclusion ledgers, or +policy hashes and versions. Registry and onboarding-session normalization +discard legacy copies of those fields without replaying them. + +Policy mutations preserve unrelated live entries. Custom preset identity is +encoded in namespaced OpenShell policy keys so list and remove commands can +derive it from live state. Generated MCP policy is derived from durable MCP +target and credential-domain state, not from a second policy registry. + +Shields retains only its bounded transition snapshot and timer state. On +restore, it reverts entries that still match the Shields-down values and keeps +host-side changes made while Shields was down. Rebuild and clone operations use +a private temporary copy of the current OpenShell base policy for the active +transaction and remove that copy after completion. diff --git a/src/lib/policy/agent-base-preset.test.ts b/src/lib/policy/agent-base-preset.test.ts deleted file mode 100644 index 47db5d08284..00000000000 --- a/src/lib/policy/agent-base-preset.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { AGENTS_DIR } from "../agent/defs"; -import * as registry from "../state/registry"; -import { isAgentBasePreset } from "./index"; - -const tempAgentDirs: string[] = []; - -function createAgentFixture(policyAdditions?: string): string { - const agentName = `agent-base-preset-${randomUUID()}`; - const agentDir = path.join(AGENTS_DIR, agentName); - tempAgentDirs.push(agentDir); - fs.mkdirSync(agentDir, { recursive: true }); - fs.writeFileSync( - path.join(agentDir, "manifest.yaml"), - `name: ${agentName}\ndisplay_name: Agent Base Preset Fixture\n`, - ); - fs.writeFileSync( - path.join(agentDir, "policy-additions.yaml"), - policyAdditions ?? - `version: 1 -network_policies: - github: - name: github - endpoints: - - host: api.github.com - port: 443 - access: full - binaries: - - path: /usr/bin/git -`, - ); - return agentName; -} - -afterEach(() => { - vi.restoreAllMocks(); - for (const agentDir of tempAgentDirs.splice(0)) { - fs.rmSync(agentDir, { recursive: true, force: true }); - } -}); - -describe("agent base preset detection", () => { - it("loads the recorded agent policy and distinguishes matching preset names (#9079)", () => { - const agent = createAgentFixture(); - vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent } as never); - - expect(isAgentBasePreset("alpha", "github")).toBe(true); - expect(isAgentBasePreset("alpha", "slack")).toBe(false); - }); - - it("recognizes the Hermes base policy when its preset name also exists in the catalog (#9079)", () => { - const hermesPolicy = fs.readFileSync( - path.join(AGENTS_DIR, "hermes", "policy-additions.yaml"), - "utf8", - ); - const agent = createAgentFixture(hermesPolicy); - vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "hermes", agent } as never); - - expect(isAgentBasePreset("hermes", "pypi")).toBe(true); - }); - - it("reports pypi and github as non-baseline presets for the Pi policy (#7924)", () => { - const piPolicy = fs.readFileSync(path.join(AGENTS_DIR, "pi", "policy-additions.yaml"), "utf8"); - const agent = createAgentFixture(piPolicy); - vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "pi", agent } as never); - - expect(isAgentBasePreset("pi", "managed_inference")).toBe(true); - expect(isAgentBasePreset("pi", "pypi")).toBe(false); - expect(isAgentBasePreset("pi", "github")).toBe(false); - }); -}); diff --git a/src/lib/policy/baseline-exclusion-journal-integration.test.ts b/src/lib/policy/baseline-exclusion-journal-integration.test.ts deleted file mode 100644 index 41149da2223..00000000000 --- a/src/lib/policy/baseline-exclusion-journal-integration.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// 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 { - managedPolicyInspection, - managedSandboxEntry, - SANDBOX_IDENTITY, -} from "../../../test/helpers/managed-policy-receipt-fixture"; - -const harness = vi.hoisted(() => ({ - inspectOpenShellSandboxIdentityFingerprint: vi.fn(() => SANDBOX_IDENTITY), - inspectSandboxPolicyAuthority: vi.fn(() => managedPolicyInspection()), - livePolicy: "", - run: vi.fn(), - runCapture: vi.fn(), -})); - -vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ - ...(await importOriginal()), - inspectOpenShellSandboxIdentityFingerprint: harness.inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority: harness.inspectSandboxPolicyAuthority, -})); - -vi.mock("../runner", async (importOriginal) => ({ - ...(await importOriginal()), - run: harness.run, - runCapture: harness.runCapture, -})); - -vi.mock("../adapters/openshell/resolve", async (importOriginal) => ({ - ...(await importOriginal()), - resolveOpenshell: vi.fn(() => "/usr/bin/openshell"), -})); - -const originalHome = process.env.HOME; -const temporaryHomes: string[] = []; - -afterEach(() => { - process.env.HOME = originalHome; - vi.restoreAllMocks(); - vi.resetModules(); - for (const home of temporaryHomes.splice(0)) { - fs.rmSync(home, { recursive: true, force: true }); - } -}); - -describe("baseline exclusion journal integration", () => { - it("reloads and finalizes a real persisted journal after interrupted commit (#7178)", async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-journal-")); - temporaryHomes.push(home); - process.env.HOME = home; - vi.resetModules(); - - const registry = await import("../state/registry"); - const baseline = await import("./baseline-exclusion"); - const policy = await import("./index"); - registry.registerSandbox(managedSandboxEntry("alpha", "hermes")); - - harness.livePolicy = `version: 1 -network_policies: - nous_research: - endpoints: - - host: nousresearch.com - port: 443 -`; - const entry = baseline.getBaselineEntry(harness.livePolicy, "nous_research"); - expect(entry).not.toBeNull(); - const digest = baseline.digestBaselineEntry(entry!); - harness.runCapture.mockImplementation(() => harness.livePolicy); - harness.run.mockImplementation((command: readonly string[]) => { - const policyIndex = command.indexOf("--policy"); - harness.livePolicy = fs.readFileSync(command[policyIndex + 1], "utf8"); - return { status: 0 }; - }); - const interruptedCommit = vi - .spyOn(registry, "commitBaselineExclusionTransition") - .mockReturnValueOnce(false); - - expect(policy.excludeBaselineEntry("alpha", "nous_research", digest, { nonFatal: true })).toBe( - false, - ); - expect(harness.livePolicy).not.toContain("nous_research:"); - expect(registry.getBaselineExclusionTransition("alpha")).toEqual( - expect.objectContaining({ - operation: "exclude", - exclusion: expect.objectContaining({ digest }), - }), - ); - expect(registry.getBaselineExclusions("alpha")).toEqual([]); - expect(registry.getSandbox("alpha")?.policyAuthority).toBe("nemoclaw-managed"); - interruptedCommit.mockRestore(); - - // Simulate a new CLI process: reload both the registry and policy modules - // from the same temp HOME, then retry against the exact live target. - vi.resetModules(); - const reloadedRegistry = await import("../state/registry"); - const reloadedPolicy = await import("./index"); - expect(reloadedPolicy.excludeBaselineEntry("alpha", "nous_research", digest)).toBe(true); - expect(reloadedRegistry.getBaselineExclusionTransition("alpha")).toBeNull(); - expect(reloadedRegistry.getBaselineExclusions("alpha")).toEqual([ - expect.objectContaining({ key: "nous_research", digest }), - ]); - expect(reloadedRegistry.getSandbox("alpha")?.policyAuthority).toBe("nemoclaw-managed"); - }); -}); diff --git a/src/lib/policy/baseline-exclusion-persistence.test.ts b/src/lib/policy/baseline-exclusion-persistence.test.ts deleted file mode 100644 index 725c85d8d15..00000000000 --- a/src/lib/policy/baseline-exclusion-persistence.test.ts +++ /dev/null @@ -1,711 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import YAML from "yaml"; -import { - managedPolicyInspection, - managedSandboxEntry, - SANDBOX_IDENTITY, -} from "../../../test/helpers/managed-policy-receipt-fixture"; - -const mocks = vi.hoisted(() => ({ - addBaselineExclusion: vi.fn(), - beginBaselineExclusionTransition: vi.fn(), - clearBaselineExclusionTransition: vi.fn(), - commitBaselineExclusionTransition: vi.fn(), - getBaselineExclusions: vi.fn(), - getBaselineExclusionTransition: vi.fn(), - getSandbox: vi.fn(), - inspectOpenShellSandboxIdentityFingerprint: vi.fn(), - inspectSandboxPolicyAuthority: vi.fn(), - removeBaselineExclusion: vi.fn(), - run: vi.fn(), - runCapture: vi.fn(), -})); - -vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ - ...(await importOriginal()), - inspectOpenShellSandboxIdentityFingerprint: mocks.inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority: mocks.inspectSandboxPolicyAuthority, -})); - -vi.mock("../runner", async (importOriginal) => ({ - ...(await importOriginal()), - run: mocks.run, - runCapture: mocks.runCapture, -})); - -vi.mock("../state/registry", async (importOriginal) => ({ - ...(await importOriginal()), - addBaselineExclusion: mocks.addBaselineExclusion, - beginBaselineExclusionTransition: mocks.beginBaselineExclusionTransition, - clearBaselineExclusionTransition: mocks.clearBaselineExclusionTransition, - commitBaselineExclusionTransition: mocks.commitBaselineExclusionTransition, - getBaselineExclusions: mocks.getBaselineExclusions, - getBaselineExclusionTransition: mocks.getBaselineExclusionTransition, - getSandbox: mocks.getSandbox, - removeBaselineExclusion: mocks.removeBaselineExclusion, -})); - -import * as openshellResolveModule from "../adapters/openshell/resolve"; -import { digestBaselineEntry, getBaselineEntry } from "./baseline-exclusion"; -import { - applyPresetContent, - excludeBaselineEntry, - getBaselineExclusionRuntimeStatus, - loadPresetForSandbox, - restoreBaselineEntry, -} from "./index"; - -const LIVE_POLICY = `version: 1 -network_policies: - nous_research: - endpoints: - - host: nousresearch.com - port: 443 -`; -const LIVE_ENTRY = getBaselineEntry(LIVE_POLICY, "nous_research"); -const LIVE_DIGEST = digestBaselineEntry(LIVE_ENTRY!); -const HERMES_BASELINE = fs.readFileSync("agents/hermes/policy-additions.yaml", "utf8"); -const HERMES_BASELINE_ENTRY = getBaselineEntry(HERMES_BASELINE, "nous_research"); -const HERMES_BASELINE_DIGEST = digestBaselineEntry(HERMES_BASELINE_ENTRY!); -const HERMES_MANAGED_INFERENCE_DIGEST = digestBaselineEntry( - getBaselineEntry(HERMES_BASELINE, "managed_inference")!, -); -const HERMES_RESTORED_POLICY = YAML.stringify({ - version: 1, - network_policies: { nous_research: HERMES_BASELINE_ENTRY }, -}); -const OPENCLAW_BASELINE_ENTRY = getBaselineEntry( - fs.readFileSync("nemoclaw-blueprint/policies/openclaw-sandbox.yaml", "utf8"), - "managed_inference", -); -const OPENCLAW_BASELINE_DIGEST = digestBaselineEntry(OPENCLAW_BASELINE_ENTRY!); -const OPENCLAW_RESTORED_POLICY = YAML.stringify({ - version: 1, - network_policies: { managed_inference: OPENCLAW_BASELINE_ENTRY }, -}); - -function expectNoBaselineMutation(): void { - expect(mocks.addBaselineExclusion).not.toHaveBeenCalled(); - expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); -} - -describe("excludeBaselineEntry persistence boundary (#7178)", () => { - beforeEach(() => { - vi.spyOn(openshellResolveModule, "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); - vi.spyOn(console, "error").mockImplementation(() => undefined); - mocks.runCapture.mockReturnValue(LIVE_POLICY); - mocks.run.mockReturnValue({ status: 0 }); - mocks.inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - mocks.inspectSandboxPolicyAuthority.mockReturnValue(managedPolicyInspection()); - mocks.getSandbox.mockReturnValue({ - ...managedSandboxEntry("alpha", "hermes"), - agentVersion: "1.2.3", - }); - mocks.getBaselineExclusions.mockReturnValue([]); - mocks.getBaselineExclusionTransition.mockReturnValue(null); - mocks.beginBaselineExclusionTransition.mockReturnValue(false); - mocks.clearBaselineExclusionTransition.mockReturnValue(true); - mocks.commitBaselineExclusionTransition.mockReturnValue(true); - mocks.addBaselineExclusion.mockReturnValue(true); - mocks.removeBaselineExclusion.mockReturnValue(true); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - for (const mock of Object.values(mocks)) mock.mockReset(); - }); - - it("does not narrow live egress when the exclusion cannot be recorded durably", () => { - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.beginBaselineExclusionTransition).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ - operation: "exclude", - exclusion: expect.objectContaining({ - version: 1, - agent: "hermes", - key: "nous_research", - digest: LIVE_DIGEST, - appliedAgentVersion: "1.2.3", - }), - targetLiveDigest: null, - }), - ); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no live policy changes")); - }); - - it("refuses exclusion journal changes when authority changes during the live read (#9833)", () => { - mocks.inspectSandboxPolicyAuthority - .mockReturnValueOnce(managedPolicyInspection()) - .mockReturnValueOnce({ ...managedPolicyInspection(), authority: "externally-managed" }); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expectNoBaselineMutation(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("externally managed")); - }); - - it("preserves a pending exclusion when authority changes at the policy-set edge (#9833)", () => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.inspectSandboxPolicyAuthority - .mockReturnValueOnce(managedPolicyInspection()) - .mockReturnValueOnce(managedPolicyInspection()) - .mockReturnValue({ ...managedPolicyInspection(), authority: "externally-managed" }); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(mocks.beginBaselineExclusionTransition).toHaveBeenCalledOnce(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("externally managed")); - }); - - it("clears a fresh transaction when live narrowing fails", () => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.run.mockReturnValue({ status: 19 }); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; - expect(mocks.clearBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - }); - - it("publishes committed intent only after exact live narrowing is verified", () => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.runCapture - .mockReturnValueOnce(LIVE_POLICY) - .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n"); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - true, - ); - - const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - }); - - it("commits when a failed OpenShell result nevertheless reached the exact live target", () => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.run.mockReturnValue({ status: 19 }); - mocks.runCapture - .mockReturnValueOnce(LIVE_POLICY) - .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n"); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - true, - ); - - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledOnce(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - }); - - it.each([ - ["returns false", () => false], - [ - "throws", - () => { - throw new Error("disk unavailable"); - }, - ], - ])("preserves a verified exclusion journal when finalization %s (#7178)", (_label, finalize) => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.commitBaselineExclusionTransition.mockImplementation(finalize); - mocks.runCapture - .mockReturnValueOnce(LIVE_POLICY) - .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n"); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledOnce(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("durable journal could not be finalized"), - ); - }); - - it("preserves the journal when post-write live readback is unavailable (#7178)", () => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.runCapture.mockReturnValueOnce(LIVE_POLICY).mockReturnValueOnce(""); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Could not verify the live"), - ); - }); - - it("pins every live read and write to the sandbox's recorded gateway (#7178)", () => { - vi.stubEnv("OPENSHELL_GATEWAY", "ambient-gateway"); - mocks.getSandbox.mockReturnValue({ - ...managedSandboxEntry("alpha", "hermes", { - gatewayName: "nemoclaw-18080", - gatewayPort: 18080, - }), - agentVersion: "1.2.3", - }); - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.runCapture - .mockImplementationOnce((_command, options) => { - expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); - expect(options).toMatchObject({ env: { OPENSHELL_GATEWAY: "nemoclaw-18080" } }); - return LIVE_POLICY; - }) - .mockImplementationOnce((_command, options) => { - expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); - expect(options).toMatchObject({ env: { OPENSHELL_GATEWAY: "nemoclaw-18080" } }); - return "version: 1\nnetwork_policies: {}\n"; - }); - mocks.run.mockImplementation((_command, options) => { - expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); - expect(options).toMatchObject({ env: { OPENSHELL_GATEWAY: "nemoclaw-18080" } }); - return { status: 0 }; - }); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - true, - ); - expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); - }); - - it("rejects an ambient OpenShell gateway endpoint before live mutation (#7178)", () => { - vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://other.example.test"); - - expect(() => - excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true }), - ).toThrow(/OPENSHELL_GATEWAY_ENDPOINT is set/); - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - }); - - it.each([ - ["returns false", () => false], - [ - "throws", - () => { - throw new Error("disk unavailable"); - }, - ], - ])("preserves and reports the journal when exclusion compensation %s", (_label, compensate) => { - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.run.mockReturnValue({ status: 19 }); - mocks.clearBaselineExclusionTransition.mockImplementation(compensate); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("durable journal was preserved"), - ); - }); - - it("finalizes an interrupted exclusion when the exact live target is already present", () => { - mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-exclude", - operation: "exclude", - exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: LIVE_DIGEST }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - true, - ); - - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", "tx-exclude"); - expect(mocks.run).not.toHaveBeenCalled(); - }); - - it("keeps an interrupted exclusion fail-closed when live policy matches neither side", () => { - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-exclude", - operation: "exclude", - exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: LIVE_DIGEST }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }); - mocks.runCapture.mockReturnValue( - LIVE_POLICY.replace("nousresearch.com", "third-state.example.test"), - ); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("matches neither side")); - }); - - it("does not mistake a malformed same-key live entry for the absent exclude target", () => { - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-exclude", - operation: "exclude", - exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: LIVE_DIGEST }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }); - mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies:\n nous_research: malformed\n"); - - expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( - false, - ); - - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - }); - - it("refuses to remove a live entry that changed after the operator preview", () => { - mocks.runCapture.mockReturnValue( - LIVE_POLICY.replace("nousresearch.com", "changed.example.test"), - ); - - expect(excludeBaselineEntry("alpha", "nous_research", "stale-digest", { nonFatal: true })).toBe( - false, - ); - - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.addBaselineExclusion).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("changed after preview")); - }); -}); - -describe("restoreBaselineEntry persistence boundary (#7178)", () => { - const RECORDED = { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: LIVE_DIGEST, - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }; - - beforeEach(() => { - vi.spyOn(openshellResolveModule, "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); - vi.spyOn(console, "error").mockImplementation(() => undefined); - mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); - mocks.run.mockReturnValue({ status: 0 }); - mocks.inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - mocks.inspectSandboxPolicyAuthority.mockReturnValue(managedPolicyInspection()); - mocks.getSandbox.mockReturnValue({ - ...managedSandboxEntry("alpha", "hermes"), - agentVersion: "1.2.3", - }); - mocks.getBaselineExclusions.mockReturnValue([RECORDED]); - mocks.getBaselineExclusionTransition.mockReturnValue(null); - mocks.beginBaselineExclusionTransition.mockReturnValue(true); - mocks.clearBaselineExclusionTransition.mockReturnValue(true); - mocks.commitBaselineExclusionTransition.mockReturnValue(true); - mocks.removeBaselineExclusion.mockReturnValue(true); - mocks.addBaselineExclusion.mockReturnValue(true); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - for (const mock of Object.values(mocks)) mock.mockReset(); - }); - - it.each([ - ["changes", "nous_research", "stale-preview-digest", [RECORDED]], - ["appears", "nous_research", null, [RECORDED]], - ["disappears", "legacy_entry", LIVE_DIGEST, [{ ...RECORDED, key: "legacy_entry" }]], - ] as const)( - "does not mutate when the baseline entry %s after the operator preview", - (_change, key, expectedTargetDigest, exclusions) => { - mocks.getBaselineExclusions.mockReturnValue([...exclusions]); - - expect(restoreBaselineEntry("alpha", key, { nonFatal: true, expectedTargetDigest })).toBe( - false, - ); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("changed after preview")); - }, - ); - - it("does not widen live egress when its durable transaction cannot be recorded", () => { - mocks.beginBaselineExclusionTransition.mockReturnValue(false); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - expect(mocks.run).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no live policy changes")); - }); - - it("refuses restore journal changes when authority changes during the live read (#9833)", () => { - mocks.inspectSandboxPolicyAuthority - .mockReturnValueOnce(managedPolicyInspection()) - .mockReturnValueOnce({ ...managedPolicyInspection(), authority: "externally-managed" }); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - expectNoBaselineMutation(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("externally managed")); - }); - - it("clears the restore transaction when live policy restoration fails", () => { - mocks.run.mockReturnValue({ status: 19 }); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; - expect(mocks.clearBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - }); - - it("publishes the restore only after exact live widening is verified", () => { - expect(HERMES_BASELINE_ENTRY).not.toBeNull(); - mocks.runCapture - .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n") - .mockReturnValueOnce(HERMES_RESTORED_POLICY); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(true); - - const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - }); - - it("restores the current baseline before clearing an exclusion recorded for another agent (#7194)", () => { - const staleExclusion = { - ...RECORDED, - agent: "hermes", - key: "managed_inference", - digest: HERMES_MANAGED_INFERENCE_DIGEST, - }; - mocks.getSandbox.mockReturnValue({ - ...managedSandboxEntry("alpha", "openclaw"), - agentVersion: "2.0.0", - }); - mocks.getBaselineExclusions.mockReturnValue([staleExclusion]); - mocks.runCapture - .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n") - .mockReturnValueOnce(OPENCLAW_RESTORED_POLICY); - - expect(OPENCLAW_BASELINE_ENTRY).not.toBeNull(); - expect(restoreBaselineEntry("alpha", "managed_inference", { nonFatal: true })).toBe(true); - - expect(mocks.beginBaselineExclusionTransition).toHaveBeenCalledWith( - "alpha", - expect.objectContaining({ - operation: "restore", - exclusion: staleExclusion, - targetLiveDigest: OPENCLAW_BASELINE_DIGEST, - }), - ); - expect(mocks.run).toHaveBeenCalledOnce(); - const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); - expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); - }); - - it.each([ - ["returns false", () => false], - [ - "throws", - () => { - throw new Error("disk unavailable"); - }, - ], - ])("preserves and reports the journal when restore compensation %s", (_label, compensate) => { - mocks.run.mockReturnValue({ status: 19 }); - mocks.clearBaselineExclusionTransition.mockImplementation(compensate); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("durable journal was preserved"), - ); - }); - - it("finalizes an interrupted restore when the exact live target is already present", () => { - mocks.runCapture.mockReturnValue(HERMES_RESTORED_POLICY); - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-restore", - operation: "restore", - exclusion: RECORDED, - targetLiveDigest: HERMES_BASELINE_DIGEST, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(true); - - expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", "tx-restore"); - expect(mocks.run).not.toHaveBeenCalled(); - }); - - it("keeps an interrupted restore pending when the release baseline changed (#7178)", () => { - mocks.runCapture.mockReturnValue(LIVE_POLICY); - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-restore", - operation: "restore", - exclusion: RECORDED, - targetLiveDigest: LIVE_DIGEST, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("current release baseline for 'nous_research' changed"), - ); - }); - - it("keeps an interrupted restore pending when the release baseline removed its key (#7178)", () => { - const legacyTargetPolicy = YAML.stringify({ - version: 1, - network_policies: { legacy_entry: LIVE_ENTRY }, - }); - const legacyExclusion = { - version: 1 as const, - agent: "hermes", - key: "legacy_entry", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }; - mocks.runCapture.mockReturnValue(legacyTargetPolicy); - mocks.getBaselineExclusions.mockReturnValue([legacyExclusion]); - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-restore", - operation: "restore", - exclusion: legacyExclusion, - targetLiveDigest: LIVE_DIGEST, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(restoreBaselineEntry("alpha", "legacy_entry", { nonFatal: true })).toBe(false); - - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("current release baseline for 'legacy_entry' changed"), - ); - }); - - it("keeps an interrupted restore pending when its agent baseline is unreadable (#7178)", () => { - mocks.getSandbox.mockReturnValue({ - ...managedSandboxEntry("alpha", "agent-without-a-readable-baseline"), - agentVersion: "1.2.3", - }); - mocks.runCapture.mockReturnValue(LIVE_POLICY); - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-restore", - operation: "restore", - exclusion: RECORDED, - targetLiveDigest: LIVE_DIGEST, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("current release baseline for 'nous_research' is unreadable"), - ); - }); - - it("keeps an interrupted restore pending when durable exclusion intent changed (#7178)", () => { - mocks.runCapture.mockReturnValue(HERMES_RESTORED_POLICY); - mocks.getBaselineExclusions.mockReturnValue([{ ...RECORDED, digest: "changed" }]); - mocks.getBaselineExclusionTransition.mockReturnValue({ - id: "tx-restore", - operation: "restore", - exclusion: RECORDED, - targetLiveDigest: HERMES_BASELINE_DIGEST, - startedAt: "2026-07-19T00:00:00.000Z", - }); - - expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); - - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("durable exclusion for 'nous_research' changed"), - ); - }); -}); - -describe("baseline exclusion live verification boundary (#7194)", () => { - const exclusion = { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: HERMES_BASELINE_DIGEST, - }; - - beforeEach(() => { - mocks.getSandbox.mockReturnValue({ name: "alpha", agent: "hermes" }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - for (const mock of Object.values(mocks)) mock.mockReset(); - }); - - it("reports a mismatch when the excluded key remains in the observed live policy", () => { - mocks.runCapture.mockReturnValue(HERMES_RESTORED_POLICY); - - expect(getBaselineExclusionRuntimeStatus("alpha", exclusion)).toBe("live-policy-mismatch"); - }); - - it("reports excluded only when the observed live policy omits the reviewed key", () => { - mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); - - expect(getBaselineExclusionRuntimeStatus("alpha", exclusion)).toBe("excluded"); - }); - - it("refuses to reintroduce an excluded Hermes pypi key through a live preset (#7194)", () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const pypiPreset = loadPresetForSandbox("alpha", "pypi"); - expect(pypiPreset).not.toBeNull(); - mocks.getBaselineExclusions.mockReturnValue([{ ...exclusion, key: "pypi" }]); - - expect(applyPresetContent("alpha", "pypi", pypiPreset!, { nonFatal: true })).toBe(false); - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("reserved by a baseline exclusion"), - ); - }); -}); diff --git a/src/lib/policy/baseline-exclusion.test.ts b/src/lib/policy/baseline-exclusion.test.ts index 1e0157ce9d0..065e75f5965 100644 --- a/src/lib/policy/baseline-exclusion.test.ts +++ b/src/lib/policy/baseline-exclusion.test.ts @@ -5,18 +5,12 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { - applyBaselineExclusions, - BaselineExclusionDriftError, - BaselineExclusionSourceError, digestBaselineEntry, - evaluateBaselineExclusionRuntimeStatus, getBaselineEntry, listBaselineEntryKeys, mergeBaselineEntryIntoPolicy, - ProtectedBaselineExclusionError, removeBaselineEntryFromPolicy, renderBaselineEntryScope, - resolveBaselineExclusion, } from "./baseline-exclusion"; const BASE_POLICY = `version: 1 @@ -42,16 +36,6 @@ network_policies: - allow: { method: POST, path: "/v1/**" } `; -function digestOf(key: string, policy = BASE_POLICY): string { - const entry = getBaselineEntry(policy, key); - expect(entry).not.toBeNull(); - return digestBaselineEntry(entry!); -} - -function exclusion(key: string, digest: string) { - return { version: 1 as const, agent: "hermes", key, digest }; -} - describe("baseline-exclusion digest (#7178)", () => { it("is stable across key ordering and whitespace", () => { const entry = getBaselineEntry(BASE_POLICY, "nous_research"); @@ -89,39 +73,6 @@ describe("baseline-exclusion enumeration (#7178)", () => { }); }); -describe("baseline-exclusion drift resolution (#7178)", () => { - it("reports no drift when the digest matches", () => { - const resolution = resolveBaselineExclusion(BASE_POLICY, { - version: 1, - agent: "hermes", - key: "nous_research", - digest: digestOf("nous_research"), - }); - expect(resolution.drift).toBeNull(); - expect(resolution.entry).not.toBeNull(); - }); - - it("reports 'changed' when the entry content no longer matches", () => { - const resolution = resolveBaselineExclusion(BASE_POLICY, { - version: 1, - agent: "hermes", - key: "nous_research", - digest: "stale-digest", - }); - expect(resolution.drift).toBe("changed"); - }); - - it("reports 'missing' when the release dropped the entry", () => { - const resolution = resolveBaselineExclusion(BASE_POLICY, { - version: 1, - agent: "hermes", - key: "absent", - digest: "any", - }); - expect(resolution.drift).toBe("missing"); - }); -}); - describe("baseline-exclusion scope render (#7178)", () => { it("previews host, method/path rules, and binaries", () => { const entry = getBaselineEntry(BASE_POLICY, "nous_research"); @@ -160,76 +111,3 @@ describe("baseline-exclusion policy edits (#7178)", () => { ]); }); }); - -describe("applyBaselineExclusions fail-closed (#7178)", () => { - it("drops matching entries and reports the excluded keys", () => { - const { content, excludedKeys } = applyBaselineExclusions( - BASE_POLICY, - [exclusion("nous_research", digestOf("nous_research"))], - "hermes", - ); - expect(excludedKeys).toEqual(["nous_research"]); - expect(Object.keys(YAML.parse(content).network_policies)).toEqual(["managed_inference"]); - }); - - it("throws on changed content instead of replaying a stale approval", () => { - expect(() => - applyBaselineExclusions(BASE_POLICY, [exclusion("nous_research", "stale")], "hermes"), - ).toThrowError(BaselineExclusionDriftError); - }); - - it("throws when the release removed the entry", () => { - let error: unknown; - try { - applyBaselineExclusions(BASE_POLICY, [exclusion("absent", "any")], "hermes"); - } catch (caught) { - error = caught; - } - expect(error).toBeInstanceOf(BaselineExclusionDriftError); - expect((error as BaselineExclusionDriftError).reason).toBe("missing"); - expect((error as Error).message).toContain("Clear it with 'policy restore'."); - expect((error as Error).message).not.toContain("re-exclude"); - }); - - it("rejects protected entries even when imported state has a matching digest", () => { - expect(() => - applyBaselineExclusions( - BASE_POLICY, - [exclusion("managed_inference", digestOf("managed_inference"))], - "hermes", - ), - ).toThrowError(ProtectedBaselineExclusionError); - }); - - it("rejects an approval recorded for a different agent baseline (#7194)", () => { - expect(() => - applyBaselineExclusions( - BASE_POLICY, - [exclusion("nous_research", digestOf("nous_research"))], - "openclaw", - ), - ).toThrowError(BaselineExclusionSourceError); - }); -}); - -describe("baseline exclusion runtime verification (#7194)", () => { - const recorded = exclusion("nous_research", "digest"); - - it("reports excluded only when the matching baseline key is absent live", () => { - expect(evaluateBaselineExclusionRuntimeStatus(recorded, "hermes", "digest", null)).toBe( - "excluded", - ); - }); - - it("reports a live mismatch when any value remains under the excluded key", () => { - expect(evaluateBaselineExclusionRuntimeStatus(recorded, "hermes", "digest", "other")).toBe( - "live-policy-mismatch", - ); - }); - - it("checks the approved agent before baseline and live digests", () => { - expect(evaluateBaselineExclusionRuntimeStatus(recorded, "openclaw", undefined, undefined)).toBe( - "agent-changed", - ); - }); -}); diff --git a/src/lib/policy/baseline-exclusion.ts b/src/lib/policy/baseline-exclusion.ts index 32489ac5ef3..e85938f2e66 100644 --- a/src/lib/policy/baseline-exclusion.ts +++ b/src/lib/policy/baseline-exclusion.ts @@ -13,10 +13,6 @@ import { parseNetworkPolicies, } from "./preset-parsing"; -/** Support posture disclosed whenever a sandbox has a baseline exclusion. */ -export const BASELINE_EXCLUSION_SUPPORT_IMPACT = - "Excluded egress leaves dependent agent features unsupported for this sandbox."; - const PROTECTED_BASELINE_EXCLUSION_KEYS = new Set(["managed_inference"]); const BASELINE_EXCLUSION_FEATURE_IMPACTS: Readonly< @@ -56,45 +52,6 @@ export function getBaselineExclusionFeatureImpact(agent: string, key: string): s return BASELINE_EXCLUSION_FEATURE_IMPACTS[agent]?.[key] ?? null; } -export interface BaselineExclusionRequest { - readonly version: 1; - readonly agent: string; - readonly key: string; - readonly digest: string; -} - -export type BaselineExclusionRuntimeStatus = - | "excluded" - | "agent-changed" - | "baseline-unreadable" - | "content-changed" - | "no-longer-in-baseline" - | "live-policy-unreadable" - | "live-policy-mismatch"; - -/** Compare reviewed intent with both the release baseline and observed live policy. */ -export function evaluateBaselineExclusionRuntimeStatus( - exclusion: BaselineExclusionRequest, - currentAgent: string, - currentBaselineDigest: string | null | undefined, - liveDigest: string | null | undefined, -): BaselineExclusionRuntimeStatus { - if (exclusion.agent !== currentAgent) return "agent-changed"; - if (currentBaselineDigest === undefined) return "baseline-unreadable"; - if (currentBaselineDigest === null) return "no-longer-in-baseline"; - if (currentBaselineDigest !== exclusion.digest) return "content-changed"; - if (liveDigest === undefined) return "live-policy-unreadable"; - return liveDigest === null ? "excluded" : "live-policy-mismatch"; -} - -export type BaselineDriftReason = "missing" | "changed"; - -export interface BaselineExclusionResolution { - readonly entry: PolicyObject | null; - readonly currentDigest: string | null; - readonly drift: BaselineDriftReason | null; -} - function canonicalize(value: PolicyValue): JsonValue { if (Array.isArray(value)) return value.map(canonicalize); if (isPolicyObject(value)) { @@ -135,108 +92,6 @@ export function listBaselineEntryKeys(basePolicyContent: string): string[] { return networkPolicies ? Object.keys(networkPolicies) : []; } -/** - * Resolve an exclusion request against the current base policy: report the - * entry, its current digest, and any drift (`missing` when the release dropped - * the key, `changed` when its content no longer matches the approved digest). - */ -export function resolveBaselineExclusion( - basePolicyContent: string, - request: BaselineExclusionRequest, -): BaselineExclusionResolution { - const entry = getBaselineEntry(basePolicyContent, request.key); - if (!entry) return { entry: null, currentDigest: null, drift: "missing" }; - const currentDigest = digestBaselineEntry(entry); - return { - entry, - currentDigest, - drift: currentDigest === request.digest ? null : "changed", - }; -} - -/** - * Raised when a recorded exclusion no longer matches the current baseline, so - * the create/rebuild policy generation fails closed instead of replaying a - * stale approval against changed egress. - */ -export class BaselineExclusionDriftError extends Error { - readonly key: string; - readonly reason: BaselineDriftReason; - - constructor(key: string, reason: BaselineDriftReason) { - super( - reason === "missing" - ? `Baseline entry '${key}' no longer exists in the current agent baseline; its exclusion approval is stale. Clear it with 'policy restore'.` - : `Baseline entry '${key}' changed since it was excluded; the exclusion approval is invalid. Re-review and re-exclude it, or restore it with 'policy restore'.`, - ); - this.name = "BaselineExclusionDriftError"; - this.key = key; - this.reason = reason; - } -} - -/** - * Raised when durable state attempts to exclude an entry that the supported - * sandbox contract requires. This check belongs in the replay path as well as - * the CLI so imported or manually edited registry state cannot bypass it. - */ -export class ProtectedBaselineExclusionError extends Error { - readonly key: string; - - constructor(key: string) { - super(`Baseline entry '${key}' is required and cannot be excluded.`); - this.name = "ProtectedBaselineExclusionError"; - this.key = key; - } -} - -/** Raised when durable approval belongs to a different agent baseline contract. */ -export class BaselineExclusionSourceError extends Error { - readonly key: string; - readonly approvedAgent: string; - readonly currentAgent: string; - - constructor(key: string, approvedAgent: string, currentAgent: string) { - super( - `Baseline exclusion '${key}' was approved for agent '${approvedAgent}', not '${currentAgent}'. Restore or re-approve it for the current agent before rebuilding.`, - ); - this.name = "BaselineExclusionSourceError"; - this.key = key; - this.approvedAgent = approvedAgent; - this.currentAgent = currentAgent; - } -} - -/** - * Apply recorded exclusions to a base policy for create/rebuild. Verifies each - * approval's digest against the current baseline and drops the matching entry; - * throws `BaselineExclusionDriftError` on any missing or changed entry so a - * release that redefined the egress forces re-review. - */ -export function applyBaselineExclusions( - basePolicyContent: string, - requests: readonly BaselineExclusionRequest[], - currentAgent: string, -): { content: string; excludedKeys: string[] } { - let content = basePolicyContent; - const excludedKeys: string[] = []; - for (const request of requests) { - if (request.agent !== currentAgent) { - throw new BaselineExclusionSourceError(request.key, request.agent, currentAgent); - } - if (isProtectedBaselineExclusionKey(request.key)) { - throw new ProtectedBaselineExclusionError(request.key); - } - const resolution = resolveBaselineExclusion(content, request); - if (resolution.drift) throw new BaselineExclusionDriftError(request.key, resolution.drift); - const removal = removeBaselineEntryFromPolicy(content, request.key); - if (!removal.removed) throw new BaselineExclusionDriftError(request.key, "missing"); - content = removal.policy; - excludedKeys.push(request.key); - } - return { content, excludedKeys }; -} - function scalarText(value: PolicyValue): string { if (value === null || value === undefined) return ""; if (typeof value === "object") return ""; diff --git a/src/lib/policy/commands.test.ts b/src/lib/policy/commands.test.ts index f3e3519a1eb..9ba66a5a22d 100644 --- a/src/lib/policy/commands.test.ts +++ b/src/lib/policy/commands.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; -const { +import { buildGlobalPolicyGetFullJsonArgs, buildGlobalPolicyListArgs, buildPolicyGetArgs, @@ -11,7 +11,7 @@ const { buildPolicyGetFullCommand, buildPolicyGetFullJsonArgs, buildPolicySetCommand, -} = require("./commands.js") as typeof import("./commands.js"); +} from "./commands"; describe("OpenShell policy command builders", () => { it("keeps every sandbox policy operation in an argv-only command", () => { @@ -32,7 +32,7 @@ describe("OpenShell policy command builders", () => { ]); }); - it("pins policy authority reads to the selected gateway", () => { + it("pins policy requirements reads to the selected gateway", () => { expect(buildPolicyGetArgs("alpha", "nemoclaw")).toEqual([ "policy", "get", diff --git a/src/lib/policy/context-builder.ts b/src/lib/policy/context-builder.ts index 668ab4caaf4..1b214286274 100644 --- a/src/lib/policy/context-builder.ts +++ b/src/lib/policy/context-builder.ts @@ -1,23 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import * as registry from "../state/registry"; import { - getBaselineExclusionRuntimeStatus, getGatewayPresets, getPresetEndpoints, - inspectPolicyMutationAuthority, - isAgentBasePreset, listCustomPresets, listPresets, loadPresetForSandbox, } from "."; -import { - BASELINE_EXCLUSION_SUPPORT_IMPACT, - type BaselineExclusionRuntimeStatus, -} from "./baseline-exclusion"; import { hostStemsFromEndpoints } from "./host-redaction"; -import { getTier } from "./tiers"; interface PresetInfo { file: string; @@ -25,12 +16,7 @@ interface PresetInfo { description: string; } -export type PolicyContextPresetVerification = - | "verified" - | "registry-only" - | "gateway-only" - | "agent-base" - | "gateway-unavailable"; +export type PolicyContextPresetVerification = "verified" | "gateway-unavailable"; export interface PolicyContextPreset { name: string; @@ -45,9 +31,8 @@ export interface PolicyContextPreset { source: "builtin" | "custom"; /** * Source-of-truth state for whether this preset is enforced by the - * OpenShell gateway. `verified`, `gateway-only`, and `agent-base` are - * based on a live gateway probe. `registry-only` and `gateway-unavailable` - * indicate that the agent cannot trust this preset as enforced policy. + * OpenShell gateway. `verified` is based on a live gateway probe; + * `gateway-unavailable` means enforcement was not observed. */ verification: PolicyContextPresetVerification; } @@ -73,58 +58,17 @@ export interface PolicyContextApprovalPath { documentation: string; } -export type PolicyContextExclusionStatus = - | BaselineExclusionRuntimeStatus - | "pending-exclude-repair" - | "pending-restore-repair"; - -export interface PolicyContextExclusion { - key: string; - digest: string; - acknowledgedAt: string | null; - /** - * `excluded` — the current baseline still defines this key at the reviewed - * digest and the observed live policy omits it. - * `content-changed` — a release redefined this key's content since - * approval; rebuild fails closed and requires re-approval before the - * exclusion applies again. - * `no-longer-in-baseline` — the current baseline no longer defines this - * key; the exclusion record is inert until restored or replaced. - * `live-policy-*` — live enforcement is unreadable or still contains the - * excluded key, so registry intent must not be treated as enforcement. - * `agent-changed` — the approval belongs to a different agent baseline. - * `pending-*-repair` — the live mutation was interrupted; its durable - * journal blocks rebuild until the exact policy command reconciles it. - */ - status: PolicyContextExclusionStatus; - supportImpact: string; -} - export interface PolicyContext { sandboxName: string; tier: PolicyContextTier | null; activePresets: PolicyContextPreset[]; knownUnappliedPresets: PolicyContextPreset[]; - baselineExclusions: PolicyContextExclusion[]; approvalPath: PolicyContextApprovalPath; supportBoundaries: PolicyContextSupportBoundary[]; generatedAt: string; } const POLICY_DOC_URL = "docs/network-policy/customize-network-policy.mdx"; -const EXTERNAL_POLICY_ADD_PATH = - "Ask the external policy authority to add or replace the policy entries required by ``."; -const EXTERNAL_POLICY_REMOVE_PATH = - "Ask the external policy authority to remove the policy entries supplied by ``."; -const EXTERNAL_POLICY_RESTORE_PATH = - "Ask the external policy authority to restore baseline policy entry ``."; -const EXTERNAL_POLICY_EXCLUDE_PATH = - "Run `nemoclaw policy exclude --dry-run`, then ask the external policy authority to remove baseline policy entry ``."; -const UNKNOWN_POLICY_MUTATION_PATH = - "NemoClaw cannot change this policy because policy ownership is not verified. Recreate the sandbox before requesting a NemoClaw policy change."; - -type PolicyContextAuthority = "nemoclaw-managed" | "externally-managed" | "owner-unknown"; - function hostStemsFromContent(content: string | null | undefined): { public: string[]; redactedCount: number; @@ -152,16 +96,13 @@ function presetEntry( function resolveVerification( presetName: string, - appliedLocally: boolean, gatewayPresets: ReadonlyArray | null, ): PolicyContextPresetVerification { if (gatewayPresets === null) { - return appliedLocally ? "gateway-unavailable" : "gateway-unavailable"; + return "gateway-unavailable"; } const enforced = gatewayPresets.includes(presetName); - if (appliedLocally && enforced) return "verified"; - if (appliedLocally && !enforced) return "registry-only"; - if (!appliedLocally && enforced) return "gateway-only"; + if (enforced) return "verified"; return "gateway-unavailable"; } @@ -170,15 +111,8 @@ function resolveVerification( * allow-listed integrations) and the unapplied set (suggested as * remediation targets). Two invariants: * - * - Custom presets always land in `active`. They live in the registry's - * `customPolicies` array, which has no "applied vs unapplied" notion; - * their presence in the registry is itself the activation signal. They - * are still annotated with the gateway-verification state so an agent - * can tell whether the gateway actually enforces them. - * - A built-in preset that the gateway enforces but the registry does - * not list (`gateway-only`) is reported as active so the agent does - * not misclassify allowed hosts as blocked. The advisory `verification` - * field discloses the drift. + * Custom and built-in preset presence is derived from the current OpenShell + * policy. NemoClaw does not maintain a second activation list. */ function partitionPresets( sandboxName: string, @@ -187,174 +121,60 @@ function partitionPresets( ): { active: PolicyContextPreset[]; unapplied: PolicyContextPreset[] } { const builtin = listPresets(); const customInfo = listCustomPresets(sandboxName); - const customByName = new Map( - registry.getCustomPolicies(sandboxName).map((entry) => [entry.name, entry.content]), - ); const active: PolicyContextPreset[] = []; const unapplied: PolicyContextPreset[] = []; for (const info of builtin) { const isApplied = applied.has(info.name); - let verification = resolveVerification(info.name, isApplied, gatewayPresets); - // A gateway-only catalog preset whose name collides with an agent - // base-policy addition (e.g. Hermes `pypi`) is enforced by the agent's own - // base policy, not registry drift. Classify it as `agent-base` so it is not - // reported as drift and does not steer operators toward an unnecessary - // `policy add`. - // The apply path already prefers the agent-specific policy content, but it - // would record the preset as operator-applied (#9079). Sibling base additions - // with no catalog entry are never iterated here, so this only corrects the - // incidental name-collision case. - if ( - !isApplied && - verification === "gateway-only" && - isAgentBasePreset(sandboxName, info.name) - ) { - verification = "agent-base"; - } - const enforcedNotApplied = - !isApplied && (verification === "gateway-only" || verification === "agent-base"); + const verification = resolveVerification(info.name, gatewayPresets); const entry = presetEntry( info, "builtin", loadPresetForSandbox(sandboxName, info.name), verification, ); - if (isApplied || enforcedNotApplied) { + if (isApplied) { active.push(entry); } else { unapplied.push(entry); } } for (const info of customInfo) { - const isApplied = applied.has(info.name); - const verification = resolveVerification(info.name, isApplied, gatewayPresets); - active.push(presetEntry(info, "custom", customByName.get(info.name) ?? null, verification)); + const verification = resolveVerification(info.name, gatewayPresets); + active.push( + presetEntry(info, "custom", loadPresetForSandbox(sandboxName, info.name), verification), + ); } return { active, unapplied }; } -function buildBaselineExclusions( - sandboxName: string, - transition: registry.BaselineExclusionTransition | null, -): PolicyContextExclusion[] { - const pendingKey = transition?.exclusion.key ?? null; - const byKey = new Map( - registry.getBaselineExclusions(sandboxName).map((exclusion) => { - const status: PolicyContextExclusionStatus = - exclusion.key === pendingKey - ? transition?.operation === "exclude" - ? "pending-exclude-repair" - : "pending-restore-repair" - : getBaselineExclusionRuntimeStatus(sandboxName, exclusion); - return [ - exclusion.key, - { - key: exclusion.key, - digest: exclusion.digest, - acknowledgedAt: exclusion.acknowledgedAt ?? null, - status, - supportImpact: BASELINE_EXCLUSION_SUPPORT_IMPACT, - }, - ] as const; - }), - ); - if (transition) { - const exclusion = transition.exclusion; - byKey.set(exclusion.key, { - key: exclusion.key, - digest: exclusion.digest, - acknowledgedAt: exclusion.acknowledgedAt ?? null, - status: - transition.operation === "exclude" ? "pending-exclude-repair" : "pending-restore-repair", - supportImpact: BASELINE_EXCLUSION_SUPPORT_IMPACT, - }); - } - return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); -} - -function buildApprovalPath( - sandboxName: string, - authority: PolicyContextAuthority, -): PolicyContextApprovalPath { - const externallyManaged = authority === "externally-managed"; - const ownerUnknown = authority === "owner-unknown"; +function buildApprovalPath(sandboxName: string): PolicyContextApprovalPath { return { inspect: `nemoclaw ${sandboxName} policy list`, - add: ownerUnknown - ? UNKNOWN_POLICY_MUTATION_PATH - : externallyManaged - ? EXTERNAL_POLICY_ADD_PATH - : `nemoclaw ${sandboxName} policy add `, - remove: ownerUnknown - ? UNKNOWN_POLICY_MUTATION_PATH - : externallyManaged - ? EXTERNAL_POLICY_REMOVE_PATH - : `nemoclaw ${sandboxName} policy remove `, - excludeBaseline: ownerUnknown - ? UNKNOWN_POLICY_MUTATION_PATH - : externallyManaged - ? EXTERNAL_POLICY_EXCLUDE_PATH.replace("", sandboxName) - : `nemoclaw ${sandboxName} policy exclude --dry-run`, - restoreBaseline: ownerUnknown - ? UNKNOWN_POLICY_MUTATION_PATH - : externallyManaged - ? EXTERNAL_POLICY_RESTORE_PATH - : `nemoclaw ${sandboxName} policy restore `, + add: `nemoclaw ${sandboxName} policy add `, + remove: `nemoclaw ${sandboxName} policy remove `, + excludeBaseline: `nemoclaw ${sandboxName} policy exclude --dry-run`, + restoreBaseline: `nemoclaw ${sandboxName} policy restore `, documentation: POLICY_DOC_URL, }; } -function buildSupportBoundaries( - tier: PolicyContextTier | null, - authority: PolicyContextAuthority, -): PolicyContextSupportBoundary[] { - const externallyManaged = authority === "externally-managed"; - const ownerUnknown = authority === "owner-unknown"; +function buildSupportBoundaries(): PolicyContextSupportBoundary[] { return [ { - capability: "policy requirement selection and verification", + capability: "policy convenience commands", owner: "nemoclaw", - note: ownerUnknown - ? "NemoClaw cannot verify the component that owns the live policy" - : externallyManaged - ? "NemoClaw selects preset and baseline requirements and verifies the live policy" - : tier - ? `tier: ${tier.label}` - : "no tier recorded", + note: "NemoClaw reads and changes the current OpenShell policy on operator request", }, { capability: "host allowlist enforcement", owner: "openshell", note: "policy is enforced by the OpenShell gateway", }, - ...(ownerUnknown - ? [ - { - capability: "policy and Shields mutation", - owner: "unknown" as const, - note: "NemoClaw refuses policy and Shields changes until it verifies policy ownership", - }, - ] - : externallyManaged - ? [ - { - capability: "policy mutation", - owner: "external" as const, - note: "the external policy authority applies each required add, remove, restore, or baseline exclusion to the live policy", - }, - { - capability: "Shields state and configuration lock", - owner: "nemoclaw" as const, - note: "NemoClaw retains Shields state and locks configuration after it verifies restrictive policy", - }, - ] - : [ - { - capability: "Shields transition", - owner: "nemoclaw" as const, - note: "Shields up locks down mutable configuration", - }, - ]), + { + capability: "Shields transition", + owner: "nemoclaw", + note: "Shields up locks mutable configuration and reverts its temporary policy delta", + }, { capability: "credential storage", owner: "nemoclaw", @@ -368,19 +188,6 @@ function buildSupportBoundaries( ]; } -function inspectContextPolicyAuthority( - sandboxName: string, - options: BuildPolicyContextOptions, -): PolicyContextAuthority { - if (options.skipGatewayProbe) return "owner-unknown"; - try { - return inspectPolicyMutationAuthority(sandboxName, "build the sandbox policy context") - .authority; - } catch { - return "owner-unknown"; - } -} - export interface BuildPolicyContextOptions { /** * Inject a gateway-preset list (or null when the gateway is unreachable) @@ -415,17 +222,9 @@ function probeGatewayPresets( * * Source-of-truth model: * - * - Active preset names are derived from the registry entry - * (`sandbox.policies` + `sandbox.customPolicies`). The OpenShell gateway - * is the actual enforcement boundary, so each preset is also annotated - * with a {@link PolicyContextPresetVerification} state: `verified` when - * the gateway snapshot agrees, `registry-only` when the gateway does - * not enforce the preset (drift), `gateway-only` when the gateway - * enforces something the registry does not list, `agent-base` when the - * gateway enforces an agent base-policy preset, or `gateway-unavailable` - * when no probe is available. Callers that require a trusted "is this host - * actually allowed?" answer must accept `verified`, `gateway-only`, and - * `agent-base` as gateway-confirmed states. + * - Active preset names are derived only from the current OpenShell policy. + * `verified` represents live enforcement; + * `gateway-unavailable` means no current observation was available. * * - Host stems are extracted by {@link hostStemsFromContent}, which * redacts RFC1918, loopback, link-local, metadata, and internal-DNS @@ -447,33 +246,17 @@ export function buildPolicyContext( sandboxName: string, options: BuildPolicyContextOptions = {}, ): PolicyContext { - const sandbox = registry.getSandbox(sandboxName); - const authority = inspectContextPolicyAuthority(sandboxName, options); - const tierName = authority === "nemoclaw-managed" ? (sandbox?.policyTier ?? null) : null; - const tierDef = tierName ? getTier(tierName) : null; - const tier: PolicyContextTier | null = tierDef - ? { name: tierDef.name, label: tierDef.label, description: tierDef.description } - : null; - - const appliedNames = new Set(sandbox?.policies ?? []); - for (const entry of sandbox?.customPolicies ?? []) { - appliedNames.add(entry.name); - } - const gatewayPresets = probeGatewayPresets(sandboxName, options); + const appliedNames = new Set(gatewayPresets ?? []); const { active, unapplied } = partitionPresets(sandboxName, appliedNames, gatewayPresets); return { sandboxName, - tier, + tier: null, activePresets: active.sort((a, b) => a.name.localeCompare(b.name)), knownUnappliedPresets: unapplied.sort((a, b) => a.name.localeCompare(b.name)), - baselineExclusions: buildBaselineExclusions( - sandboxName, - sandbox?.baselineExclusionTransition ?? null, - ), - approvalPath: buildApprovalPath(sandboxName, authority), - supportBoundaries: buildSupportBoundaries(tier, authority), + approvalPath: buildApprovalPath(sandboxName), + supportBoundaries: buildSupportBoundaries(), generatedAt: new Date().toISOString(), }; } @@ -482,56 +265,11 @@ function verificationTag(verification: PolicyContextPresetVerification): string switch (verification) { case "verified": return "verified"; - case "registry-only": - return "registry-only (gateway does not enforce)"; - case "gateway-only": - return "gateway-only (not in local registry)"; - case "agent-base": - return "agent-base (enforced by the agent's base policy; not user-applied; `policy add` is unnecessary)"; case "gateway-unavailable": return "gateway-unavailable"; } } -function exclusionStatusTag(status: PolicyContextExclusionStatus): string { - switch (status) { - case "excluded": - return "excluded"; - case "content-changed": - return "content-changed (release redefined this entry; rebuild requires re-approval)"; - case "no-longer-in-baseline": - return "no-longer-in-baseline (record is inert)"; - case "baseline-unreadable": - return "baseline-unreadable (current release scope could not be inspected)"; - case "agent-changed": - return "agent-changed (approval belongs to a different agent baseline)"; - case "live-policy-unreadable": - return "live-policy-unreadable (enforcement could not be inspected)"; - case "live-policy-mismatch": - return "live-policy-mismatch (excluded key remains in the live policy)"; - case "pending-exclude-repair": - return "repair-required (exclude transaction was interrupted; rebuild blocked)"; - case "pending-restore-repair": - return "repair-required (restore transaction was interrupted; rebuild blocked)"; - } -} - -function formatExclusionLine( - exclusion: PolicyContextExclusion, - sandboxName: string, - restoreAction: string, -): string { - const restore = restoreAction.startsWith("nemoclaw ") - ? `\`nemoclaw ${sandboxName} policy restore ${exclusion.key}\`` - : restoreAction; - return [ - `- \`${exclusion.key}\` — status: ${exclusionStatusTag(exclusion.status)}`, - ` acknowledged: ${exclusion.acknowledgedAt ?? "(unknown)"}`, - ` impact: ${exclusion.supportImpact}`, - ` restore: ${restore}`, - ].join("\n"); -} - function formatPresetLine(preset: PolicyContextPreset): string { const categories = preset.allowedHostCategories.length ? preset.allowedHostCategories.join(", ") @@ -589,15 +327,6 @@ export function renderPolicyContextMarkdown(ctx: PolicyContext): string { } } lines.push(""); - lines.push("## Baseline exclusions"); - if (ctx.baselineExclusions.length === 0) { - lines.push("- none"); - } else { - for (const exclusion of ctx.baselineExclusions) { - lines.push(formatExclusionLine(exclusion, ctx.sandboxName, ctx.approvalPath.restoreBaseline)); - } - } - lines.push(""); lines.push("## Approval and remediation"); lines.push(`- inspect: \`${ctx.approvalPath.inspect}\``); lines.push(`- add a preset: ${formatApprovalAction(ctx.approvalPath.add)}`); @@ -626,7 +355,7 @@ export function renderPolicyContextMarkdown(ctx: PolicyContext): string { ); lines.push(""); lines.push( - "Preset status reflects registry and gateway agreement. `verified`, `gateway-only`, and `agent-base` mean the gateway confirms enforcement. `agent-base` identifies a preset from the agent's base policy rather than a user-applied preset. It is active, not drift, and does not need `policy add`. Treat `registry-only` and `gateway-unavailable` as advisory because the gateway has not confirmed the listed hosts.", + "Preset status is derived from the current OpenShell policy. `verified` means the gateway confirms enforcement; `gateway-unavailable` is advisory because enforcement could not be observed.", ); lines.push(""); lines.push(`Generated at ${ctx.generatedAt}.`); diff --git a/src/lib/policy/context.test.ts b/src/lib/policy/context.test.ts index 1c9d988931c..1c2a9dd308c 100644 --- a/src/lib/policy/context.test.ts +++ b/src/lib/policy/context.test.ts @@ -1,714 +1,109 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; - -vi.mock("../state/registry", () => ({ - getSandbox: vi.fn(), - getCustomPolicies: vi.fn(() => []), - getBaselineExclusions: vi.fn(() => []), -})); +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock(".", () => ({ - getBaselineExclusionRuntimeStatus: vi.fn(() => "excluded"), + getGatewayPresets: vi.fn(), getPresetEndpoints: vi.fn(), - getGatewayPresets: vi.fn(() => null), - inspectPolicyMutationAuthority: vi.fn(() => ({ authority: "nemoclaw-managed" })), - isAgentBasePreset: vi.fn(() => false), listCustomPresets: vi.fn(), listPresets: vi.fn(), - loadPreset: vi.fn(), loadPresetForSandbox: vi.fn(), })); -vi.mock("./tiers", () => ({ - getTier: vi.fn(), -})); - -import * as registry from "../state/registry"; import * as policies from "."; import { buildPolicyContext, renderPolicyContextMarkdown } from "./context"; -import { getTier } from "./tiers"; - -const SANDBOX = "alpha"; - -const SLACK_PRESET_YAML = `preset: - name: slack - description: Slack API access -network_policies: - slack: - endpoints: - - host: slack.com - - host: api.slack.com -`; - -const GITHUB_PRESET_YAML = `preset: - name: github - description: GitHub API access -network_policies: - github: - endpoints: - - host: api.github.com -`; -const PRESET_CONTENT: Record = { - slack: SLACK_PRESET_YAML, - github: GITHUB_PRESET_YAML, -}; - -function mockBuiltinPresets() { +beforeEach(() => { vi.mocked(policies.listPresets).mockReturnValue([ - { file: "slack.yaml", name: "slack", description: "Slack API access" }, - { file: "github.yaml", name: "github", description: "GitHub API access" }, + { file: "npm.yaml", name: "npm", description: "npm registry" }, + { file: "github.yaml", name: "github", description: "GitHub API" }, ]); 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, + (_sandbox, name) => + `preset:\n name: ${name}\nnetwork_policies:\n ${name}:\n endpoints:\n - host: ${name}.example.com\n`, ); - vi.mocked(policies.getPresetEndpoints).mockImplementation((content: string) => { - const hosts: string[] = []; - const regex = /host:\s*(\S+)/g; - let match: RegExpExecArray | null = null; - while ((match = regex.exec(content)) !== null) { - hosts.push(match[1]); - } - return hosts; - }); -} - -function stubRegistry( - entry: Partial<{ - policies: string[]; - policyTier: string; - policyAuthority: "nemoclaw-managed" | "externally-managed"; - }>, -) { - vi.mocked(registry.getSandbox).mockReturnValue({ - name: SANDBOX, - policies: entry.policies, - policyTier: entry.policyTier ?? null, - policyAuthority: entry.policyAuthority, - } as ReturnType); -} - -function stubTier() { - vi.mocked(getTier).mockReturnValue({ - name: "balanced", - label: "Balanced", - description: "Full dev tooling and web search", - presets: [], - }); -} - -function resetMocks() { - vi.mocked(registry.getSandbox).mockReset(); - vi.mocked(registry.getCustomPolicies).mockReset(); - vi.mocked(registry.getCustomPolicies).mockReturnValue([]); - 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); - vi.mocked(policies.inspectPolicyMutationAuthority).mockReset(); - vi.mocked(policies.inspectPolicyMutationAuthority).mockReturnValue({ - authority: "nemoclaw-managed", - } as ReturnType); - vi.mocked(policies.isAgentBasePreset).mockReset(); - vi.mocked(policies.isAgentBasePreset).mockReturnValue(false); - vi.mocked(registry.getBaselineExclusions).mockReset(); - vi.mocked(registry.getBaselineExclusions).mockReturnValue([]); - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReset(); - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("excluded"); - vi.mocked(getTier).mockReset(); -} - -describe("buildPolicyContext", () => { - it("partitions active presets from known unapplied presets and resolves the tier", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const ctx = buildPolicyContext(SANDBOX); - - expect(ctx.sandboxName).toBe(SANDBOX); - expect(ctx.tier).toEqual({ - name: "balanced", - label: "Balanced", - description: "Full dev tooling and web search", - }); - expect(ctx.activePresets.map((p) => p.name)).toEqual(["slack"]); - expect(ctx.activePresets[0].allowedHostCategories).toEqual(["api.slack.com", "slack.com"]); - expect(ctx.activePresets[0].source).toBe("builtin"); - expect(ctx.activePresets[0].redactedHostCount).toBe(0); - expect(ctx.activePresets[0].verification).toBe("gateway-unavailable"); - expect(ctx.knownUnappliedPresets.map((p) => p.name)).toEqual(["github"]); - expect(ctx.approvalPath.inspect).toBe(`nemoclaw ${SANDBOX} policy list`); - expect(ctx.approvalPath.add).toBe(`nemoclaw ${SANDBOX} policy add `); - expect(ctx.approvalPath.remove).toBe(`nemoclaw ${SANDBOX} policy remove `); - expect(ctx.approvalPath.excludeBaseline).toBe( - `nemoclaw ${SANDBOX} policy exclude --dry-run`, - ); - expect(ctx.approvalPath.restoreBaseline).toBe(`nemoclaw ${SANDBOX} policy restore `); - expect(ctx.supportBoundaries.some((b) => b.capability === "host allowlist enforcement")).toBe( - true, - ); - expect(ctx.supportBoundaries).toContainEqual({ - capability: "Shields transition", - owner: "nemoclaw", - note: "Shields up locks down mutable configuration", - }); - }); - - it("attributes externally managed policy changes only to the external authority (#9833)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ - policies: [], - policyTier: "balanced", - policyAuthority: "externally-managed", - }); - vi.mocked(policies.inspectPolicyMutationAuthority).mockReturnValue({ - authority: "externally-managed", - } as ReturnType); - vi.mocked(registry.getBaselineExclusions).mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]); - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("excluded"); - - const context = buildPolicyContext(SANDBOX); - const markdown = renderPolicyContextMarkdown(context); - - expect(context.tier).toBeNull(); - expect(getTier).not.toHaveBeenCalled(); - expect(context.supportBoundaries).toContainEqual({ - capability: "policy requirement selection and verification", - owner: "nemoclaw", - note: "NemoClaw selects preset and baseline requirements and verifies the live policy", - }); - expect(context.supportBoundaries).toContainEqual({ - capability: "policy mutation", - owner: "external", - note: "the external policy authority applies each required add, remove, restore, or baseline exclusion to the live policy", - }); - expect(context.supportBoundaries).toContainEqual({ - capability: "Shields state and configuration lock", - owner: "nemoclaw", - note: "NemoClaw retains Shields state and locks configuration after it verifies restrictive policy", - }); - expect(context.approvalPath).toEqual({ - inspect: "nemoclaw alpha policy list", - add: "Ask the external policy authority to add or replace the policy entries required by ``.", - remove: - "Ask the external policy authority to remove the policy entries supplied by ``.", - excludeBaseline: - "Run `nemoclaw alpha policy exclude --dry-run`, then ask the external policy authority to remove baseline policy entry ``.", - restoreBaseline: - "Ask the external policy authority to restore baseline policy entry ``.", - documentation: "docs/network-policy/customize-network-policy.mdx", - }); - expect(markdown).toContain( - "restore: Ask the external policy authority to restore baseline policy entry ``.", - ); - expect(markdown).toContain( - "- restore a baseline entry: Ask the external policy authority to restore baseline policy entry ``.", - ); - expect(markdown).toContain( - "- policy mutation (owner: external) — the external policy authority applies each required add, remove, restore, or baseline exclusion to the live policy", - ); - expect(markdown).toContain( - "- Shields state and configuration lock (owner: nemoclaw) — NemoClaw retains Shields state and locks configuration after it verifies restrictive policy", - ); - expect(markdown).toContain( - "- preview a baseline exclusion: Run `nemoclaw alpha policy exclude --dry-run`, then ask the external policy authority to remove baseline policy entry ``.", - ); - expect(markdown).not.toContain( - "- preview a baseline exclusion: `Run `nemoclaw alpha policy exclude --dry-run`", - ); - expect(markdown).not.toMatch(/nemoclaw alpha policy (?:add|remove|restore)(?:\s|`)/u); - }); - - it("does not advertise mutation commands when policy ownership is unknown (#9833)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - vi.mocked(policies.inspectPolicyMutationAuthority).mockImplementation(() => { - throw new Error("receipt unavailable"); - }); - - const context = buildPolicyContext(SANDBOX); - - expect(context.tier).toBeNull(); - expect(context.approvalPath.add).not.toContain("policy add"); - expect(context.approvalPath.remove).not.toContain("policy remove"); - expect(context.approvalPath.excludeBaseline).not.toContain("policy exclude"); - expect(context.approvalPath.restoreBaseline).not.toContain("policy restore"); - expect(context.supportBoundaries).toContainEqual({ - capability: "policy and Shields mutation", - owner: "unknown", - note: "NemoClaw refuses policy and Shields changes until it verifies policy ownership", - }); - }); - - it("does not inspect live policy authority when gateway probes are disabled (#9833)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const context = buildPolicyContext(SANDBOX, { skipGatewayProbe: true }); - - expect(policies.inspectPolicyMutationAuthority).not.toHaveBeenCalled(); - expect(policies.getGatewayPresets).not.toHaveBeenCalled(); - expect(context.tier).toBeNull(); - expect(context.approvalPath.add).not.toContain("policy add"); - expect(context.supportBoundaries).toContainEqual({ - capability: "policy and Shields mutation", - owner: "unknown", - note: "NemoClaw refuses policy and Shields changes until it verifies policy ownership", - }); - }); - - it("marks active presets as `verified` when the gateway agrees and `registry-only` when it disagrees", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack", "github"], policyTier: "balanced" }); - - const ctx = buildPolicyContext(SANDBOX, { gatewayPresets: ["slack"] }); - - const slack = ctx.activePresets.find((p) => p.name === "slack"); - const github = ctx.activePresets.find((p) => p.name === "github"); - expect(slack?.verification).toBe("verified"); - expect(github?.verification).toBe("registry-only"); - }); - - it("surfaces presets enforced by the gateway but missing from the registry as `gateway-only` actives", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: [], policyTier: "balanced" }); - - const ctx = buildPolicyContext(SANDBOX, { gatewayPresets: ["github"] }); - - const github = ctx.activePresets.find((p) => p.name === "github"); - expect(github?.verification).toBe("gateway-only"); - expect(ctx.knownUnappliedPresets.some((p) => p.name === "github")).toBe(false); - }); - - it("classifies a gateway-enforced agent-base preset as `agent-base`, not gateway-only drift (#9079)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - // Neither preset applied by the user; both enforced by the gateway. Only - // `github` is an agent base-policy addition. The other must stay - // gateway-only so genuine drift is still reported. - stubRegistry({ policies: [], policyTier: "restricted" }); - vi.mocked(policies.isAgentBasePreset).mockImplementation( - (_sandboxName: string, name: string) => name === "github", - ); - - const ctx = buildPolicyContext(SANDBOX, { gatewayPresets: ["slack", "github"] }); - - const github = ctx.activePresets.find((p) => p.name === "github"); - const slack = ctx.activePresets.find((p) => p.name === "slack"); - expect(github?.verification).toBe("agent-base"); - expect(slack?.verification).toBe("gateway-only"); - // Agent-base preset is active (enforced), never suggested for `policy add`. - expect(ctx.knownUnappliedPresets.some((p) => p.name === "github")).toBe(false); - }); - - it("does not reclassify an applied preset as agent-base even when the agent defines it (#9079)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - // `github` is both user-applied and an agent base addition; the user - // intent (applied + enforced) must remain `verified`, not `agent-base`. - stubRegistry({ policies: ["github"], policyTier: "restricted" }); - vi.mocked(policies.isAgentBasePreset).mockReturnValue(true); - - const ctx = buildPolicyContext(SANDBOX, { gatewayPresets: ["github"] }); - - const github = ctx.activePresets.find((p) => p.name === "github"); - expect(github?.verification).toBe("verified"); + vi.mocked(policies.getPresetEndpoints).mockImplementation((content) => { + const match = /host:\s*(\S+)/u.exec(content); + return match ? [match[1]] : []; }); +}); - it("redacts internal hostnames and IP ranges from allowedHostCategories and counts the drop", () => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(policies.listCustomPresets).mockReturnValue([ - { file: "internal.yaml", name: "internal", description: "internal API" }, +describe("policy context", () => { + it("derives active and inactive presets only from the live OpenShell view", () => { + vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); + const context = buildPolicyContext("alpha"); + expect(context.activePresets).toEqual([ + expect.objectContaining({ name: "npm", verification: "verified" }), ]); - vi.mocked(registry.getCustomPolicies).mockReturnValue([ - { - name: "internal", - content: - "preset:\n name: internal\nnetwork_policies:\n internal:\n endpoints:\n" + - " - host: 10.0.0.1\n" + - " - host: 192.168.1.10\n" + - " - host: 172.20.0.1\n" + - " - host: 127.0.0.1\n" + - " - host: 169.254.169.254\n" + - " - host: localhost\n" + - " - host: api.internal\n" + - " - host: gateway.local\n" + - " - host: shared.corp\n" + - " - host: public.example.com\n", - }, + expect(context.knownUnappliedPresets).toEqual([ + expect.objectContaining({ name: "github", verification: "gateway-unavailable" }), ]); - vi.mocked(getTier).mockReturnValue(null); - stubRegistry({ policies: ["internal"], policyTier: undefined }); - - const ctx = buildPolicyContext(SANDBOX); - const internal = ctx.activePresets.find((p) => p.name === "internal"); - expect(internal?.allowedHostCategories).toEqual(["public.example.com"]); - expect(internal?.redactedHostCount).toBeGreaterThanOrEqual(9); + expect(context).not.toHaveProperty("baselineExclusions"); }); - it("handles a sandbox with no recorded tier and no applied presets", () => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(getTier).mockReturnValue(null); - stubRegistry({ policies: [], policyTier: undefined }); - - const ctx = buildPolicyContext(SANDBOX); - - expect(ctx.tier).toBeNull(); - expect(ctx.activePresets).toEqual([]); - expect(ctx.knownUnappliedPresets.map((p) => p.name)).toEqual(["github", "slack"]); + it("reports an unavailable gateway without falling back to registry state", () => { + vi.mocked(policies.getGatewayPresets).mockReturnValue(null); + const context = buildPolicyContext("alpha"); + expect(context.activePresets).toEqual([]); + expect(context.knownUnappliedPresets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "npm", verification: "gateway-unavailable" }), + ]), + ); }); - it("includes custom presets as active and tags their source", () => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(policies.listCustomPresets).mockReturnValue([ - { file: "internal.yaml", name: "internal", description: "custom preset" }, - ]); - vi.mocked(policies.loadPreset).mockImplementation( - (name: string) => PRESET_CONTENT[name] ?? null, + it("describes OpenShell as the enforcement owner", () => { + vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); + expect(renderPolicyContextMarkdown(buildPolicyContext("alpha"))).toContain( + "policy is enforced by the OpenShell gateway", ); - vi.mocked(getTier).mockReturnValue(null); - stubRegistry({ policies: ["internal"], policyTier: undefined }); - - const ctx = buildPolicyContext(SANDBOX); - const internal = ctx.activePresets.find((p) => p.name === "internal"); - expect(internal?.source).toBe("custom"); }); - it("derives custom preset host stems from the registry-stored content, not loadPreset", () => { - resetMocks(); - mockBuiltinPresets(); + it("includes live custom presets as active without a registry activation list", () => { vi.mocked(policies.listCustomPresets).mockReturnValue([ - { file: "internal.yaml", name: "internal", description: "internal API" }, + { file: "corp.yaml", name: "corp", description: "Corporate API" }, ]); - vi.mocked(registry.getCustomPolicies).mockReturnValue([ - { - name: "internal", - content: - "preset:\n name: internal\nnetwork_policies:\n internal:\n endpoints:\n - host: internal.example.com\n", - }, - ]); - vi.mocked(getTier).mockReturnValue(null); - stubRegistry({ policies: ["internal"], policyTier: undefined }); - - const ctx = buildPolicyContext(SANDBOX); - const internal = ctx.activePresets.find((p) => p.name === "internal"); - expect(internal?.allowedHostCategories).toEqual(["internal.example.com"]); - }); - - it("reports baseline exclusions with a status per current digest agreement (#7194)", () => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(getTier).mockReturnValue(null); - stubRegistry({ policies: [], policyTier: undefined }); - vi.mocked(registry.getBaselineExclusions).mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - { - version: 1, - agent: "openclaw", - key: "changed_entry", - digest: "digest-stale", - acknowledgedAt: "2026-07-18T00:00:00.000Z", - }, - { - version: 1, - agent: "openclaw", - key: "dropped_entry", - digest: "digest-2", - acknowledgedAt: "2026-07-17T00:00:00.000Z", - }, - ]); - const statuses: Record = { - nous_research: "excluded", - changed_entry: "content-changed", - dropped_entry: "no-longer-in-baseline", - }; - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockImplementation( - (_sandbox, entry) => statuses[entry.key], + vi.mocked(policies.getGatewayPresets).mockReturnValue(["corp"]); + const context = buildPolicyContext("alpha"); + expect(context.activePresets).toContainEqual( + expect.objectContaining({ name: "corp", source: "custom", verification: "verified" }), ); - - const ctx = buildPolicyContext(SANDBOX); - - expect(ctx.baselineExclusions).toEqual([ - { - key: "changed_entry", - digest: "digest-stale", - acknowledgedAt: "2026-07-18T00:00:00.000Z", - status: "content-changed", - supportImpact: - "Excluded egress leaves dependent agent features unsupported for this sandbox.", - }, - { - key: "dropped_entry", - digest: "digest-2", - acknowledgedAt: "2026-07-17T00:00:00.000Z", - status: "no-longer-in-baseline", - supportImpact: - "Excluded egress leaves dependent agent features unsupported for this sandbox.", - }, - { - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - status: "excluded", - supportImpact: - "Excluded egress leaves dependent agent features unsupported for this sandbox.", - }, - ]); }); - it("surfaces an interrupted live-policy transaction as repair-required (#7178)", () => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(getTier).mockReturnValue(null); - vi.mocked(registry.getSandbox).mockReturnValue({ - name: SANDBOX, - policies: [], - baselineExclusionTransition: { - id: "tx-1", - operation: "exclude", - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }, - }); - - const ctx = buildPolicyContext(SANDBOX); - - expect(ctx.baselineExclusions).toEqual([ - expect.objectContaining({ - key: "nous_research", - status: "pending-exclude-repair", - }), + it("redacts private and internal hosts while retaining public host stems", () => { + vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); + vi.mocked(policies.getPresetEndpoints).mockReturnValue([ + "registry.npmjs.org", + "10.20.30.40", + "metadata.google.internal", ]); - const markdown = renderPolicyContextMarkdown(ctx); - expect(markdown).toContain("repair-required"); - expect(markdown).toContain("exclude transaction was interrupted"); - expect(markdown).toContain("rebuild blocked"); - }); - - it.each(["exclude", "restore"] as const)( - "surfaces pending %s repair even when the release baseline is unreadable (#7194)", - (operation) => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(getTier).mockReturnValue(null); - vi.mocked(registry.getBaselineExclusions).mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "another_entry", - digest: "c".repeat(64), - acknowledgedAt: "2026-07-18T00:00:00.000Z", - }, - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]); - vi.mocked(registry.getSandbox).mockReturnValue({ - name: SANDBOX, - policies: [], - baselineExclusionTransition: { - id: "00000000-0000-4000-8000-000000000001", - operation, - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - targetLiveDigest: operation === "restore" ? "b".repeat(64) : null, - startedAt: "2026-07-19T00:00:00.000Z", - }, - }); - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("baseline-unreadable"); - - const ctx = buildPolicyContext(SANDBOX); - - expect(ctx.baselineExclusions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "another_entry", status: "baseline-unreadable" }), - expect.objectContaining({ - key: "nous_research", - status: operation === "exclude" ? "pending-exclude-repair" : "pending-restore-repair", - }), - ]), - ); - expect(ctx.baselineExclusions).toHaveLength(2); - expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledOnce(); - expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledWith( - SANDBOX, - expect.objectContaining({ key: "another_entry" }), - ); - }, - ); -}); - -describe("renderPolicyContextMarkdown", () => { - it("emits a redacted markdown summary with only host stems and no raw policy YAML", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX)); - - expect(md).toContain(`# Sandbox policy context: ${SANDBOX}`); - expect(md).toContain("## Active presets"); - expect(md).toContain("`slack`"); - expect(md).toContain("api.slack.com"); - expect(md).toContain("## Approval and remediation"); - expect(md).toContain("## Failure classification"); - expect(md).not.toMatch(/enforcement:|websocket_credential_rewrite|binaries:/); - expect(md).not.toMatch(/network_policies:/); - }); - - it.each([ - { - status: "verified", - applied: ["slack"], - gatewayPresets: ["slack"], - agentBase: false, - }, - { - status: "registry-only", - applied: ["slack"], - gatewayPresets: [], - agentBase: false, - }, - { - status: "gateway-only", - applied: [], - gatewayPresets: ["slack"], - agentBase: false, - }, - { - status: "agent-base", - applied: [], - gatewayPresets: ["slack"], - agentBase: true, - }, - { - status: "gateway-unavailable", - applied: ["slack"], - gatewayPresets: null, - agentBase: false, - }, - ])( - "renders the $status verification status (#9079)", - ({ status, applied, gatewayPresets, agentBase }) => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: applied, policyTier: "balanced" }); - vi.mocked(policies.isAgentBasePreset).mockReturnValue(agentBase); - - const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX, { gatewayPresets })); - - expect(md).toContain(`status: ${status}`); - }, - ); - - it("states which verification statuses confirm gateway enforcement (#9079)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const md = renderPolicyContextMarkdown( - buildPolicyContext(SANDBOX, { gatewayPresets: ["slack"] }), - ); - - expect(md).toContain( - "`verified`, `gateway-only`, and `agent-base` mean the gateway confirms enforcement", - ); - expect(md).toContain( - "Treat `registry-only` and `gateway-unavailable` as advisory because the gateway has not confirmed the listed hosts", + const [active] = buildPolicyContext("alpha").activePresets; + expect(active.allowedHostCategories).toEqual(["registry.npmjs.org"]); + expect(active.redactedHostCount).toBe(2); + }); + + it("skips the live OpenShell probe when requested", () => { + const gatewayProbe = vi.mocked(policies.getGatewayPresets); + const context = buildPolicyContext("alpha", { skipGatewayProbe: true }); + expect(gatewayProbe).not.toHaveBeenCalled(); + expect(context.activePresets).toEqual([]); + expect(context.knownUnappliedPresets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "npm", verification: "gateway-unavailable" }), + ]), ); }); - it("discloses excluded baseline entries and their support impact (#7194)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - vi.mocked(registry.getBaselineExclusions).mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "digest-1", - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]); - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("excluded"); - - const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX)); - - expect(md).toContain("## Baseline exclusions"); - expect(md).toContain("`nous_research`"); - expect(md).toContain("status: excluded"); - expect(md).toContain("Excluded egress leaves dependent agent features unsupported"); - expect(md).toContain("policy restore nous_research"); - }); - - it("reports no baseline exclusions when none are recorded", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX)); - - expect(md).toContain("## Baseline exclusions"); - expect(md).toMatch(/## Baseline exclusions\n- none/); + it("renders a redacted summary and preserves the convenience command surface", () => { + vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); + vi.mocked(policies.getPresetEndpoints).mockReturnValue(["registry.npmjs.org", "10.20.30.40"]); + const markdown = renderPolicyContextMarkdown(buildPolicyContext("alpha")); + expect(markdown).toContain("npmjs.org"); + expect(markdown).not.toContain("10.20.30.40"); + expect(markdown).toContain("nemoclaw alpha policy add "); + expect(markdown).toContain("nemoclaw alpha policy remove "); + expect(markdown).not.toContain("network_policies:"); }); }); diff --git a/src/lib/policy/custom-preset-ownership.test.ts b/src/lib/policy/custom-preset-ownership.test.ts deleted file mode 100644 index f4c73879a4a..00000000000 --- a/src/lib/policy/custom-preset-ownership.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -// 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 { getCustomPolicies, runCapture } = vi.hoisted(() => ({ - getCustomPolicies: vi.fn(), - runCapture: vi.fn(), -})); - -vi.mock("../runner", async (importOriginal) => ({ - ...(await importOriginal()), - runCapture, -})); - -vi.mock("../state/registry", () => ({ getCustomPolicies })); - -import { customPresetOwnsNetworkPolicyKey } from "./index"; - -const MATCHING_POLICY = `version: 1 -network_policies: - shared-otel: - endpoints: - - host: collector.internal - port: 4318 -`; - -const DRIFTED_PRESET = `preset: - name: drifted -network_policies: - shared-otel: - endpoints: - - host: stale.internal - port: 4318 -`; - -const MATCHING_PRESET = `preset: - name: matching -network_policies: - shared-otel: - endpoints: - - host: collector.internal - port: 4318 -`; - -const MATCHING_KEY_WITH_DRIFTED_SIBLING = `preset: - name: matching-with-sibling -network_policies: - shared-otel: - endpoints: - - host: collector.internal - port: 4318 - unrelated-policy: - endpoints: - - host: stale.internal - port: 443 -`; - -describe("customPresetOwnsNetworkPolicyKey", () => { - beforeEach(() => { - getCustomPolicies.mockReset(); - runCapture.mockReset(); - }); - - it("compares two matching-key candidates against one live policy read (#3915)", () => { - getCustomPolicies.mockReturnValue([ - { name: "drifted", content: DRIFTED_PRESET }, - { name: "matching", content: MATCHING_PRESET }, - ]); - runCapture.mockReturnValue(MATCHING_POLICY); - - expect(customPresetOwnsNetworkPolicyKey("my-sandbox", "shared-otel")).toBe(true); - expect(runCapture).toHaveBeenCalledOnce(); - expect(runCapture.mock.calls[0]?.[0]?.slice(1)).toEqual([ - "policy", - "get", - "--base", - "my-sandbox", - ]); - }); - - it("compares only the requested key when another key in the custom preset drifts", () => { - getCustomPolicies.mockReturnValue([ - { name: "matching-with-sibling", content: MATCHING_KEY_WITH_DRIFTED_SIBLING }, - ]); - runCapture.mockReturnValue( - `${MATCHING_POLICY} unrelated-policy:\n endpoints:\n - host: live.internal\n port: 443\n`, - ); - - expect(customPresetOwnsNetworkPolicyKey("my-sandbox", "shared-otel")).toBe(true); - expect(runCapture).toHaveBeenCalledOnce(); - }); - - it("does not read live policy when no custom candidate owns the key (#3915)", () => { - getCustomPolicies.mockReturnValue([ - { - name: "unrelated", - content: "network_policies:\n unrelated:\n endpoints: []\n", - }, - ]); - - expect(customPresetOwnsNetworkPolicyKey("my-sandbox", "shared-otel")).toBe(false); - expect(runCapture).not.toHaveBeenCalled(); - }); - - it("aborts before mutation when registered custom ownership content is malformed", () => { - getCustomPolicies.mockReturnValue([ - { - name: "corrupt-otel", - content: "network_policies:\n shared-otel: [unterminated", - }, - ]); - - expect(() => customPresetOwnsNetworkPolicyKey("my-sandbox", "shared-otel")).toThrow( - /Could not inspect registered custom policy ownership.*refusing to reconcile/, - ); - expect(runCapture).not.toHaveBeenCalled(); - }); - - it("aborts reconciliation when the single live policy read fails (#3915)", () => { - getCustomPolicies.mockReturnValue([{ name: "matching", content: MATCHING_PRESET }]); - runCapture.mockImplementation(() => { - throw new Error("gateway unavailable"); - }); - - expect(() => customPresetOwnsNetworkPolicyKey("my-sandbox", "shared-otel")).toThrow( - /Could not read live policy ownership.*refusing to reconcile/, - ); - expect(runCapture).toHaveBeenCalledOnce(); - }); - - it("aborts reconciliation when the live policy response is indeterminate", () => { - getCustomPolicies.mockReturnValue([{ name: "matching", content: MATCHING_PRESET }]); - runCapture.mockReturnValue("version: [invalid"); - - expect(() => customPresetOwnsNetworkPolicyKey("my-sandbox", "shared-otel")).toThrow( - /Could not determine live policy ownership.*refusing to reconcile/, - ); - expect(runCapture).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/lib/policy/failure-classifier.test.ts b/src/lib/policy/failure-classifier.test.ts index b2e53dda495..3177622ce6c 100644 --- a/src/lib/policy/failure-classifier.test.ts +++ b/src/lib/policy/failure-classifier.test.ts @@ -1,382 +1,143 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; -import { managedSandboxEntry } from "../../../test/helpers/managed-policy-receipt-fixture"; +import { describe, expect, it } from "vitest"; -vi.mock("../state/registry", () => ({ - getSandbox: vi.fn(), - getCustomPolicies: vi.fn(() => []), - getBaselineExclusions: vi.fn(() => []), -})); - -vi.mock(".", () => ({ - getPresetEndpoints: vi.fn(), - getGatewayPresets: vi.fn(() => null), - getSandboxBaselineEntryDigest: vi.fn(() => null), - inspectPolicyMutationAuthority: vi.fn(() => ({ authority: "nemoclaw-managed" })), - isAgentBasePreset: vi.fn(() => false), - listCustomPresets: vi.fn(), - listPresets: vi.fn(), - loadPreset: vi.fn(), - loadPresetForSandbox: vi.fn(), -})); - -vi.mock("./tiers", () => ({ - getTier: vi.fn(), -})); - -import * as registry from "../state/registry"; -import * as policies from "."; +import type { PolicyContext } from "./context-builder"; import { classifyAccessFailure } from "./failure-classifier"; -import { getTier } from "./tiers"; - -const SANDBOX = "alpha"; - -const SLACK_PRESET_YAML = `preset: - name: slack - description: Slack API access -network_policies: - slack: - endpoints: - - host: slack.com - - host: api.slack.com -`; - -const GITHUB_PRESET_YAML = `preset: - name: github - description: GitHub API access -network_policies: - github: - endpoints: - - host: api.github.com -`; - -const PRESET_CONTENT: Record = { - slack: SLACK_PRESET_YAML, - github: GITHUB_PRESET_YAML, -}; - -function mockBuiltinPresets() { - vi.mocked(policies.listPresets).mockReturnValue([ - { file: "slack.yaml", name: "slack", description: "Slack API access" }, - { file: "github.yaml", name: "github", description: "GitHub API access" }, - ]); - 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; - let match: RegExpExecArray | null = null; - while ((match = regex.exec(content)) !== null) { - hosts.push(match[1]); - } - return hosts; - }); -} -function stubRegistry(entry: Partial<{ policies: string[]; policyTier: string }>) { - vi.mocked(registry.getSandbox).mockReturnValue({ - ...managedSandboxEntry(SANDBOX), - policies: entry.policies, - policyTier: entry.policyTier ?? null, - } as ReturnType); +function context(verification: "verified" | "gateway-unavailable" = "verified"): PolicyContext { + return { + sandboxName: "alpha", + tier: null, + activePresets: [ + { + name: "slack", + description: "Slack", + allowedHostCategories: ["slack.com"], + redactedHostCount: 0, + source: "builtin", + verification, + }, + ], + knownUnappliedPresets: [], + approvalPath: { + inspect: "nemoclaw alpha policy list", + add: "nemoclaw alpha policy add ", + remove: "nemoclaw alpha policy remove ", + excludeBaseline: "nemoclaw alpha policy exclude --dry-run", + restoreBaseline: "nemoclaw alpha policy restore ", + documentation: "docs/network-policy/customize-network-policy.mdx", + }, + supportBoundaries: [], + generatedAt: "2026-08-27T00:00:00.000Z", + }; } -function stubTier() { - vi.mocked(getTier).mockReturnValue({ - name: "balanced", - label: "Balanced", - description: "Full dev tooling and web search", - presets: [], - }); -} - -function resetMocks() { - vi.mocked(registry.getSandbox).mockReset(); - vi.mocked(registry.getCustomPolicies).mockReset(); - vi.mocked(registry.getCustomPolicies).mockReturnValue([]); - 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); - vi.mocked(policies.inspectPolicyMutationAuthority).mockReset(); - vi.mocked(policies.inspectPolicyMutationAuthority).mockReturnValue({ - authority: "nemoclaw-managed", - } as ReturnType); - vi.mocked(policies.isAgentBasePreset).mockReset(); - vi.mocked(policies.isAgentBasePreset).mockReturnValue(false); - vi.mocked(getTier).mockReset(); -} - -describe("classifyAccessFailure", () => { - it("returns high-confidence missing-approval when the host is on a gateway-verified preset and credentials return 401", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { status: 401 }, - gatewayPresets: ["slack"], - }); - - expect(result.kind).toBe("missing-approval"); - expect(result.matchedPreset).toBe("slack"); - expect(result.confidence).toBe("high"); - }); - - it("downgrades a matched 401 to low confidence when the preset is registry-only (gateway disagrees)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { status: 401 }, - gatewayPresets: [], - }); - - expect(result.kind).toBe("missing-approval"); - expect(result.matchedPreset).toBe("slack"); - expect(result.confidence).toBe("low"); - expect(result.reason).toContain("drift"); - expect(result.nextStep).toContain("policy list"); - }); - - it("downgrades a matched 401 to low confidence when the gateway is unavailable", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { status: 401 }, - gatewayPresets: null, - }); - - expect(result.confidence).toBe("low"); - expect(result.reason).toContain("registry-derived"); - }); - - it("returns low-confidence missing-approval when an active host returns 403 (ambiguous policy denial vs auth)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { status: 403 }, - }); - - expect(result.kind).toBe("missing-approval"); - expect(result.matchedPreset).toBe("slack"); - expect(result.confidence).toBe("low"); - expect(result.nextStep).toContain("openshell policy get"); - }); - - it("returns blocked-by-policy when a known preset declares the host but is not applied", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.github.com", - error: { code: "EHOSTUNREACH" }, - }); - - expect(result.kind).toBe("blocked-by-policy"); - expect(result.matchedPreset).toBe("github"); - expect(result.nextStep).toContain("policy add github"); - }); - - it("returns blocked-by-policy when no preset declares the host and the request is refused", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "example.unknown", - error: { status: 403 }, - }); - - expect(result.kind).toBe("blocked-by-policy"); - expect(result.matchedPreset).toBeUndefined(); - }); - - it("falls back to unknown when the failure is not a policy or approval signal", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { code: "ECONNRESET", status: 500 }, - }); - - expect(result.kind).toBe("unknown"); - expect(result.matchedPreset).toBe("slack"); - }); - - it("matches a subdomain against the preset host stem", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "edge.api.slack.com", - error: { status: 403 }, - }); - - expect(result.matchedPreset).toBe("slack"); - }); - - it("returns unsupported when the caller declares the capability unavailable", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - capability: { supported: false, reason: "messaging not enabled for this agent" }, - }); - - expect(result.kind).toBe("unsupported"); - expect(result.reason).toContain("messaging not enabled for this agent"); - expect(result.nextStep).toContain("Surface the limitation"); - }); - - it("returns unsupported even when the host matches an applied preset", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { status: 403 }, - capability: { supported: false }, - }); - - expect(result.kind).toBe("unsupported"); - }); - - it("classifies a verified-preset host hitting a network-block code as upstream-unknown, not blocked-by-policy", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { code: "EHOSTUNREACH" }, - gatewayPresets: ["slack"], - }); - - // Gateway confirms enforcement → the block code cannot mean the - // gateway is denying the host; it must be upstream. - expect(result.kind).toBe("unknown"); - expect(result.matchedPreset).toBe("slack"); - expect(result.confidence).toBe("high"); - expect(result.reason).toContain("EHOSTUNREACH"); - expect(result.reason).toContain("upstream"); - }); - - it("treats an agent-base preset host hitting a network-block code as high-confidence upstream-unknown (#9079)", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - // `slack` is enforced by the gateway via the agent base policy, not - // user-applied. Verification resolves to `agent-base`, which is enforced, - // so a block code is upstream (like `verified`), not a policy denial. - stubRegistry({ policies: [], policyTier: "restricted" }); - vi.mocked(policies.isAgentBasePreset).mockReturnValue(true); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { code: "EHOSTUNREACH" }, - gatewayPresets: ["slack"], - }); - - expect(result.kind).toBe("unknown"); - expect(result.matchedPreset).toBe("slack"); - expect(result.confidence).toBe("high"); - }); - - it.each([ - "EHOSTUNREACH", - "ENETUNREACH", - "ENOTFOUND", - "ECONNREFUSED", - "ETIMEDOUT", - "EAI_AGAIN", - ])("classifies a registry-only active-preset host hitting %s as blocked-by-policy with low confidence", (code) => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { code }, - gatewayPresets: [], - }); - - // Registry says allow but the gateway has not been confirmed to - // enforce the preset (drift). The network-block code is the - // strongest signal that the gateway is in fact blocking egress; - // surface as blocked-by-policy so the agent reaches for - // policy-list / policy-add rather than chasing an upstream issue. - expect(result.kind).toBe("blocked-by-policy"); - expect(result.matchedPreset).toBe("slack"); - expect(result.confidence).toBe("low"); - expect(result.reason).toContain(code); - expect(result.nextStep).toContain("policy list"); - }); - - it("classifies a gateway-unavailable active-preset host hitting EHOSTUNREACH as blocked-by-policy advisory", () => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: ["slack"], policyTier: "balanced" }); - - const result = classifyAccessFailure({ - sandboxName: SANDBOX, - host: "api.slack.com", - error: { code: "EHOSTUNREACH" }, - gatewayPresets: null, - }); - - expect(result.kind).toBe("blocked-by-policy"); - expect(result.confidence).toBe("low"); - expect(result.reason).toContain("registry-derived"); +describe("access failure classification", () => { + it("uses live verified preset state for a high-confidence missing approval", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "api.slack.com", + error: { status: 401 }, + context: context(), + }), + ).toEqual(expect.objectContaining({ kind: "missing-approval", confidence: "high" })); + }); + + it("keeps an unavailable live observation advisory", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "api.slack.com", + error: { code: "ETIMEDOUT" }, + context: context("gateway-unavailable"), + }), + ).toEqual(expect.objectContaining({ kind: "blocked-by-policy", confidence: "low" })); + }); + + it("classifies an undeclared host as blocked by policy", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "unknown.example", + error: { code: "ENETUNREACH" }, + context: context(), + }).kind, + ).toBe("blocked-by-policy"); + }); + + it("reports unsupported capabilities before network heuristics", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "api.slack.com", + capability: { supported: false, reason: "not available" }, + context: context(), + }).kind, + ).toBe("unsupported"); + }); + + it("treats a network error on a live verified preset as upstream-unknown", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "api.slack.com", + error: { code: "EHOSTUNREACH" }, + context: context(), + }), + ).toEqual(expect.objectContaining({ kind: "unknown", confidence: "high" })); + }); + + it("keeps HTTP 403 on an active host ambiguous", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "api.slack.com", + error: { status: 403 }, + context: context(), + }), + ).toEqual(expect.objectContaining({ kind: "missing-approval", confidence: "low" })); + }); + + it("reports a known but live-unapplied host as blocked by policy", () => { + const ctx = context(); + ctx.activePresets = []; + ctx.knownUnappliedPresets = [ + { + name: "github", + description: "GitHub", + allowedHostCategories: ["github.com"], + redactedHostCount: 0, + source: "builtin", + verification: "gateway-unavailable", + }, + ]; + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "api.github.com", + error: { status: 403 }, + context: ctx, + }), + ).toEqual( + expect.objectContaining({ + kind: "blocked-by-policy", + matchedPreset: "github", + confidence: "high", + }), + ); + }); + + it("falls back to unknown when there is no policy or approval signal", () => { + expect( + classifyAccessFailure({ + sandboxName: "alpha", + host: "unknown.example", + error: { message: "application failed" }, + context: context(), + }).kind, + ).toBe("unknown"); }); }); diff --git a/src/lib/policy/failure-classifier.ts b/src/lib/policy/failure-classifier.ts index e8e26efa8ef..49d8d8428ac 100644 --- a/src/lib/policy/failure-classifier.ts +++ b/src/lib/policy/failure-classifier.ts @@ -30,7 +30,7 @@ export interface AccessFailureInput { * Optional caller-provided context. When omitted, the classifier builds * its own context for `sandboxName`. Callers that already hold a * context (the explain command, the agent runtime) should pass it to - * avoid a second registry/gateway probe and to keep the verification + * avoid a second gateway probe and to keep the verification * status consistent with what the caller already rendered. */ context?: PolicyContext; @@ -53,8 +53,8 @@ export interface AccessFailureClassification { * `high` when the underlying signal unambiguously maps to {@link kind} * AND the matched preset (if any) was confirmed by a live gateway * probe. `low` when either the signal is ambiguous (notably HTTP 403 - * on an allowed host) or the matched preset is `registry-only` / - * `gateway-unavailable`, in which case the agent must treat the + * on an allowed host) or the matched preset is `gateway-unavailable`, + * in which case the agent must treat the * verdict as advisory. */ confidence: "high" | "low"; @@ -93,19 +93,12 @@ function findMatchingPreset( } function isVerified(preset: PolicyContextPreset): boolean { - return ( - preset.verification === "verified" || - preset.verification === "gateway-only" || - preset.verification === "agent-base" - ); + return preset.verification === "verified"; } function verificationNote(preset: PolicyContextPreset): string { if (isVerified(preset)) return ""; - if (preset.verification === "registry-only") { - return " The local registry lists this preset but the OpenShell gateway is not enforcing it (drift); treat this verdict as advisory."; - } - return " The OpenShell gateway is unreachable, so this verdict is registry-derived and advisory."; + return " The OpenShell gateway is unreachable, so current enforcement could not be verified."; } function resolveContext(input: AccessFailureInput): PolicyContext { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index fb7447514d9..97071148ba6 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -3,7 +3,6 @@ // // Policy preset management — list, load, merge, and apply presets. -import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -13,17 +12,11 @@ import YAML from "yaml"; // Namespace access keeps resolveOpenshell spyable in focused policy tests. import { - assertExternalPolicyRequirements, - assertRecordedPolicyAuthority, captureSandboxBasePolicy, - inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, - isExternalPolicyAuthorityRefusalError as isExternalAuthorityRefusalError, - isPolicyAuthorityRefusalError as isAuthorityRefusalError, - PolicyAuthorityRefusalError, - type SandboxPolicyAuthority, - type SandboxPolicyAuthorityInspection, -} from "../adapters/openshell/policy-authority"; + inspectSandboxPolicy, + PolicyObservationError, + type SandboxPolicyInspection, +} from "../adapters/openshell/policy-state"; import * as openshellResolveModule from "../adapters/openshell/resolve"; import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; @@ -37,17 +30,15 @@ import { loadMessagingChannelPolicyPreset, materializeMessagingPolicySandboxName, } from "../messaging/channels"; -import { resolveGatewayPortFromName, resolveSandboxGatewayName } from "../onboard/gateway-binding"; +import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; import { ROOT, run, runCapture } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import { redact } from "../security/redact"; import * as registry from "../state/registry"; -import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; import { digestBaselineEntry, - evaluateBaselineExclusionRuntimeStatus, getBaselineEntry, mergeBaselineEntryIntoPolicy, removeBaselineEntryFromPolicy, @@ -59,10 +50,7 @@ import { } from "./commands"; import { inspectGatewayPresetNames, inspectPresetContentGatewayState } from "./gateway-state"; import { - assertNemoClawPolicyCreationReceiptMatches, - type NemoClawPolicyCreationReceipt, parseOpenShellPolicy, - parseNemoClawPolicyCreationReceipt, stripProviderComposedPolicies, withoutProviderComposedPolicies, } from "./merge"; @@ -88,7 +76,6 @@ import { type ExternalPolicyPreset, isTrustedPrivatePolicyPinCapability, prepareTrustedPrivatePolicyPresets, - replayTrustedPrivatePolicyPinCapability, type TrustedPrivatePolicyPinCapability, } from "./trusted-private-endpoints"; @@ -122,7 +109,6 @@ type PresetListOptions = { type MergePresetNamesOptions = { agent?: string | null; sandboxName?: string; - excludedBaselineKeys?: readonly string[]; credentialBoundMessagingChannels?: readonly string[]; }; @@ -287,34 +273,48 @@ function parsePresetPolicyKeys(presetContent: string | null | undefined): string return Object.keys(parseNetworkPolicies(`network_policies:\n${presetEntries}`) || {}); } -/** Preserve invalid registered content as indeterminate for ownership decisions. */ -function parsePresetPolicyKeysForOwnership(presetContent: string): string[] | null { - const networkPolicies = parseNetworkPolicies(presetContent); - return networkPolicies === null ? null : Object.keys(networkPolicies); +const CUSTOM_POLICY_KEY_PREFIX = "nemoclaw_custom__"; + +function customPolicyKey(presetName: string, key: string): string { + return `${CUSTOM_POLICY_KEY_PREFIX}${presetName}__${key}`; } -function findExcludedBaselineKeyForPolicy( - sandboxName: string, - presetContent: string, -): string | null { - const excludedKeys = new Set( - registry.getBaselineExclusions(sandboxName).map((exclusion) => exclusion.key), - ); - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (transition?.operation === "exclude") excludedKeys.add(transition.exclusion.key); - return parsePresetPolicyKeys(presetContent).find((key) => excludedKeys.has(key)) ?? null; +function parseCustomPolicyKey(key: string): { presetName: string; originalKey: string } | null { + if (!key.startsWith(CUSTOM_POLICY_KEY_PREFIX)) return null; + const separator = key.indexOf("__", CUSTOM_POLICY_KEY_PREFIX.length); + if (separator < 0) return null; + const presetName = key.slice(CUSTOM_POLICY_KEY_PREFIX.length, separator); + const originalKey = key.slice(separator + 2); + return presetName && originalKey ? { presetName, originalKey } : null; } -function findAppliedPolicyOwnerForKey(sandboxName: string, key: string): string | null { - const sandbox = registry.getSandbox(sandboxName); - for (const presetName of sandbox?.policies ?? []) { - const content = loadPresetForSandbox(sandboxName, presetName); - if (content && parsePresetPolicyKeys(content).includes(key)) return presetName; +function namespaceCustomPresetContent(presetName: string, content: string): string { + const parsed = YAML.parse(content); + if (!isPolicyDocument(parsed) || !isPolicyObject(parsed.network_policies)) { + throw new Error(`Preset '${presetName}' has invalid or missing network_policies.`); } - for (const custom of registry.getCustomPolicies(sandboxName)) { - if (parsePresetPolicyKeys(custom.content).includes(key)) return custom.name; - } - return null; + parsed.network_policies = Object.fromEntries( + Object.entries(parsed.network_policies).map(([key, value]) => [ + customPolicyKey(presetName, key), + value, + ]), + ); + return YAML.stringify(parsed); +} + +function liveCustomPresetContent(sandboxName: string, presetName: string): string | null { + const current = readCurrentSandboxPolicy(sandboxName); + if (!current) return null; + const parsed = YAML.parse(current); + if (!isPolicyDocument(parsed) || !isPolicyObject(parsed.network_policies)) return null; + const entries = Object.fromEntries( + Object.entries(parsed.network_policies).filter(([key]) => { + return parseCustomPolicyKey(key)?.presetName === presetName; + }), + ); + return Object.keys(entries).length > 0 + ? YAML.stringify({ preset: { name: presetName }, network_policies: entries }) + : null; } const AGENT_PRESET_KEY_ALIASES: Readonly> = @@ -381,22 +381,6 @@ function loadAgentPresetContent( } } -/** - * True when `presetName` is supplied by the sandbox agent's base policy - * (`agents//policy-additions.yaml`) rather than only by the built-in - * catalog. Used to distinguish an agent base-policy entry that the gateway - * enforces (for example, Hermes `pypi`) from genuine registry drift. - * `policy explain` can then avoid an unnecessary `policy add`, which would - * record the preset as operator-applied even though the apply path already - * prefers the agent-specific policy content (#9079). Best-effort: any load - * failure resolves to `false`, preserving the pre-existing gateway-only - * classification. - */ -function isAgentBasePreset(sandboxName: string, presetName: string): boolean { - const builtinPresetContent = loadCentralPreset(presetName); - return loadAgentPresetContent(sandboxName, presetName, builtinPresetContent ?? "") !== null; -} - function loadPresetForSandbox( sandboxName: string, presetName: string, @@ -429,7 +413,7 @@ function loadPresetForSandbox( if (isMessagingChannelPolicyPreset(presetName)) return null; const builtinPresetContent = loadCentralPreset(presetName); - if (!builtinPresetContent) return null; + if (!builtinPresetContent) return liveCustomPresetContent(sandboxName, presetName); const resolvedPresetContent = loadAgentPresetContent(sandboxName, presetName, builtinPresetContent) || builtinPresetContent; return presetName === "outlook" && @@ -658,26 +642,18 @@ interface PolicySetSubmission { readonly status: number | null; } -function policyAuthorityError(error: unknown): string { +function policyObservationError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export interface PolicyMutationAuthority { - readonly authority: SandboxPolicyAuthority; - readonly authorityRecordedNow: boolean; +export interface PolicyMutationContext { readonly gatewayName: string; - readonly inspection: SandboxPolicyAuthorityInspection; - readonly policyCreationReceipt?: NemoClawPolicyCreationReceipt | null; + readonly inspection: SandboxPolicyInspection; } -export const isPolicyAuthorityRefusalError = isAuthorityRefusalError; -export const isExternalPolicyAuthorityRefusalError = isExternalAuthorityRefusalError; - interface LivePolicyBoundary { - readonly sandbox: NonNullable>; readonly gatewayName: string; - readonly gatewayPort: number; - readonly inspection: SandboxPolicyAuthorityInspection; + readonly inspection: SandboxPolicyInspection; } function inspectLivePolicyBoundary( @@ -689,25 +665,25 @@ function inspectLivePolicyBoundary( try { sandbox = registry.getSandbox(sandboxName); } catch { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: sandbox '${sandboxName}' policy authority is unavailable.`, + throw new PolicyObservationError( + `Refusing to ${operation}: sandbox '${sandboxName}' policy state is unavailable.`, ); } if (!sandbox) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: sandbox '${sandboxName}' policy authority is unavailable.`, + throw new PolicyObservationError( + `Refusing to ${operation}: sandbox '${sandboxName}' policy state is unavailable.`, ); } let recordedGatewayName: string | null; try { recordedGatewayName = resolveSandboxGatewayName(sandbox); } catch { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Refusing to ${operation}: the recorded sandbox gateway is unavailable or invalid.`, ); } if (recordedGatewayName && requestedGatewayName && requestedGatewayName !== recordedGatewayName) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Refusing to ${operation}: the requested gateway does not match the recorded sandbox gateway.`, ); } @@ -716,270 +692,47 @@ function inspectLivePolicyBoundary( gatewayName = recordedGatewayName ?? requestedGatewayName ?? resolveSandboxGatewayName(undefined); } catch { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Refusing to ${operation}: the sandbox gateway is unavailable or invalid.`, ); } - const inspection = inspectSandboxPolicyAuthority({ + const inspection = inspectSandboxPolicy({ sandboxName, gatewayName, }); - const gatewayPort = resolveGatewayPortFromName(gatewayName); - if (gatewayPort === null) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the sandbox gateway is unavailable or invalid.`, - ); - } - return { sandbox, gatewayName, gatewayPort, inspection }; -} - -function managedReceiptSandboxBoundary( - live: LivePolicyBoundary, - sandboxName: string, - operation: string, -): { - readonly liveIdentityFingerprint: string; - readonly receipt: NemoClawPolicyCreationReceipt; -} { - const { sandbox, gatewayName, gatewayPort } = live; - if ( - sandbox.policyAuthority !== "nemoclaw-managed" || - sandbox.gatewayName !== gatewayName || - sandbox.gatewayPort !== gatewayPort || - typeof sandbox.lifecycleGeneration !== "string" || - typeof sandbox.lifecycleLiveIdentityFingerprint !== "string" - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: NemoClaw policy ownership is unavailable or incomplete.`, - "owner-unknown", - ); - } - - let liveIdentityFingerprint: string; - try { - liveIdentityFingerprint = inspectOpenShellSandboxIdentityFingerprint({ - sandboxName, - gatewayName, - }); - } catch { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the live sandbox identity could not be verified.`, - "owner-unknown", - ); - } - if (liveIdentityFingerprint !== sandbox.lifecycleLiveIdentityFingerprint) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the live sandbox identity does not match the registered lifecycle.`, - "owner-unknown", - ); - } - - let confirmedSandbox: ReturnType; - try { - confirmedSandbox = registry.getSandbox(sandboxName); - } catch { - confirmedSandbox = null; - } - if ( - !confirmedSandbox || - confirmedSandbox.pendingRouteReservation === true || - confirmedSandbox.policyAuthority !== sandbox.policyAuthority || - confirmedSandbox.gatewayName !== sandbox.gatewayName || - confirmedSandbox.gatewayPort !== sandbox.gatewayPort || - confirmedSandbox.lifecycleGeneration !== sandbox.lifecycleGeneration || - confirmedSandbox.lifecycleLiveIdentityFingerprint !== - sandbox.lifecycleLiveIdentityFingerprint || - !isDeepStrictEqual(confirmedSandbox.policyCreationReceipt, sandbox.policyCreationReceipt) - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the recorded policy creation receipt changed during live verification.`, - "owner-unknown", - ); - } - - let receipt: NemoClawPolicyCreationReceipt; - try { - receipt = parseNemoClawPolicyCreationReceipt(sandbox.policyCreationReceipt); - } catch { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the NemoClaw policy creation receipt is unavailable or invalid.`, - "owner-unknown", - ); - } - if ( - receipt.gatewayName !== gatewayName || - receipt.gatewayPort !== gatewayPort || - receipt.sandboxName !== sandboxName || - receipt.lifecycleGeneration !== sandbox.lifecycleGeneration || - receipt.sandboxIdentityFingerprint !== liveIdentityFingerprint - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the NemoClaw policy creation receipt does not match the live sandbox identity.`, - "owner-unknown", - ); - } - return { liveIdentityFingerprint, receipt }; -} - -function managedReceiptBoundary( - live: LivePolicyBoundary, - sandboxName: string, - operation: string, -): { - readonly liveIdentityFingerprint: string; - readonly receipt: NemoClawPolicyCreationReceipt; -} { - const boundary = managedReceiptSandboxBoundary(live, sandboxName, operation); - try { - assertNemoClawPolicyCreationReceiptMatches(boundary.receipt, { - origin: "sandbox-create", - gatewayName: live.gatewayName, - gatewayPort: live.gatewayPort, - sandboxName, - lifecycleGeneration: live.sandbox.lifecycleGeneration as string, - sandboxIdentityFingerprint: boundary.liveIdentityFingerprint, - policyHash: live.inspection.policyIdentity.hash, - policyVersion: live.inspection.policyIdentity.activeVersion, - }); - } catch { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the NemoClaw policy creation receipt does not match the live sandbox policy.`, - "owner-unknown", - ); - } - return boundary; -} - -function resolvePolicyAuthority( - live: LivePolicyBoundary, - sandboxName: string, - operation: string, -): PolicyMutationAuthority { - if (live.inspection.authority === "externally-managed") { - if (live.sandbox.policyAuthority === "nemoclaw-managed") { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the live policy is externally managed but the sandbox registry records NemoClaw ownership. The external policy authority must perform the requested policy mutation.`, - "externally-managed", - ); - } - return { - authority: "externally-managed", - authorityRecordedNow: false, - gatewayName: live.gatewayName, - inspection: live.inspection, - policyCreationReceipt: null, - }; - } - - if (live.inspection.authority !== "owner-unknown") { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the live sandbox policy authority is invalid.`, - "owner-unknown", - ); - } - const managed = { - live, - receipt: managedReceiptBoundary(live, sandboxName, operation).receipt, - }; - return { - authority: "nemoclaw-managed", - authorityRecordedNow: false, - gatewayName: managed.live.gatewayName, - inspection: { ...managed.live.inspection, authority: "nemoclaw-managed" }, - policyCreationReceipt: managed.receipt, - }; -} - -/** Read live authority for Shields recovery without changing its durable owner. */ -export function inspectPolicyRecoveryAuthority( - sandboxName: string, - operation: string, - requestedGatewayName?: string, -): PolicyMutationAuthority { - return resolvePolicyAuthority( - inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName), - sandboxName, - operation, - ); + return { gatewayName, inspection }; } -/** Inspect the live policy owner without creating an ownership claim from observation. */ -export function inspectPolicyMutationAuthority( +/** Read the current live policy through the sandbox's recorded gateway binding. */ +export function inspectPolicyMutationContext( sandboxName: string, operation: string, requestedGatewayName?: string, - _requireRecordedAuthority = false, -): PolicyMutationAuthority { - return resolvePolicyAuthority( - inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName), - sandboxName, - operation, - ); +): PolicyMutationContext { + return inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName); } -/** Require the durable policy receipt immediately before a local mutation. */ -function preparePolicyMutationAuthority( +function preparePolicyMutationContext( sandboxName: string, operation: string, requestedGatewayName?: string, -): PolicyMutationAuthority { - return resolvePolicyAuthority( - inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName), - sandboxName, - operation, - ); -} - -/** Require NemoClaw ownership before a local policy mutation. */ -export function assertNemoClawManagedPolicy( - authority: PolicyMutationAuthority, - operation: string, -): void { - if (authority.authority === "nemoclaw-managed") return; - if (authority.authority === "owner-unknown") { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: NemoClaw cannot verify policy ownership. Recreate this sandbox before requesting a NemoClaw policy mutation.`, - authority.authority, - ); - } - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: this sandbox policy is externally managed. ` + - "The external policy authority must perform the requested policy mutation.", - authority.authority, - ); +): PolicyMutationContext { + return inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName); } -/** Recheck one recorded receipt immediately before a policy mutation. */ -export function recheckPolicyMutationAuthority( +/** Re-read live state immediately before a policy mutation. */ +export function recheckPolicyMutationContext( sandboxName: string, operation: string, - recorded: PolicyMutationAuthority, -): PolicyMutationAuthority { - const observed = inspectPolicyMutationAuthority( - sandboxName, - operation, - recorded.gatewayName, - true, - ); - assertRecordedPolicyAuthority(recorded.authority, observed.authority, operation); - if ( - recorded.policyCreationReceipt != null && - observed.policyCreationReceipt != null && - (recorded.policyCreationReceipt.gatewayName !== observed.policyCreationReceipt.gatewayName || - recorded.policyCreationReceipt.gatewayPort !== observed.policyCreationReceipt.gatewayPort || - recorded.policyCreationReceipt.sandboxName !== observed.policyCreationReceipt.sandboxName || - recorded.policyCreationReceipt.lifecycleGeneration !== - observed.policyCreationReceipt.lifecycleGeneration || - recorded.policyCreationReceipt.sandboxIdentityFingerprint !== - observed.policyCreationReceipt.sandboxIdentityFingerprint) - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to ${operation}: the NemoClaw policy creation receipt changed sandbox identity.`, - "owner-unknown", + previous: PolicyMutationContext, +): PolicyMutationContext { + const current = inspectPolicyMutationContext(sandboxName, operation, previous.gatewayName); + if (!isDeepStrictEqual(current.inspection.effectivePolicy, previous.inspection.effectivePolicy)) { + throw new PolicyObservationError( + `Refusing to ${operation}: the current OpenShell policy changed while NemoClaw prepared the requested update. Rerun the command against the current policy.`, ); } - assertNemoClawManagedPolicy(observed, operation); - return observed; + return current; } /** Reject a final OpenShell policy refusal without exposing raw diagnostics. */ @@ -999,43 +752,40 @@ export function rejectFinalPolicySetResult( : (captured.stderr ?? null), }); if (outcome.kind === "rejected") { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Refusing to ${operation}: OpenShell rejected the policy change: ${redact(outcome.message)}`, ); } } -function reportPolicyAuthorityFailure(error: unknown): false { - console.error(` ${policyAuthorityError(error)}`); +function reportPolicyObservationFailure(error: unknown): false { + console.error(` ${policyObservationError(error)}`); return false; } -function inspectNemoClawManagedPolicy( +function inspectLivePolicyForMutation( sandboxName: string, operation: string, gatewayName?: string, -): PolicyMutationAuthority | null { +): PolicyMutationContext | null { try { - const context = preparePolicyMutationAuthority(sandboxName, operation, gatewayName); - assertNemoClawManagedPolicy(context, operation); - return context; + return preparePolicyMutationContext(sandboxName, operation, gatewayName); } catch (error) { - reportPolicyAuthorityFailure(error); + reportPolicyObservationFailure(error); return null; } } -/** Recheck the original managed receipt immediately before a local state mutation. */ -function recheckNemoClawManagedPolicy( +function recheckLivePolicyForMutation( sandboxName: string, operation: string, - authority: PolicyMutationAuthority, + context: PolicyMutationContext, ): boolean { try { - recheckPolicyMutationAuthority(sandboxName, operation, authority); + recheckPolicyMutationContext(sandboxName, operation, context); return true; } catch (error) { - return reportPolicyAuthorityFailure(error); + return reportPolicyObservationFailure(error); } } @@ -1088,118 +838,52 @@ function submitComposedPolicy( * reaches the console. * * A `rejected` verdict is final: OpenShell understood the document and refused - * it, so resubmitting only replays a policy it already declined. An `ambiguous` - * result proves nothing about gateway state, so the operator must read the - * policy back before deciding anything. + * it, so resubmitting only replays a policy it already declined. Ambiguous + * results are resolved through live readback before this formatter is used. */ function policySetFailure( sandboxName: string, - outcome: Exclude, + outcome: Extract, ): Error { - if (outcome.kind === "rejected") { - return new Error( - `OpenShell rejected the policy for sandbox '${sandboxName}' (exit ${outcome.status}): ` + - `${redact(outcome.message)}. The policy was not applied and re-applying it will be ` + - `rejected again; change the preset selection instead.`, - ); - } return new Error( - `Could not confirm the policy update for sandbox '${sandboxName}': ${redact(outcome.detail)}. ` + - `The gateway may or may not have applied it; read the current policy back before retrying.`, + `OpenShell rejected the policy for sandbox '${sandboxName}' (exit ${outcome.status}): ` + + `${redact(outcome.message)}. The policy was not applied and re-applying it will be ` + + `rejected again; change the preset selection instead.`, ); } -export function finalizePolicyMutationReceipt( +export function verifyAppliedPolicyDocument( sandboxName: string, desiredPolicyDocument: string, - previous: PolicyMutationAuthority, + previous: PolicyMutationContext, ): void { - const previousReceipt = previous.policyCreationReceipt; - if (previousReceipt == null) { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but no policy creation receipt was available. The policy update is incomplete.`, - "owner-unknown", - ); - } - - const operation = "complete the sandbox policy update"; - const live = inspectLivePolicyBoundary(sandboxName, operation, previous.gatewayName); - if (live.inspection.authority !== "owner-unknown") { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but OpenShell no longer reports a sandbox-scoped policy. The policy update is incomplete.`, - live.inspection.authority, - ); - } - const boundary = managedReceiptSandboxBoundary(live, sandboxName, operation); - if (!isDeepStrictEqual(boundary.receipt, previousReceipt)) { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but its policy creation receipt changed during the update. The policy update is incomplete.`, - "owner-unknown", - ); - } - - let observedBasePolicy: string; - try { - observedBasePolicy = captureSandboxBasePolicy(sandboxName, previous.gatewayName); - } catch { - throw new PolicyAuthorityRefusalError( + const readback = inspectPolicyDocumentReadback(sandboxName, desiredPolicyDocument, previous); + if (readback === "unavailable") { + throw new PolicyObservationError( `NemoClaw applied the sandbox policy for '${sandboxName}', but could not verify the resulting base policy. The policy update is incomplete.`, - "owner-unknown", ); } - if (!policyDocumentsMatch(observedBasePolicy, desiredPolicyDocument)) { - throw new PolicyAuthorityRefusalError( + if (readback === "different") { + throw new PolicyObservationError( `NemoClaw applied the sandbox policy for '${sandboxName}', but the resulting base policy did not match the requested policy. The policy update is incomplete.`, - "owner-unknown", - ); - } - - const confirmed = inspectLivePolicyBoundary(sandboxName, operation, previous.gatewayName); - if (confirmed.inspection.authority !== "owner-unknown") { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but OpenShell no longer reports a sandbox-scoped policy. The policy update is incomplete.`, - confirmed.inspection.authority, - ); - } - const confirmedBoundary = managedReceiptSandboxBoundary(confirmed, sandboxName, operation); - if ( - !isDeepStrictEqual(confirmedBoundary.receipt, previousReceipt) || - confirmedBoundary.liveIdentityFingerprint !== boundary.liveIdentityFingerprint || - confirmed.inspection.policyIdentity.hash !== live.inspection.policyIdentity.hash || - confirmed.inspection.policyIdentity.activeVersion !== - live.inspection.policyIdentity.activeVersion - ) { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but its policy identity changed during verification. The policy update is incomplete.`, - "owner-unknown", - ); - } - - const nextReceipt: NemoClawPolicyCreationReceipt = { - ...previousReceipt, - policyHash: confirmed.inspection.policyIdentity.hash, - policyVersion: confirmed.inspection.policyIdentity.activeVersion, - }; - if ( - !registry.compareAndSetSandboxPolicyCreationReceipt(sandboxName, previousReceipt, nextReceipt) - ) { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but could not record the resulting policy identity. The policy update is incomplete.`, - "owner-unknown", ); } +} - const completed = inspectPolicyMutationAuthority( - sandboxName, - operation, - previous.gatewayName, - true, - ); - if (!isDeepStrictEqual(completed.policyCreationReceipt, nextReceipt)) { - throw new PolicyAuthorityRefusalError( - `NemoClaw applied the sandbox policy for '${sandboxName}', but could not verify the recorded policy identity. The policy update is incomplete.`, - "owner-unknown", - ); +function inspectPolicyDocumentReadback( + sandboxName: string, + desiredPolicyDocument: string, + previous: PolicyMutationContext, +): "matched" | "different" | "unavailable" { + try { + return policyDocumentsMatch( + captureSandboxBasePolicy(sandboxName, previous.gatewayName), + desiredPolicyDocument, + ) + ? "matched" + : "different"; + } catch { + return "unavailable"; } } @@ -1209,33 +893,27 @@ export function finalizePolicyMutationReceipt( * nonFatal so a failed OpenShell mutation cannot bypass its rollback through * process.exit. * - * The submission owns the temp policy file, so the composed policy is already + * The submission controls the temp policy file, so the composed policy is already * deleted by the time this ends the process for a fatal caller (#9206). */ -export function setReceiptBoundPolicyDocument( +export function setPolicyDocument( sandboxName: string, policyDocument: string, options: { nonFatal?: boolean; gatewayName?: string; operation?: string; - authority?: PolicyMutationAuthority; + context?: PolicyMutationContext; } = {}, ): boolean { const operation = options.operation ?? "set the sandbox policy"; - let authority: PolicyMutationAuthority; + let context: PolicyMutationContext; try { - if (options.authority) { - recheckPolicyMutationAuthority(sandboxName, operation, options.authority); - } - authority = preparePolicyMutationAuthority( - sandboxName, - operation, - options.authority?.gatewayName ?? options.gatewayName, - ); - assertNemoClawManagedPolicy(authority, operation); + context = options.context + ? recheckPolicyMutationContext(sandboxName, operation, options.context) + : preparePolicyMutationContext(sandboxName, operation, options.gatewayName); } catch (error) { - console.error(` ${policyAuthorityError(error)}`); + console.error(` ${policyObservationError(error)}`); if (options.nonFatal) return false; process.exit(1); } @@ -1243,19 +921,34 @@ export function setReceiptBoundPolicyDocument( const { outcome, status } = submitComposedPolicy( sandboxName, policyDocument, - authority.gatewayName, + context.gatewayName, ); if (outcome.kind === "applied") { try { - finalizePolicyMutationReceipt(sandboxName, policyDocument, authority); + verifyAppliedPolicyDocument(sandboxName, policyDocument, context); return true; } catch (error) { - console.error(` ${policyAuthorityError(error)}`); + console.error(` ${policyObservationError(error)}`); if (options.nonFatal) return false; process.exit(1); } } + if (outcome.kind === "ambiguous") { + const readback = inspectPolicyDocumentReadback(sandboxName, policyDocument, context); + if (readback === "matched") return true; + const observedResult = + readback === "different" + ? "The current live policy differs from the requested document" + : "The current live policy could not be read"; + console.error( + ` Could not confirm the policy update for sandbox '${sandboxName}': ${redact(outcome.detail)}. ` + + `${observedResult}; the update remains unconfirmed.`, + ); + if (options.nonFatal) return false; + process.exit(status || 1); + } + console.error(` ${policySetFailure(sandboxName, outcome).message}`); if (options.nonFatal) return false; process.exit(status || 1); @@ -1349,8 +1042,8 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st * OpenShell 0.0.101 rejects a hostless `allowed_ips` endpoint when any other * endpoint selects the same port with different connection metadata. Personal * deliberately grants every sandbox binary direct L4 access on ports 80/443, - * so exact web endpoints add no transport authority while Personal is active. - * Keep the reviewed Personal entry as the sole web authority and retain every + * so exact web endpoints add no transport context while Personal is active. + * Keep the reviewed Personal entry as the sole web context and retain every * non-web endpoint and non-network policy section unchanged. OpenShell handles * `inference.local` before ordinary network-policy evaluation, so removing its * overlapping base-policy endpoint does not remove routed inference. @@ -1669,19 +1362,11 @@ function resolveSandboxOpenClawNpmBaseline(sandboxName: string): string | null { return baseline.content; } -function openClawNpmExclusionStateError(sandboxName: string, currentPolicy: string): string | null { - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (transition?.exclusion.key === OPENCLAW_NPM_BASELINE_KEY) { - return `baseline repair for '${OPENCLAW_NPM_BASELINE_KEY}' is still pending; finish that transaction before changing npm`; - } - const isExcluded = registry - .getBaselineExclusions(sandboxName) - .some((entry) => entry.key === OPENCLAW_NPM_BASELINE_KEY); - if (!isExcluded) return null; - const live = inspectLiveBaselineEntry(currentPolicy, OPENCLAW_NPM_BASELINE_KEY); - return live.state === "absent" - ? null - : `recorded exclusion for '${OPENCLAW_NPM_BASELINE_KEY}' requires the live entry to remain absent`; +function openClawNpmExclusionStateError( + _sandboxName: string, + _currentPolicy: string, +): string | null { + return null; } export type OpenClawNpmCompatibilityState = "match" | "repair" | "excluded" | "drift"; @@ -1694,13 +1379,8 @@ function getOpenClawNpmCompatibilityState( if (!baselinePolicyContent) return "match"; const currentPolicy = readCurrentSandboxPolicy(sandboxName); if (!currentPolicy) return null; - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (transition?.exclusion.key === OPENCLAW_NPM_BASELINE_KEY) return "drift"; - const isExcluded = registry - .getBaselineExclusions(sandboxName) - .some((entry) => entry.key === OPENCLAW_NPM_BASELINE_KEY); const live = inspectLiveBaselineEntry(currentPolicy, OPENCLAW_NPM_BASELINE_KEY); - if (isExcluded) return live.state === "absent" ? "excluded" : "drift"; + if (live.state === "absent") return "excluded"; if (live.state !== "present") return "drift"; const parsed = YAML.parse(currentPolicy); @@ -1861,14 +1541,6 @@ function mergePresetNamesIntoPolicy( continue; } - const excludedKeys = new Set(options.excludedBaselineKeys ?? []); - const collision = parsePresetPolicyKeys(presetContent).find((key) => excludedKeys.has(key)); - if (collision) { - throw new Error( - `Cannot compose policy preset '${presetName}': network policy key '${collision}' is reserved by a baseline exclusion. Restore that baseline key before applying the preset.`, - ); - } - merged = mergePresetIntoPolicy(merged, presetEntries); appliedPresets.push(presetName); } @@ -1984,17 +1656,13 @@ function removePresetFromPolicy( } /** - * Remove a previously-applied preset from the running sandbox policy and - * delete its name from the registry entry. Resolves the preset's content - * from the built-in presets directory first, then from the registry's - * `customPolicies` list for presets applied via `--from-file`/`--from-dir`. - * Returns `false` if the preset is unknown or has no `network_policies` - * section. + * Remove one built-in or namespaced custom preset from the live OpenShell + * policy. No local preset attribution is read or written. */ function removePreset( sandboxName: string, presetName: string, - options: { nonFatal?: boolean; skipRegistryUpdate?: boolean } = {}, + options: { nonFatal?: boolean; presetContent?: string } = {}, ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" @@ -2012,20 +1680,8 @@ function removePreset( return false; } - // Resolve preset content: built-in first, then custom presets persisted - // in the registry. `isCustom` controls which registry bucket to prune on - // success. - let presetContent: string | null = loadPresetForSandbox(sandboxName, presetName); - let isCustom = false; - if (!presetContent) { - const custom = registry - .getCustomPolicies(sandboxName) - .find((p: { name: string }) => p.name === presetName); - if (custom) { - presetContent = custom.content; - isCustom = true; - } - } + const isCustom = listCustomPresets(sandboxName).some((entry) => entry.name === presetName); + const presetContent = options.presetContent ?? loadPresetForSandbox(sandboxName, presetName); if (!presetContent) { console.error(` Cannot load preset: ${presetName}`); return false; @@ -2038,21 +1694,10 @@ function removePreset( } const operation = `remove policy preset '${presetName}'`; - const authority = inspectNemoClawManagedPolicy(sandboxName, operation); - if (!authority) return false; - - // Get current policy YAML from sandbox - let rawPolicy = ""; - try { - // Mutations start from round-trippable --base, never provider-composed --full. - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { - env: { OPENSHELL_GATEWAY: authority.gatewayName }, - }); - } catch { - /* ignored */ - } + const context = inspectLivePolicyForMutation(sandboxName, operation); + if (!context) return false; - const currentPolicy = parseCurrentPolicyOrEmpty(rawPolicy); + const currentPolicy = readCurrentSandboxPolicy(sandboxName, context.gatewayName); if (!currentPolicy) { console.error(` Could not read current policy for sandbox '${sandboxName}'.`); return false; @@ -2078,31 +1723,7 @@ function removePreset( classifyPresetEntries(currentPolicy, presetEntries) === "absent" && policyDocumentsMatch(currentPolicy, mergePresetIntoPolicy(currentPolicy, presetEntries)); if (supersededByPersonal) { - const sandbox = options.skipRegistryUpdate ? undefined : registry.getSandbox(sandboxName); - const attributionRecorded = - options.skipRegistryUpdate === true || - (isCustom - ? (sandbox?.customPolicies ?? []).some((policy) => policy.name === presetName) - : (sandbox?.policies ?? []).includes(presetName)); - if (!attributionRecorded) { - console.error(` Preset '${presetName}' could not be removed from the current policy.`); - return false; - } - if (sandbox) { - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - const attributionRemoved = isCustom - ? registry.removeCustomPolicyByName(sandboxName, presetName) - : registry.updateSandbox(sandboxName, { - policies: (sandbox.policies ?? []).filter((name) => name !== presetName), - }); - if (!attributionRemoved) { - console.error(` Preset '${presetName}' could not be removed from the registry.`); - return false; - } - } - console.log( - ` Removed preset: ${presetName} (Personal remains the sole web authority; live policy unchanged).`, - ); + console.log(` Preset '${presetName}' is already absent from the live OpenShell policy.`); return true; } @@ -2112,9 +1733,9 @@ function removePreset( const teamsActive = presetName === "teams" ? false - : getCredentialBoundMessagingChannelsFromEntry( - registry.getSandbox(sandboxName), - ).includes("teams"); + : getCredentialBoundMessagingChannelsFromEntry(registry.getSandbox(sandboxName)).includes( + "teams", + ); updated = reconcileTeamsOutlookLoginCredentialBinding(updated, sandboxName, teamsActive); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -2134,8 +1755,8 @@ function removePreset( updated = normalizePersonalOpenInternetPolicy(updated); if (updated === currentPolicy) { - console.error(` Preset '${presetName}' could not be removed from the current policy.`); - return false; + console.log(` Preset '${presetName}' is already absent from the live OpenShell policy.`); + return true; } const endpoints = getPresetEndpoints(presetContent); @@ -2146,54 +1767,31 @@ function removePreset( // Run before submitting so a missing-binary exit doesn't orphan files in // $TMPDIR (the cleanup doesn't run on process.exit). if (!assertOpenshellResolvable(options)) return false; - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; + if (!recheckLivePolicyForMutation(sandboxName, operation, context)) return false; if ( - !setReceiptBoundPolicyDocument(sandboxName, updated, { + !setPolicyDocument(sandboxName, updated, { nonFatal: options.nonFatal, - gatewayName: authority.gatewayName, + context, }) ) { return false; } - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - - const sandbox = options.skipRegistryUpdate ? undefined : registry.getSandbox(sandboxName); - if (sandbox) { - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - if (isCustom) { - registry.removeCustomPolicyByName(sandboxName, presetName); - } else { - const pols = (sandbox.policies || []).filter((p: string) => p !== presetName); - registry.updateSandbox(sandboxName, { policies: pols }); - } - } - console.log(` Removed preset: ${presetName}`); return true; } -/** Push a policy YAML body to a sandbox's live gateway via a private temp file. */ -function pushPolicyYaml( - sandboxName: string, - updatedPolicy: string, - options: { nonFatal?: boolean; gatewayName?: string } = {}, -): boolean { - if (!assertOpenshellResolvable(options)) return false; - return setReceiptBoundPolicyDocument(sandboxName, updatedPolicy, options); -} - /** Round-trippable live policy body from `--base`, or null when unreadable. */ function readCurrentSandboxPolicy(sandboxName: string, gatewayName?: string): string | null { - let rawPolicy = ""; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { - ...(gatewayName ? { env: { OPENSHELL_GATEWAY: gatewayName } } : {}), - }); + const selectedGateway = + gatewayName ?? resolveSandboxGatewayName(registry.getSandbox(sandboxName)); + return ( + parseCurrentPolicyOrEmpty(captureSandboxBasePolicy(sandboxName, selectedGateway)) || null + ); } catch { - /* ignored */ + return null; } - return parseCurrentPolicyOrEmpty(rawPolicy) || null; } /** Resolve and validate one agent's reviewed baseline policy source. */ @@ -2239,55 +1837,7 @@ function getSandboxBaselineEntryDigest(sandboxName: string, key: string): string return entry ? digestBaselineEntry(entry) : null; } -/** Digest of an observed live policy key, null when absent, or throw when unreadable. */ -function getLiveSandboxPolicyEntryDigest(sandboxName: string, key: string): string | null { - assertNoOpenShellGatewayEndpointOverride(); - const sandbox = registry.getSandbox(sandboxName); - if (!sandbox) throw new Error(`Sandbox '${sandboxName}' is not registered.`); - const gatewayName = resolveSandboxGatewayName(sandbox); - const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); - if (!currentPolicy) throw new Error(`Live policy for '${sandboxName}' is unreadable.`); - const live = inspectLiveBaselineEntry(currentPolicy, key); - if (live.state === "invalid") { - throw new Error(`Live policy key '${key}' for '${sandboxName}' is malformed.`); - } - return live.digest; -} - -/** Three-way status across agent source, reviewed baseline, and observed live policy. */ -function getBaselineExclusionRuntimeStatus( - sandboxName: string, - exclusion: registry.BaselineExclusionEntry, -): BaselineExclusionRuntimeStatus { - const currentAgent = registry.getSandbox(sandboxName)?.agent || "openclaw"; - if (exclusion.agent !== currentAgent) return "agent-changed"; - let currentBaselineDigest: string | null; - try { - currentBaselineDigest = getSandboxBaselineEntryDigest(sandboxName, exclusion.key); - } catch { - return "baseline-unreadable"; - } - const baselineStatus = evaluateBaselineExclusionRuntimeStatus( - exclusion, - currentAgent, - currentBaselineDigest, - undefined, - ); - if (baselineStatus !== "live-policy-unreadable") return baselineStatus; - try { - const liveDigest = getLiveSandboxPolicyEntryDigest(sandboxName, exclusion.key); - return evaluateBaselineExclusionRuntimeStatus( - exclusion, - currentAgent, - currentBaselineDigest, - liveDigest, - ); - } catch { - return "live-policy-unreadable"; - } -} - -/** Run one baseline transaction against the sandbox's durable gateway binding. */ +/** Run one mutation against the sandbox's recorded OpenShell gateway. */ function withRecordedSandboxGateway( sandboxName: string, operation: (gatewayName: string) => boolean, @@ -2298,559 +1848,129 @@ function withRecordedSandboxGateway( console.error(` Sandbox '${sandboxName}' is not registered; no policy changes were made.`); return false; } - const gatewayName = resolveSandboxGatewayName(sandbox); - // Never rewrite process.env here: two sandbox operations may run in the - // same CLI process. Every live read/write receives this binding explicitly. - return operation(gatewayName); -} - -type BaselineTransitionReconciliation = - | { state: "none" } - | { state: "excluded" | "restored" } - | { state: "resume"; transition: registry.BaselineExclusionTransition }; - -function registryTransitionStep(action: () => boolean, failureMessage: string): boolean { - try { - if (action()) return true; - } catch { - // The durable journal remains authoritative; do not hide it with a second - // best-effort mutation after a persistence exception. - } - console.error(` ${failureMessage}`); - return false; + return operation(resolveSandboxGatewayName(sandbox)); } -type LiveBaselineEntryState = - | { state: "absent"; digest: null } - | { state: "present"; digest: string } - | { state: "invalid"; digest: null }; - -type RecheckManagedPolicyAuthority = () => boolean; +type RestoreBaselineEntryOptions = { + nonFatal?: boolean; + expectedTargetDigest?: string | null; +}; -function authorityBoundRegistryStep( - recheckAuthority: RecheckManagedPolicyAuthority, - operation: () => boolean, - failureMessage: string, +function excludeBaselineEntry( + sandboxName: string, + key: string, + digest: string, + options: { nonFatal?: boolean } = {}, ): boolean { - if (!recheckAuthority()) return false; - return registryTransitionStep(operation, failureMessage); -} - -function inspectLiveBaselineEntry(policy: string, key: string): LiveBaselineEntryState { - try { - const document = YAML.parse(policy); - if (!isPolicyDocument(document)) { - return { state: "invalid", digest: null }; - } - if (document.network_policies === undefined || document.network_policies === null) { - return { state: "absent", digest: null }; + return withRecordedSandboxGateway(sandboxName, (gatewayName) => { + const operation = `exclude baseline policy entry '${key}'`; + const context = inspectLivePolicyForMutation(sandboxName, operation, gatewayName); + if (!context) return false; + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) { + console.error(` Could not read current policy for sandbox '${sandboxName}'.`); + return false; } - if (!isPolicyObject(document.network_policies)) return { state: "invalid", digest: null }; - if (!Object.prototype.hasOwnProperty.call(document.network_policies, key)) { - return { state: "absent", digest: null }; + const live = inspectLiveBaselineEntry(currentPolicy, key); + if (live.state === "absent") return true; + if (live.state !== "present" || live.digest !== digest) { + console.error( + ` Baseline entry '${key}' changed after preview. Rerun the command to review its current scope; no policy changes were made.`, + ); + return false; } - const entry = document.network_policies[key]; - return isPolicyObject(entry) - ? { state: "present", digest: digestBaselineEntry(entry) } - : { state: "invalid", digest: null }; - } catch { - return { state: "invalid", digest: null }; - } + const { policy, removed } = removeBaselineEntryFromPolicy(currentPolicy, key); + return ( + removed && + assertOpenshellResolvable(options) && + setPolicyDocument(sandboxName, policy, { + ...options, + context, + operation, + }) + ); + }); } -/** - * Recover an interrupted registry/live-policy transaction from exact live - * state. The journal is finalized only at its exact target and rolled back - * only at its exact source; any third state remains visible and fail-closed. - */ -function reconcileBaselineExclusionTransition( +function restoreBaselineEntry( sandboxName: string, - requestedKey: string, - gatewayName: string, - recheckAuthority: RecheckManagedPolicyAuthority, -): BaselineTransitionReconciliation | null { - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (!transition) return { state: "none" }; - const key = transition.exclusion.key; - if (key !== requestedKey) { - console.error( - ` Baseline policy repair for '${key}' is still pending. Re-run 'policy ${transition.operation} ${key}' before changing another baseline entry.`, - ); - return null; - } - if (transition.operation === "restore") { - const committed = registry - .getBaselineExclusions(sandboxName) - .find((entry) => entry.key === transition.exclusion.key); - if (!committed || !isDeepStrictEqual(committed, transition.exclusion)) { + key: string, + options: RestoreBaselineEntryOptions = {}, +): boolean { + return withRecordedSandboxGateway(sandboxName, (gatewayName) => { + let entry: PolicyObject | null; + try { + entry = getSandboxBaselineEntry(sandboxName, key); + } catch { console.error( - ` The durable exclusion for '${key}' changed during the pending restore. The journal was preserved; inspect registry intent before retrying.`, + ` The current release baseline for '${key}' is unreadable. No policy changes were made.`, ); - return null; - } - } - const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); - if (!currentPolicy) { - console.error( - ` Could not inspect the live policy needed to repair the pending '${transition.operation}' for '${key}'. The journal remains pending and rebuild is blocked.`, - ); - return null; - } - const live = inspectLiveBaselineEntry(currentPolicy, key); - const atTarget = - transition.targetLiveDigest === null - ? live.state === "absent" - : live.state === "present" && live.digest === transition.targetLiveDigest; - if (atTarget) { - if (!finalizeBaselineExclusionTransition(sandboxName, transition, recheckAuthority)) { - return null; - } - return { state: transition.operation === "exclude" ? "excluded" : "restored" }; - } - - const atSource = - transition.operation === "exclude" - ? live.state === "present" && live.digest === transition.exclusion.digest - : live.state === "absent"; - if (atSource) { - const committed = registry - .getBaselineExclusions(sandboxName) - .some((entry) => entry.key === transition.exclusion.key); - // A re-exclude can begin from a pre-existing inconsistent record. Preserve - // its journal and resume the exact live mutation instead of hiding that - // divergence by returning to the already-inconsistent committed state. - if (transition.operation === "exclude" && committed) { - return { state: "resume", transition }; + return false; } + const targetDigest = entry ? digestBaselineEntry(entry) : null; if ( - !authorityBoundRegistryStep( - recheckAuthority, - () => registry.clearBaselineExclusionTransition(sandboxName, transition.id), - `The live policy remains at the pre-${transition.operation} state for '${key}', but the durable journal could not be rolled back. Re-run the same command; rebuild remains blocked.`, - ) + Object.prototype.hasOwnProperty.call(options, "expectedTargetDigest") && + targetDigest !== options.expectedTargetDigest ) { - return null; + console.error( + ` Baseline entry '${key}' changed after preview. Rerun the command to review its current scope; no policy changes were made.`, + ); + return false; } - return { state: transition.operation === "exclude" ? "restored" : "excluded" }; - } - - console.error( - ` Live baseline entry '${key}' matches neither side of the pending '${transition.operation}' transaction. The journal was preserved; inspect the live policy and repair it before rebuilding.`, - ); - return null; -} - -function beginBaselineExclusionTransition( - sandboxName: string, - operation: registry.BaselineExclusionTransitionOperation, - exclusion: registry.BaselineExclusionEntry, - targetLiveDigest: string | null, - recheckAuthority: RecheckManagedPolicyAuthority, -): registry.BaselineExclusionTransition | null { - const transition: registry.BaselineExclusionTransition = { - id: randomUUID(), - operation, - exclusion, - targetLiveDigest, - startedAt: new Date().toISOString(), - }; - return authorityBoundRegistryStep( - recheckAuthority, - () => registry.beginBaselineExclusionTransition(sandboxName, transition), - `Could not record the pending baseline '${operation}' for '${sandboxName}'; no live policy changes were made.`, - ) - ? transition - : null; -} - -function restoreTransitionCanFinalize( - sandboxName: string, - transition: registry.BaselineExclusionTransition, -): boolean { - if (transition.operation !== "restore") return true; - const committed = registry - .getBaselineExclusions(sandboxName) - .find((entry) => entry.key === transition.exclusion.key); - if (!committed || !isDeepStrictEqual(committed, transition.exclusion)) { - console.error( - ` The durable exclusion for '${transition.exclusion.key}' no longer matches the pending restore. The journal was preserved; rebuild remains blocked.`, - ); - return false; - } - let currentBaselineDigest: string | null; - try { - currentBaselineDigest = getSandboxBaselineEntryDigest(sandboxName, transition.exclusion.key); - } catch { - console.error( - ` The current release baseline for '${transition.exclusion.key}' is unreadable. The pending restore was not finalized; rebuild remains blocked.`, - ); - return false; - } - if (currentBaselineDigest !== transition.targetLiveDigest) { - console.error( - ` The current release baseline for '${transition.exclusion.key}' changed during the pending restore. The journal was preserved; re-review the current scope before repairing it.`, - ); - return false; - } - return true; -} - -function finalizeBaselineExclusionTransition( - sandboxName: string, - transition: registry.BaselineExclusionTransition, - recheckAuthority: RecheckManagedPolicyAuthority, -): boolean { - if (!restoreTransitionCanFinalize(sandboxName, transition)) return false; - return authorityBoundRegistryStep( - recheckAuthority, - () => registry.commitBaselineExclusionTransition(sandboxName, transition.id), - `The live policy was updated for '${transition.exclusion.key}', but the durable journal could not be finalized. Re-run 'policy ${transition.operation} ${transition.exclusion.key}' to reconcile it; rebuild remains blocked.`, - ); -} - -function compensateBaselineExclusionTransition( - sandboxName: string, - transition: registry.BaselineExclusionTransition, - recheckAuthority: RecheckManagedPolicyAuthority, -): boolean { - return authorityBoundRegistryStep( - recheckAuthority, - () => registry.clearBaselineExclusionTransition(sandboxName, transition.id), - `Failed to roll back the pending baseline '${transition.operation}' for '${transition.exclusion.key}'. The durable journal was preserved; re-run the same command before rebuilding '${sandboxName}'.`, - ); -} - -function settleBaselineExclusionTransitionAfterPush( - sandboxName: string, - transition: registry.BaselineExclusionTransition, - pushSucceeded: boolean, - canRollbackAtSource: boolean, - gatewayName: string, - recheckAuthority: RecheckManagedPolicyAuthority, -): boolean { - const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); - if (!currentPolicy) { - console.error( - ` Could not verify the live '${transition.operation}' result for '${transition.exclusion.key}'. The durable journal was preserved and rebuild remains blocked.`, - ); - return false; - } - const live = inspectLiveBaselineEntry(currentPolicy, transition.exclusion.key); - const atTarget = - transition.targetLiveDigest === null - ? live.state === "absent" - : live.state === "present" && live.digest === transition.targetLiveDigest; - if (atTarget) { - return finalizeBaselineExclusionTransition(sandboxName, transition, recheckAuthority); - } - const atSource = - transition.operation === "exclude" - ? live.state === "present" && live.digest === transition.exclusion.digest - : live.state === "absent"; - if (!pushSucceeded && atSource && canRollbackAtSource) { - compensateBaselineExclusionTransition(sandboxName, transition, recheckAuthority); - return false; - } - const state = atSource ? "the pre-mutation state" : "an unexpected third state"; - console.error( - ` Live baseline entry '${transition.exclusion.key}' is in ${state} after the '${transition.operation}' attempt. The durable journal was preserved; re-run the same command before rebuilding.`, - ); - return false; -} - -function attemptBaselineTransitionPolicyPush( - sandboxName: string, - updatedPolicy: string, - options: { nonFatal?: boolean }, - gatewayName: string, - recheckAuthority: RecheckManagedPolicyAuthority, -): boolean { - try { - if (!recheckAuthority()) return false; - return pushPolicyYaml(sandboxName, updatedPolicy, { - ...options, - nonFatal: true, - gatewayName, - }); - } catch { - console.error( - ` The live policy update for '${sandboxName}' raised an unexpected error; verifying the journal before deciding whether it applied.`, - ); - return false; - } -} - -/** - * Exclude a baseline entry from the running sandbox policy and record the - * approval, bound to `digest`, in the registry so create/rebuild replay it. - */ -function excludeBaselineEntry( - sandboxName: string, - key: string, - digest: string, - options: { nonFatal?: boolean } = {}, -): boolean { - return withRecordedSandboxGateway(sandboxName, (gatewayName) => { - const operation = `exclude baseline policy entry '${key}'`; - const authority = inspectNemoClawManagedPolicy(sandboxName, operation, gatewayName); - if (!authority) return false; - const recheckAuthority = () => recheckNemoClawManagedPolicy(sandboxName, operation, authority); - return excludeBaselineEntryOnGateway( - sandboxName, - key, - digest, - options, - gatewayName, - recheckAuthority, - ); - }); -} - -function excludeBaselineEntryOnGateway( - sandboxName: string, - key: string, - digest: string, - options: { nonFatal?: boolean }, - gatewayName: string, - recheckAuthority: RecheckManagedPolicyAuthority, -): boolean { - const reconciled = reconcileBaselineExclusionTransition( - sandboxName, - key, - gatewayName, - recheckAuthority, - ); - if (!reconciled) return false; - if (reconciled.state === "excluded") return true; - if (reconciled.state === "resume" && reconciled.transition.operation !== "exclude") { - console.error(` Finish the pending baseline restore for '${key}' before excluding it again.`); - return false; - } - const appliedOwner = findAppliedPolicyOwnerForKey(sandboxName, key); - if (appliedOwner) { - console.error( - ` Baseline entry '${key}' is also owned by applied policy '${appliedOwner}'. Remove that policy before excluding the baseline key; no policy changes were made.`, - ); - return false; - } - const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); - if (!currentPolicy) { - console.error(` Could not read current policy for sandbox '${sandboxName}'.`); - return false; - } - const live = inspectLiveBaselineEntry(currentPolicy, key); - if (live.state === "invalid") { - console.error( - ` Live baseline entry '${key}' could not be classified safely; no policy changes were made.`, - ); - return false; - } - if (live.state === "present" && live.digest !== digest) { - console.error( - ` Baseline entry '${key}' changed after preview. Rerun the command to review its current scope; no policy changes were made.`, - ); - return false; - } - const { policy: updated, removed } = removeBaselineEntryFromPolicy(currentPolicy, key); - const previousExclusion = registry - .getBaselineExclusions(sandboxName) - .find((entry) => entry.key === key); - const sandbox = registry.getSandbox(sandboxName); - const appliedAgentVersion = sandbox?.agentVersion ?? null; - const exclusion: registry.BaselineExclusionEntry = { - version: 1, - agent: sandbox?.agent || "openclaw", - key, - digest, - acknowledgedAt: new Date().toISOString(), - appliedAgentVersion, - }; - if (!removed) { - if (reconciled.state === "resume") { - return finalizeBaselineExclusionTransition( - sandboxName, - reconciled.transition, - recheckAuthority, + if (!entry) return true; + const operation = `restore baseline policy entry '${key}'`; + const context = inspectLivePolicyForMutation(sandboxName, operation, gatewayName); + if (!context) return false; + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) { + console.error(` Could not read current policy for sandbox '${sandboxName}'.`); + return false; + } + const live = inspectLiveBaselineEntry(currentPolicy, key); + if (live.state === "present" && live.digest === targetDigest) return true; + if (live.state !== "absent") { + console.error( + ` Live baseline entry '${key}' differs from the current release baseline. Refusing to overwrite it.`, ); + return false; } - return authorityBoundRegistryStep( - recheckAuthority, - () => registry.addBaselineExclusion(sandboxName, exclusion), - `The already-narrow live policy could not be recorded for '${sandboxName}'.`, + const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, entry); + return ( + assertOpenshellResolvable(options) && + setPolicyDocument(sandboxName, updated, { + ...options, + context, + operation, + }) ); - } - const transition = - reconciled.state === "resume" - ? reconciled.transition - : beginBaselineExclusionTransition(sandboxName, "exclude", exclusion, null, recheckAuthority); - if (!transition) return false; - const pushSucceeded = attemptBaselineTransitionPolicyPush( - sandboxName, - updated, - options, - gatewayName, - recheckAuthority, - ); - // When this was a fresh exclusion, a failed push that verifies at the exact - // source can clear the journal. A re-exclude that began with committed/live - // divergence must retain it until the live side reaches the target. - return settleBaselineExclusionTransitionAfterPush( - sandboxName, - transition, - pushSucceeded, - !previousExclusion, - gatewayName, - recheckAuthority, - ); -} - -/** - * Restore a previously excluded baseline entry against the current release - * baseline and drop its recorded exclusion. When the release removed the entry - * entirely, only the registry record is cleared. - */ -type RestoreBaselineEntryOptions = { - nonFatal?: boolean; - expectedTargetDigest?: string | null; -}; - -function restoreBaselineEntry( - sandboxName: string, - key: string, - options: RestoreBaselineEntryOptions = {}, -): boolean { - return withRecordedSandboxGateway(sandboxName, (gatewayName) => { - const operation = `restore baseline policy entry '${key}'`; - const authority = inspectNemoClawManagedPolicy(sandboxName, operation, gatewayName); - if (!authority) return false; - const recheckAuthority = () => recheckNemoClawManagedPolicy(sandboxName, operation, authority); - return restoreBaselineEntryOnGateway(sandboxName, key, options, gatewayName, recheckAuthority); }); } -function restoreBaselineEntryOnGateway( - sandboxName: string, - key: string, - options: RestoreBaselineEntryOptions, - gatewayName: string, - recheckAuthority: RecheckManagedPolicyAuthority, -): boolean { - // Resolve the current agent baseline before changing either durable or live - // state. A missing non-OpenClaw baseline must not be mistaken for a release - // that intentionally removed this key. - // Bind the mutation to the target disclosed before acknowledgement. This - // check precedes transaction recovery because reconciliation can change the - // durable journal. - let entry: PolicyObject | null; +function inspectLiveBaselineEntry(policy: string, key: string): LiveBaselineEntryState { try { - entry = getSandboxBaselineEntry(sandboxName, key); - } catch { - console.error( - ` The current release baseline for '${key}' is unreadable. No policy changes were made.`, - ); - return false; - } - const target = entry ? { entry, digest: digestBaselineEntry(entry) } : null; - const targetDigest = target?.digest ?? null; - if ( - Object.prototype.hasOwnProperty.call(options, "expectedTargetDigest") && - targetDigest !== options.expectedTargetDigest - ) { - console.error( - ` Baseline entry '${key}' changed after preview. Rerun the command to review its current scope; no policy changes were made.`, - ); - return false; - } - - const reconciled = reconcileBaselineExclusionTransition( - sandboxName, - key, - gatewayName, - recheckAuthority, - ); - if (!reconciled) return false; - if (reconciled.state === "restored") return true; - if (reconciled.state === "resume" && reconciled.transition.operation !== "restore") { - console.error(` Finish the pending baseline exclusion for '${key}' before restoring it.`); - return false; - } - const recordedExclusion = registry - .getBaselineExclusions(sandboxName) - .find((entry) => entry.key === key); - if (!recordedExclusion) { - console.error( - ` The exclusion for '${key}' is not recorded; no live policy changes were made.`, - ); - return false; - } - const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); - if (!currentPolicy) { - console.error(` Could not read current policy for sandbox '${sandboxName}'.`); - return false; - } - if (!target) { - return authorityBoundRegistryStep( - recheckAuthority, - () => registry.removeBaselineExclusion(sandboxName, key), - `The obsolete exclusion for '${key}' could not be cleared; no live policy changes were made.`, - ); - } - const live = inspectLiveBaselineEntry(currentPolicy, key); - if (live.state === "invalid") { - console.error( - ` Live baseline entry '${key}' could not be classified safely; no policy changes were made.`, - ); - return false; - } - if (live.state === "present" && live.digest !== targetDigest) { - console.error( - ` Live baseline entry '${key}' differs from the current release baseline. Refusing to overwrite it; repair the live policy before restoring this exclusion.`, - ); - return false; - } - if (live.state === "present" && live.digest === targetDigest) { - if (reconciled.state === "resume") { - return finalizeBaselineExclusionTransition( - sandboxName, - reconciled.transition, - recheckAuthority, - ); + const document = YAML.parse(policy); + if (!isPolicyDocument(document)) return { state: "invalid", digest: null }; + if (document.network_policies === undefined || document.network_policies === null) { + return { state: "absent", digest: null }; } - return authorityBoundRegistryStep( - recheckAuthority, - () => registry.removeBaselineExclusion(sandboxName, key), - `The restored live policy could not be recorded for '${sandboxName}'.`, - ); + if (!isPolicyObject(document.network_policies)) return { state: "invalid", digest: null }; + if (!Object.prototype.hasOwnProperty.call(document.network_policies, key)) { + return { state: "absent", digest: null }; + } + const entry = document.network_policies[key]; + return isPolicyObject(entry) + ? { state: "present", digest: digestBaselineEntry(entry) } + : { state: "invalid", digest: null }; + } catch { + return { state: "invalid", digest: null }; } - const transition = - reconciled.state === "resume" - ? reconciled.transition - : beginBaselineExclusionTransition( - sandboxName, - "restore", - recordedExclusion, - targetDigest, - recheckAuthority, - ); - if (!transition) return false; - const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, target.entry); - const pushSucceeded = attemptBaselineTransitionPolicyPush( - sandboxName, - updated, - options, - gatewayName, - recheckAuthority, - ); - return settleBaselineExclusionTransitionAfterPush( - sandboxName, - transition, - pushSucceeded, - true, - gatewayName, - recheckAuthority, - ); } +type LiveBaselineEntryState = + | { state: "absent"; digest: null } + | { state: "present"; digest: string } + | { state: "invalid"; digest: null }; + /** * Ask one preset-picker question on stderr and resolve to the raw answer. * @@ -2939,14 +2059,9 @@ async function selectForRemoval( /** * Apply raw preset content (already loaded in memory) to a running sandbox. * Validates the sandbox name, extracts the `network_policies` entries, merges - * them into the sandbox's current policy, runs `openshell policy set --wait`, - * and records the preset name in the registry. Returns `false` if the content - * has no `network_policies` section. Used by both `applyPreset` (built-in - * presets) and the `--from-file` / `--from-dir` paths (custom preset files). - * - * When `options.custom` is set, the preset content is also persisted under - * `customPolicies` in the registry so `removePreset` can later undo a - * custom preset purely by name. + * them into the sandbox's current OpenShell policy, and runs + * `openshell policy set --wait`. Custom preset identity is encoded in the + * OpenShell rule keys instead of a local registry copy. */ function applyPresetContent( sandboxName: string, @@ -2959,7 +2074,6 @@ function applyPresetContent( }; expectedExistingNetworkPolicyContent?: string | null; nonFatal?: boolean; - skipRegistryUpdate?: boolean; suppressDisclosure?: boolean; disclosedPresetState?: PresetPolicyState | null; includeMessagingCredentialBindings?: boolean; @@ -2988,17 +2102,17 @@ function applyPresetContent( return false; } const hasGeneratedPins = networkPoliciesHasAllowedIps(np); - const trustedPrivatePinsValid = isTrustedPrivatePolicyPinCapability( + const trustedPrivateCapabilityValid = isTrustedPrivatePolicyPinCapability( presetContent, options.custom.trustedPrivatePinCapability, ); - if (options.custom.trustedPrivatePinCapability && !trustedPrivatePinsValid) { + if (options.custom.trustedPrivatePinCapability && !trustedPrivateCapabilityValid) { console.error( - ` Preset '${presetName}' has an invalid trusted-private pin receipt for its content.`, + ` Preset '${presetName}' has an invalid trusted-private pin capability for its content.`, ); return false; } - if (hasGeneratedPins && !trustedPrivatePinsValid) { + if (hasGeneratedPins && !trustedPrivateCapabilityValid) { console.error( ` Preset '${presetName}' contains 'allowed_ips', which is not permitted in user-supplied presets.`, ); @@ -3022,53 +2136,28 @@ function applyPresetContent( } } - const presetEntries = extractPresetEntries(presetContent); + const effectivePresetContent = options.custom + ? namespaceCustomPresetContent(presetName, presetContent) + : presetContent; + const presetEntries = extractPresetEntries(effectivePresetContent); if (!presetEntries) { console.error(` Preset ${presetName} has no network_policies section.`); return false; } - const excludedCollision = findExcludedBaselineKeyForPolicy(sandboxName, presetContent); - if (excludedCollision) { - console.error( - ` Network policy key '${excludedCollision}' is reserved by a baseline exclusion. Restore that baseline key before applying '${presetName}'.`, - ); - return false; - } - - const requiredNetworkPolicies = parseNetworkPolicies(presetContent); + const requiredNetworkPolicies = parseNetworkPolicies(effectivePresetContent); if (!requiredNetworkPolicies) { console.error(` Preset ${presetName} has invalid network_policies.`); return false; } const operation = `apply policy preset '${presetName}'`; - let authority: PolicyMutationAuthority; + let context: PolicyMutationContext; try { - authority = preparePolicyMutationAuthority(sandboxName, operation); - if (authority.authority === "externally-managed") { - assertExternalPolicyRequirements({ - inspection: authority.inspection, - requiredPolicy: { network_policies: requiredNetworkPolicies }, - operation, - sandboxName, - }); - return true; - } + context = preparePolicyMutationContext(sandboxName, operation); } catch (error) { - return reportPolicyAuthorityFailure(error); - } - - // Get current policy YAML from sandbox - let rawPolicy: string | null = null; - try { - // Mutations start from round-trippable --base, never provider-composed --full. - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { - env: { OPENSHELL_GATEWAY: authority.gatewayName }, - }); - } catch { - /* Refused below. */ + return reportPolicyObservationFailure(error); } - const currentPolicy = parseCurrentPolicyOrEmpty(rawPolicy); + const currentPolicy = readCurrentSandboxPolicy(sandboxName, context.gatewayName); // A live mutation requires a usable policy; empty is an invalid read, not a // fresh sandbox whose unknown policy may be replaced with a scaffold. if (!currentPolicy) { @@ -3162,88 +2251,22 @@ function applyPresetContent( logOpenClawNpmCompatibilityDisclosure(); } - // Ownership-aware callers use a successful `policy set --wait` as part of - // their live-policy/registry transaction, even when the desired document is - // byte-for-byte equivalent to the current policy. Skipping that submission - // would let the caller commit its ownership reservation without observing a - // failed gateway mutation. Ordinary preset re-application remains a no-op. - const requiresOwnedKeyRefresh = Object.prototype.hasOwnProperty.call( - options, - "expectedExistingNetworkPolicyContent", - ); - const policyChanged = requiresOwnedKeyRefresh || !policyDocumentsMatch(currentPolicy, merged); + const policyChanged = !policyDocumentsMatch(currentPolicy, merged); // Run before submitting so a missing-binary exit doesn't orphan files in // $TMPDIR (the cleanup doesn't run on process.exit). if (policyChanged && !assertOpenshellResolvable(options)) return false; if (policyChanged) { - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; + if (!recheckLivePolicyForMutation(sandboxName, operation, context)) return false; if ( - !setReceiptBoundPolicyDocument(sandboxName, merged, { + !setPolicyDocument(sandboxName, merged, { nonFatal: options.nonFatal, - gatewayName: authority.gatewayName, + context, }) ) { return false; } - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - } - - // Some multi-resource lifecycle callers reserve ownership in the registry - // before mutating the live gateway. That ordering prevents a successful - // policy set followed by a registry-write failure from leaving an unowned - // live key. They explicitly request no second registry write here. - if (options.skipRegistryUpdate) { - if (policyChanged) console.log(` Applied preset: ${presetName}`); - return true; - } - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - - const sandbox = registry.getSandbox(sandboxName); - if (sandbox) { - if (options.custom) { - // Custom preset: persist full content so it can be removed later - // without requiring the user to still have the file on disk. - registry.addCustomPolicy(sandboxName, { - name: presetName, - content: presetContent, - sourcePath: options.custom.sourcePath, - ...(options.custom.trustedPrivatePinCapability - ? { trustedPrivatePins: options.custom.trustedPrivatePinCapability.receipt } - : {}), - }); - } else { - const pols = sandbox.policies || []; - if (!pols.includes(presetName)) { - pols.push(presetName); - } - registry.updateSandbox(sandboxName, { policies: pols }); - } - } else if (options.custom) { - // The preset reached the gateway, but sandbox `sandboxName` has no local - // registry entry, so it cannot be recorded under `customPolicies`. Custom - // presets are surfaced only from the registry (both `listCustomPresets` - // and `getGatewayPresets` read `registry.getCustomPolicies`), so an - // unrecorded custom preset never appears in `policy-list` or `status`. - // Report the gap instead of exiting 0 as if the preset were fully applied. (#4510) - console.error( - ` Warning: '${presetName}' was applied to the gateway but could not be ` + - `recorded locally because sandbox '${sandboxName}' is not in the ` + - `registry, so it will not appear in policy list or status. Recover or ` + - `re-onboard the sandbox, then re-apply.`, - ); - return false; - } else { - // A built-in preset stays discoverable from the gateway, so the mutation - // stands. Name the gap anyway: silence here is what leaves an operator - // holding egress that no local state explains. (#9295) - console.error( - ` Warning: '${presetName}' was applied to the gateway but could not be ` + - `recorded locally because sandbox '${sandboxName}' is not in the ` + - `registry, so policy list will report it as active on gateway, missing ` + - `from local state.`, - ); } if (policyChanged) console.log(` Applied preset: ${presetName}`); @@ -3293,7 +2316,6 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { entries: string; name: string; }> = []; - const requiredNetworkPolicies: PolicyObject = {}; for (const presetName of uniquePresetNames) { const presetContent = loadPresetForSandbox(sandboxName, presetName); @@ -3307,21 +2329,11 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { console.error(` Preset ${presetName} has no network_policies section.`); return false; } - const excludedCollision = findExcludedBaselineKeyForPolicy(sandboxName, presetContent); - if (excludedCollision) { - console.error( - ` Network policy key '${excludedCollision}' is reserved by a baseline exclusion. Restore that baseline key before applying '${presetName}'.`, - ); - return false; - } const networkPolicies = parseNetworkPolicies(presetContent); if (!networkPolicies) { console.error(` Preset ${presetName} has invalid network_policies.`); return false; } - for (const [key, value] of Object.entries(networkPolicies)) { - requiredNetworkPolicies[key] = value; - } preparedPresets.push({ content: presetContent, entries: presetEntries, @@ -3330,33 +2342,14 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { } const operation = "apply policy presets"; - let authority: PolicyMutationAuthority; + let context: PolicyMutationContext; try { - authority = preparePolicyMutationAuthority(sandboxName, operation); - if (authority.authority === "externally-managed") { - assertExternalPolicyRequirements({ - inspection: authority.inspection, - requiredPolicy: { network_policies: requiredNetworkPolicies }, - operation, - sandboxName, - }); - return true; - } + context = preparePolicyMutationContext(sandboxName, operation); } catch (error) { - return reportPolicyAuthorityFailure(error); + return reportPolicyObservationFailure(error); } - let rawPolicy: string | null = null; - try { - // Mutations start from round-trippable --base, never provider-composed --full. - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { - env: { OPENSHELL_GATEWAY: authority.gatewayName }, - }); - } catch { - /* Refused below. */ - } - - let merged = parseCurrentPolicyOrEmpty(rawPolicy); + let merged = readCurrentSandboxPolicy(sandboxName, context.gatewayName); // Keep the batch entrypoint on the same fail-closed source boundary as // applyPresetContent: an unusable successful read is still a failed read. if (!merged) { @@ -3437,24 +2430,10 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { // The shared fatal path preserves OpenShell's status after it removes the // temporary policy. Onboarding defers that exit until its recovery state // and outer cleanup have finished. - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - setReceiptBoundPolicyDocument(sandboxName, merged, { - gatewayName: authority.gatewayName, + if (!recheckLivePolicyForMutation(sandboxName, operation, context)) return false; + setPolicyDocument(sandboxName, merged, { + context, }); - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - } - - if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - - const sandbox = registry.getSandbox(sandboxName); - if (sandbox) { - const pols = sandbox.policies || []; - for (const presetName of uniquePresetNames) { - if (!pols.includes(presetName)) { - pols.push(presetName); - } - } - registry.updateSandbox(sandboxName, { policies: pols }); } if (policyChanged) { @@ -3602,93 +2581,43 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st return { presetName, content }; } -/** - * Return the list of preset names currently recorded as applied to the - * sandbox (both built-in names and custom-preset names), or an empty array - * if the sandbox is not tracked in the registry. - */ -function getAppliedPresets(sandboxName: string): string[] { - const sandbox = registry.getSandbox(sandboxName); - if (!sandbox) return []; - const builtin = sandbox.policies || []; - const custom = (sandbox.customPolicies || []).map((p: { name: string }) => p.name); - return [...builtin, ...custom]; +function getAppliedPresets(sandboxName: string, timeoutMs?: number): string[] { + return getGatewayPresets(sandboxName, timeoutMs) ?? []; } -/** - * Return the custom preset entries recorded on the sandbox as - * `PresetInfo`-shaped objects, so they can be mixed with built-in presets - * in listing / selection UIs. `file` is populated from `sourcePath` when - * available for a user hint; `description` is empty. - */ function listCustomPresets(sandboxName: string): PresetInfo[] { - const entries = registry.getCustomPolicies(sandboxName); - return entries.map((e: { name: string; sourcePath?: string }) => ({ - file: e.sourcePath || `${e.name}.yaml`, - name: e.name, - description: "custom preset", + const current = readCurrentSandboxPolicy(sandboxName); + if (!current) return []; + const parsed = YAML.parse(current); + if (!isPolicyDocument(parsed) || !isPolicyObject(parsed.network_policies)) return []; + const names = new Set(); + for (const key of Object.keys(parsed.network_policies)) { + const decoded = parseCustomPolicyKey(key); + if (decoded) names.add(decoded.presetName); + } + return [...names].sort().map((name) => ({ + file: `${name}.yaml`, + name, + description: "custom OpenShell policy", })); } -/** Return whether registered custom content owns an exact live network-policy key. */ +/** Return whether the live OpenShell key belongs to a namespaced custom preset. */ function customPresetOwnsNetworkPolicyKey(sandboxName: string, policyKey: string): boolean { - let candidates: ReturnType; - try { - candidates = []; - for (const entry of registry.getCustomPolicies(sandboxName)) { - const keys = parsePresetPolicyKeysForOwnership(entry.content); - if (keys === null) { - throw new Error("invalid registered custom policy content"); - } - if (keys.includes(policyKey)) candidates.push(entry); - } - } catch { - throw new Error( - `Could not inspect registered custom policy ownership for '${policyKey}' in sandbox '${sandboxName}'; refusing to reconcile overlapping built-in policy content.`, - ); - } - if (candidates.length === 0) return false; - - let rawPolicy: string; - try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); - } catch { - throw new Error( - `Could not read live policy ownership for '${policyKey}' in sandbox '${sandboxName}'; refusing to reconcile overlapping built-in policy content.`, - ); - } - const states = candidates.map((entry) => - inspectPresetContentGatewayState({ - readPolicy: () => rawPolicy, - parseCurrentPolicy: parseCurrentPolicyOrEmpty, - extractPresetEntries, - presetContent: entry.content, - policyKey, - }), - ); - if (states.includes("match")) return true; - if (states.includes(null)) { - throw new Error( - `Could not determine live policy ownership for '${policyKey}' in sandbox '${sandboxName}'; refusing to reconcile overlapping built-in policy content.`, - ); - } - return false; -} - -/** Drop built-in registry attribution without mutating overlapping live policy content. */ -function removeBuiltinPresetAttribution(sandboxName: string, presetName: string): void { - const sandbox = registry.getSandbox(sandboxName); - if (!sandbox) return; - const policies = (sandbox.policies ?? []).filter((name) => name !== presetName); - if (policies.length === (sandbox.policies ?? []).length) return; - registry.updateSandbox(sandboxName, { policies }); + const content = readCurrentSandboxPolicy(sandboxName); + if (!content) return false; + const parsed = YAML.parse(content); + if (!isPolicyDocument(parsed) || !isPolicyObject(parsed.network_policies)) return false; + return Object.keys(parsed.network_policies).some((key) => { + const decoded = parseCustomPolicyKey(key); + return decoded?.originalKey === policyKey; + }); } /** * Query the gateway for the currently loaded policy and determine which * presets are actually enforced by matching network_policies entries - * against known preset definitions. Considers both built-in presets and - * sandbox-scoped custom presets recorded in the registry. (#3590) + * against known preset definitions and live namespaced custom entries. (#3590) * * Returns an array of preset names whose network_policies keys are all * found in the gateway's loaded policy, or `null` when the gateway @@ -3703,7 +2632,7 @@ function getGatewayPresets(sandboxName: string, timeoutMs?: number): string[] | } catch { sandboxAgent = null; } - return inspectGatewayPresetNames({ + const builtins = inspectGatewayPresetNames({ readPolicy: () => runCapture(buildPolicyGetFullCommand(sandboxName), { ignoreError: true, @@ -3716,12 +2645,10 @@ function getGatewayPresets(sandboxName: string, timeoutMs?: number): string[] | name: preset.name, content: loadPresetForSandbox(sandboxName, preset.name), })), - ...registry.getCustomPolicies(sandboxName).map((entry) => ({ - name: entry.name, - content: entry.content, - })), ], }); + if (builtins === null) return null; + return [...new Set([...builtins, ...listCustomPresets(sandboxName).map((entry) => entry.name)])]; } /** @@ -3734,7 +2661,7 @@ function getPresetContentGatewayState( policyKey?: string, ): "match" | "absent" | "drift" | null { return inspectPresetContentGatewayState({ - readPolicy: () => runCapture(buildPolicyGetCommand(sandboxName)), + readPolicy: () => readCurrentSandboxPolicy(sandboxName) ?? "", parseCurrentPolicy: parseCurrentPolicyOrEmpty, extractPresetEntries, presetContent, @@ -3828,8 +2755,7 @@ function applyPermissivePolicy(sandboxName: string): void { } const operation = "apply the permissive sandbox policy"; - const authority = preparePolicyMutationAuthority(sandboxName, operation); - assertNemoClawManagedPolicy(authority, operation); + const context = preparePolicyMutationContext(sandboxName, operation); const policyPath = resolvePermissivePolicyPath(sandboxName); if (!fs.existsSync(policyPath)) { @@ -3843,18 +2769,10 @@ function applyPermissivePolicy(sandboxName: string): void { console.log(" Applying permissive policy..."); assertOpenshellResolvable(); - recheckPolicyMutationAuthority(sandboxName, operation, authority); - setReceiptBoundPolicyDocument(sandboxName, materializedPolicy, { - gatewayName: authority.gatewayName, + recheckPolicyMutationContext(sandboxName, operation, context); + setPolicyDocument(sandboxName, materializedPolicy, { + context, }); - const observed = inspectPolicyMutationAuthority( - sandboxName, - operation, - authority.gatewayName, - true, - ); - assertRecordedPolicyAuthority(authority.authority, observed.authority, operation); - assertNemoClawManagedPolicy(observed, operation); console.log(" Applied permissive policy."); } @@ -3874,16 +2792,13 @@ export { extractPresetEntries, filterSetupPolicyPresets, getAppliedPresets, - getBaselineExclusionRuntimeStatus, getGatewayPresets, - getLiveSandboxPolicyEntryDigest, getOpenClawNpmCompatibilityState, getPresetContentGatewayState, getPresetEndpoints, getPresetValidationWarning, getSandboxBaselineEntry, getSandboxBaselineEntryDigest, - isAgentBasePreset, isMessagingChannelPolicyPreset, listCustomPresets, listPresets, @@ -3904,12 +2819,10 @@ export { parsePresetPolicyKeys, prepareTrustedPrivatePolicyPresets, presetContentMatchesGateway, - removeBuiltinPresetAttribution, removePreset, removePresetFromPolicy, reconcileTeamsOutlookLoginCredentialBinding, renderPresetScope, - replayTrustedPrivatePolicyPinCapability, resolveAgentBaselinePolicy, resolvePermissivePolicyPath, resolveSandboxBaselinePolicy, diff --git a/src/lib/policy/managed-policy-binding.test.ts b/src/lib/policy/managed-policy-binding.test.ts deleted file mode 100644 index 2165b5a4f2f..00000000000 --- a/src/lib/policy/managed-policy-binding.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { - ManagedPolicyBinding, - type ManagedPolicyBindingRuntime, - type ManagedPolicyContentState, -} from "./managed-policy-binding"; - -const CONTENT = "network_policies:\n managed-key:\n name: managed-key\n"; - -function runtime(states: ManagedPolicyContentState[] = ["match", "absent"]) { - const stateQueue = [...states]; - return { - getPresetContentGatewayState: vi.fn(() => stateQueue.shift() ?? null), - loadPresetForSandbox: vi.fn(() => CONTENT), - removePreset: vi.fn(() => true), - } as unknown as ManagedPolicyBindingRuntime; -} - -describe("managed policy binding", () => { - const binding = new ManagedPolicyBinding({ - presetName: "managed-preset", - policyKey: "managed-key", - }); - - it("normalizes preset identity and registry attribution", () => { - expect(binding.matchesPreset(" Managed-Preset ")).toBe(true); - expect(binding.setAttribution(["npm", "MANAGED-PRESET"], true)).toEqual([ - "npm", - "managed-preset", - ]); - expect(binding.setAttribution(["npm", "managed-preset"], false)).toEqual(["npm"]); - }); - - it("requires matching policy keys and exact live content for custom ownership", () => { - const deps = runtime(["drift", "match"]); - expect(binding.hasLiveCustomOwner("alpha", [CONTENT, CONTENT], deps)).toBe(true); - expect(deps.getPresetContentGatewayState).toHaveBeenNthCalledWith( - 1, - "alpha", - CONTENT, - "managed-key", - ); - expect(binding.hasLiveCustomOwner("alpha", ["network_policies:\n other: {}\n"], deps)).toBe( - false, - ); - }); - - it("aborts managed reconciliation when custom ownership is indeterminate", () => { - const deps = runtime([null]); - expect(() => binding.hasLiveCustomOwner("alpha", [CONTENT], deps)).toThrow( - /Could not determine live policy ownership.*refusing to reconcile/, - ); - }); - - it("aborts before inspection when registered custom content is malformed", () => { - const deps = runtime(); - expect(() => - binding.hasLiveCustomOwner("alpha", ["network_policies:\n managed-key: [invalid"], deps), - ).toThrow(/Could not determine live policy ownership.*refusing to reconcile/); - expect(deps.getPresetContentGatewayState).not.toHaveBeenCalled(); - }); - - it("loads and inspects managed content without exposing policy read failures", () => { - const deps = runtime(["match"]); - expect(binding.load("alpha", deps)).toEqual({ content: CONTENT, state: "match" }); - vi.mocked(deps.loadPresetForSandbox).mockImplementation(() => { - throw new Error("gateway unavailable"); - }); - expect(binding.load("alpha", deps)).toEqual({ content: null, state: null }); - }); - - it("removes only exact managed content and verifies absence afterward", () => { - const deps = runtime(["match", "absent"]); - expect(binding.removeExact("alpha", CONTENT, deps)).toMatchObject({ - before: "match", - after: "absent", - attempted: true, - reportedSuccess: true, - failureDetail: null, - verifiedAbsent: true, - }); - expect(deps.removePreset).toHaveBeenCalledWith("alpha", "managed-preset"); - }); - - it("retains an actionable failure when removal cannot prove absence", () => { - const deps = runtime(["match", "drift"]); - vi.mocked(deps.removePreset).mockReturnValue(false); - expect(binding.removeExact("alpha", CONTENT, deps)).toMatchObject({ - after: "drift", - reportedSuccess: false, - failureDetail: "remove failed; post-remove content drifted", - verifiedAbsent: false, - }); - }); -}); diff --git a/src/lib/policy/managed-policy-binding.ts b/src/lib/policy/managed-policy-binding.ts deleted file mode 100644 index 9507f9f329d..00000000000 --- a/src/lib/policy/managed-policy-binding.ts +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { parseNetworkPolicies } from "./preset-parsing"; - -export type ManagedPolicyContentState = "match" | "absent" | "drift" | null; - -export type ManagedPolicyBindingRuntime = Pick< - typeof import("./index"), - "getPresetContentGatewayState" | "loadPresetForSandbox" | "removePreset" ->; - -export interface ManagedPolicyBindingRemovalInput { - knownBefore?: ManagedPolicyContentState; - removeOptions?: Parameters[2]; -} - -export interface ManagedPolicyBindingRemovalResult { - before: ManagedPolicyContentState; - after: ManagedPolicyContentState; - attempted: boolean; - reportedSuccess: boolean | null; - errorMessage: string | null; - failureDetail: string | null; - verifiedAbsent: boolean; -} - -/** Exact-content ownership and removal contract for one NemoClaw-managed policy preset. */ -export class ManagedPolicyBinding { - readonly presetName: string; - readonly policyKey: string; - - constructor(input: { presetName: string; policyKey?: string }) { - this.presetName = input.presetName.trim().toLowerCase(); - this.policyKey = (input.policyKey ?? input.presetName).trim().toLowerCase(); - } - - matchesPreset(name: string): boolean { - return name.trim().toLowerCase() === this.presetName; - } - - private contentOwnershipState(content: string): boolean | null { - try { - const policies = parseNetworkPolicies(content); - return policies === null - ? null - : Object.prototype.hasOwnProperty.call(policies, this.policyKey); - } catch { - return null; - } - } - - ownsContent(content: string): boolean { - return this.contentOwnershipState(content) === true; - } - - inspectContent( - sandboxName: string, - content: string, - runtime: ManagedPolicyBindingRuntime, - policyKey?: string, - ): ManagedPolicyContentState { - try { - return policyKey === undefined - ? runtime.getPresetContentGatewayState(sandboxName, content) - : runtime.getPresetContentGatewayState(sandboxName, content, policyKey); - } catch { - return null; - } - } - - load( - sandboxName: string, - runtime: ManagedPolicyBindingRuntime, - ): { content: string | null; state: ManagedPolicyContentState } { - let content: string | null = null; - try { - content = runtime.loadPresetForSandbox(sandboxName, this.presetName); - } catch { - content = null; - } - return { - content, - state: content ? this.inspectContent(sandboxName, content, runtime) : null, - }; - } - - hasLiveCustomOwner( - sandboxName: string, - contents: readonly string[], - runtime: ManagedPolicyBindingRuntime, - ): boolean { - let indeterminate = false; - for (const content of contents) { - const ownsContent = this.contentOwnershipState(content); - if (ownsContent === null) { - indeterminate = true; - continue; - } - if (!ownsContent) continue; - const state = this.inspectContent(sandboxName, content, runtime, this.policyKey); - if (state === "match") return true; - if (state === null) indeterminate = true; - } - if (indeterminate) { - throw new Error( - `Could not determine live policy ownership for '${this.policyKey}' in sandbox '${sandboxName}'; refusing to reconcile overlapping managed policy content.`, - ); - } - return false; - } - - setAttribution(names: readonly string[], enabled: boolean): string[] { - const withoutBinding = names.filter((name) => !this.matchesPreset(name)); - return enabled ? [...withoutBinding, this.presetName] : withoutBinding; - } - - removeExact( - sandboxName: string, - content: string, - runtime: ManagedPolicyBindingRuntime, - input: ManagedPolicyBindingRemovalInput = {}, - ): ManagedPolicyBindingRemovalResult { - const before = - input.knownBefore === undefined - ? this.inspectContent(sandboxName, content, runtime) - : input.knownBefore; - if (before !== "match") { - return { - before, - after: before, - attempted: false, - reportedSuccess: null, - errorMessage: null, - failureDetail: null, - verifiedAbsent: before === "absent", - }; - } - - let reportedSuccess = false; - let errorMessage: string | null = null; - try { - reportedSuccess = - input.removeOptions === undefined - ? runtime.removePreset(sandboxName, this.presetName) - : runtime.removePreset(sandboxName, this.presetName, input.removeOptions); - } catch (error) { - errorMessage = error instanceof Error ? error.message : String(error); - } - const after = this.inspectContent(sandboxName, content, runtime); - const mutationFailure = errorMessage - ? `remove: ${errorMessage}` - : reportedSuccess - ? null - : "remove failed"; - const stateFailure = - after === "absent" - ? null - : after === "match" - ? "exact content still live after remove" - : after === "drift" - ? "post-remove content drifted" - : "post-remove state unavailable"; - return { - before, - after, - attempted: true, - reportedSuccess, - errorMessage, - failureDetail: - mutationFailure && stateFailure - ? `${mutationFailure}; ${stateFailure}` - : (mutationFailure ?? stateFailure), - verifiedAbsent: after === "absent", - }; - } -} diff --git a/src/lib/policy/merge.ts b/src/lib/policy/merge.ts index c9782bf40ff..3de5e3a3910 100644 --- a/src/lib/policy/merge.ts +++ b/src/lib/policy/merge.ts @@ -2,22 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { - assertExternalPolicyRequirementContainment as assertCanonicalExternalPolicyRequirementContainment, assertPolicyRequirementContainment as assertCanonicalPolicyRequirementContainment, - assertMatchingPolicyAuthority as assertCanonicalMatchingPolicyAuthority, - assertNemoClawPolicyCreationReceiptMatches as assertCanonicalNemoClawPolicyCreationReceiptMatches, classifyOpenShellGlobalPolicyHistory as classifyCanonicalOpenShellGlobalPolicyHistory, - parseActiveGlobalPolicyAuthorityMetadata as parseCanonicalActiveGlobalPolicyAuthorityMetadata, - parseNemoClawPolicyCreationReceipt as parseCanonicalNemoClawPolicyCreationReceipt, + parseActiveGlobalPolicyMetadata as parseCanonicalActiveGlobalPolicyMetadata, parseOpenShellPolicy as parseCanonicalOpenShellPolicy, - parseSandboxPolicyAuthorityMetadata as parseCanonicalSandboxPolicyAuthorityMetadata, + parseSandboxPolicyMetadata as parseCanonicalSandboxPolicyMetadata, stripProviderComposedPolicies as stripCanonicalProviderComposedPolicies, - type NemoClawPolicyCreationReceipt, type ActiveGlobalPolicyInspection, - type OpenShellPolicyAuthority, type OpenShellPolicyIdentity, type OpenShellGlobalPolicyHistoryState, - type SandboxPolicyAuthorityInspection, + type OpenShellPolicyInspection, withoutProviderComposedPolicies as withoutCanonicalProviderComposedPolicies, } from "../../../nemoclaw/dist/shared/openshell-policy-boundary.cjs"; @@ -28,24 +22,15 @@ import type { JsonObject } from "../core/json-types"; // CommonJS wrapper is compiled. Keep this file implementation-free. export const parseOpenShellPolicy = parseCanonicalOpenShellPolicy; export const classifyOpenShellGlobalPolicyHistory = classifyCanonicalOpenShellGlobalPolicyHistory; -export const parseNemoClawPolicyCreationReceipt = parseCanonicalNemoClawPolicyCreationReceipt; -export const parseActiveGlobalPolicyAuthorityMetadata = - parseCanonicalActiveGlobalPolicyAuthorityMetadata; -export const assertNemoClawPolicyCreationReceiptMatches = - assertCanonicalNemoClawPolicyCreationReceiptMatches; +export const parseActiveGlobalPolicyMetadata = parseCanonicalActiveGlobalPolicyMetadata; export const stripProviderComposedPolicies = stripCanonicalProviderComposedPolicies; -export const parseSandboxPolicyAuthorityMetadata = parseCanonicalSandboxPolicyAuthorityMetadata; -export const assertMatchingPolicyAuthority = assertCanonicalMatchingPolicyAuthority; -export const assertExternalPolicyRequirementContainment = - assertCanonicalExternalPolicyRequirementContainment; +export const parseSandboxPolicyMetadata = parseCanonicalSandboxPolicyMetadata; export const assertPolicyRequirementContainment = assertCanonicalPolicyRequirementContainment; export type { ActiveGlobalPolicyInspection, - NemoClawPolicyCreationReceipt, - OpenShellPolicyAuthority, OpenShellPolicyIdentity, OpenShellGlobalPolicyHistoryState, - SandboxPolicyAuthorityInspection, + OpenShellPolicyInspection, }; export function withoutProviderComposedPolicies(policies: JsonObject): JsonObject { diff --git a/src/lib/policy/policy-apply-finality.test.ts b/src/lib/policy/policy-apply-finality.test.ts index 5cae22f8984..67f511acaea 100644 --- a/src/lib/policy/policy-apply-finality.test.ts +++ b/src/lib/policy/policy-apply-finality.test.ts @@ -7,46 +7,43 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { - managedPolicyInspection, + livePolicyInspection, managedSandboxEntry, SANDBOX_IDENTITY, -} from "../../../test/helpers/managed-policy-receipt-fixture"; +} from "../../../test/helpers/live-policy-fixture"; const { - addCustomPolicy, + captureSandboxBasePolicy, getSandbox, inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, + inspectSandboxPolicy, resolveOpenshell, run, - runCapture, updateSandbox, } = vi.hoisted(() => ({ - addCustomPolicy: vi.fn(), + captureSandboxBasePolicy: vi.fn(), getSandbox: vi.fn(), inspectOpenShellSandboxIdentityFingerprint: vi.fn(), - inspectSandboxPolicyAuthority: vi.fn(), + inspectSandboxPolicy: vi.fn(), resolveOpenshell: vi.fn(), run: vi.fn(), - runCapture: vi.fn(), updateSandbox: vi.fn(), })); -vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../adapters/openshell/policy-state", async (importOriginal) => ({ + ...(await importOriginal()), + captureSandboxBasePolicy, inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, + inspectSandboxPolicy, })); vi.mock("../runner", async (importOriginal) => ({ ...(await importOriginal()), run, - runCapture, })); vi.mock("../state/registry", async (importOriginal) => ({ ...(await importOriginal()), - addCustomPolicy, getSandbox, updateSandbox, })); @@ -157,19 +154,18 @@ function removeTemporaryDirectory(directory: string, removeDirectory: typeof fs. describe("applyPresets finality when openshell rejects the composed policy", () => { beforeEach(() => { + captureSandboxBasePolicy.mockReset(); run.mockReset(); - runCapture.mockReset(); getSandbox.mockReset(); inspectOpenShellSandboxIdentityFingerprint.mockReset(); - inspectSandboxPolicyAuthority.mockReset(); + inspectSandboxPolicy.mockReset(); updateSandbox.mockReset(); - addCustomPolicy.mockReset(); resolveOpenshell.mockReset(); resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - inspectSandboxPolicyAuthority.mockReturnValue(managedPolicyInspection()); - runCapture.mockReturnValue(BASE_POLICY); + inspectSandboxPolicy.mockReturnValue(livePolicyInspection()); + captureSandboxBasePolicy.mockReturnValue(BASE_POLICY); getSandbox.mockReturnValue(managedSandboxEntry(SANDBOX)); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); @@ -208,7 +204,6 @@ describe("applyPresets finality when openshell rejects the composed policy", () expect(applyWeatherPreset()).toBeInstanceOf(Error); expect(updateSandbox).not.toHaveBeenCalled(); - expect(addCustomPolicy).not.toHaveBeenCalled(); }); it("leaves local preset attribution unwritten when the outcome is unknown (#9206)", () => { @@ -218,9 +213,10 @@ describe("applyPresets finality when openshell rejects the composed policy", () const error = applyWeatherPreset(); - expect((error as Error).message).toContain("read the current policy back before retrying"); + expect((error as Error).message).toContain( + "The current live policy differs from the requested document", + ); expect(updateSandbox).not.toHaveBeenCalled(); - expect(addCustomPolicy).not.toHaveBeenCalled(); }); }); @@ -234,18 +230,18 @@ describe("single-preset mutations when openshell rejects the composed policy", ( const REJECTION_MESSAGE = "unsupported field in network_policies.weather"; beforeEach(() => { + captureSandboxBasePolicy.mockReset(); run.mockReset(); - runCapture.mockReset(); getSandbox.mockReset(); inspectOpenShellSandboxIdentityFingerprint.mockReset(); - inspectSandboxPolicyAuthority.mockReset(); + inspectSandboxPolicy.mockReset(); updateSandbox.mockReset(); - addCustomPolicy.mockReset(); resolveOpenshell.mockReset(); resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - inspectSandboxPolicyAuthority.mockReturnValue(managedPolicyInspection()); + inspectSandboxPolicy.mockReturnValue(livePolicyInspection()); + captureSandboxBasePolicy.mockReturnValue(BASE_POLICY); getSandbox.mockReturnValue({ ...managedSandboxEntry(SANDBOX), policies: ["weather"] }); run.mockReturnValue(policySetResult(openshellRejection(REJECTION_MESSAGE))); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -253,7 +249,7 @@ describe("single-preset mutations when openshell rejects the composed policy", ( }); it("returns false from a nonFatal removePreset and reports the OpenShell message (#9206)", () => { - runCapture.mockReturnValue(BASE_POLICY_WITH_WEATHER); + captureSandboxBasePolicy.mockReturnValue(BASE_POLICY_WITH_WEATHER); expect(removePreset(SANDBOX, "weather", { nonFatal: true })).toBe(false); expect(reportedText()).toContain(REJECTION_MESSAGE); @@ -262,18 +258,14 @@ describe("single-preset mutations when openshell rejects the composed policy", ( }); it("returns false from a nonFatal applyPresetContent and reports the OpenShell message (#9206)", () => { - runCapture.mockReturnValue(BASE_POLICY); - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET_CONTENT, { nonFatal: true })).toBe( false, ); expect(reportedText()).toContain(REJECTION_MESSAGE); expect(updateSandbox).not.toHaveBeenCalled(); - expect(addCustomPolicy).not.toHaveBeenCalled(); }); it("redacts a credential-shaped token before reporting a nonFatal failure (#9206)", () => { - runCapture.mockReturnValue(BASE_POLICY); run.mockReturnValue( policySetResult(openshellRejection(`rejected: header carries ${CREDENTIAL_TOKEN}`)), ); @@ -287,18 +279,18 @@ describe("single-preset mutations when openshell rejects the composed policy", ( describe("applyPresets temporary policy material under local I/O failure", () => { beforeEach(() => { + captureSandboxBasePolicy.mockReset(); run.mockReset(); - runCapture.mockReset(); getSandbox.mockReset(); inspectOpenShellSandboxIdentityFingerprint.mockReset(); - inspectSandboxPolicyAuthority.mockReset(); + inspectSandboxPolicy.mockReset(); updateSandbox.mockReset(); resolveOpenshell.mockReset(); resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - inspectSandboxPolicyAuthority.mockReturnValue(managedPolicyInspection()); - runCapture.mockReturnValue(BASE_POLICY); + inspectSandboxPolicy.mockReturnValue(livePolicyInspection()); + captureSandboxBasePolicy.mockReturnValue(BASE_POLICY); getSandbox.mockReturnValue(managedSandboxEntry(SANDBOX)); run.mockReturnValue(policySetResult(openshellRejection("refused"))); vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/lib/policy/policy-list-display.ts b/src/lib/policy/policy-list-display.ts index 733dcfda5dc..a99a221d270 100644 --- a/src/lib/policy/policy-list-display.ts +++ b/src/lib/policy/policy-list-display.ts @@ -5,34 +5,18 @@ import { formatPresetProvenanceSuffix, type PresetProvenanceContext } from "./pr interface PolicyListPresetRowOptions { preset: { name: string; description: string }; - inRegistry: boolean; - inGateway: boolean | null; + observedInOpenShell: boolean | null; provenanceContext: PresetProvenanceContext; } -/** Render one policy-list row from reconciled registry and gateway state. */ +/** Render one policy-list row from current OpenShell state. */ export function formatPolicyListPresetRow(options: PolicyListPresetRowOptions): string { - const { preset, inRegistry, inGateway, provenanceContext } = options; - let marker: "●" | "○"; - let stateSuffix = ""; - if (inGateway === null) { - marker = inRegistry ? "●" : "○"; - } else if (inRegistry && inGateway) { - marker = "●"; - } else if (!inRegistry && !inGateway) { - marker = "○"; - } else if (inGateway) { - marker = "●"; - stateSuffix = " (active on gateway, missing from local state)"; - } else { - marker = "○"; - stateSuffix = " (recorded locally, not active on gateway)"; - } + const { preset, observedInOpenShell, provenanceContext } = options; + const marker = observedInOpenShell === true ? "●" : "○"; const provenanceSuffix = formatPresetProvenanceSuffix(preset.name, provenanceContext, { active: marker === "●", - inRegistry, - inGateway, + observedInOpenShell, }); - return ` ${marker} ${preset.name}${provenanceSuffix} — ${preset.description}${stateSuffix}`; + return ` ${marker} ${preset.name}${provenanceSuffix} — ${preset.description}`; } diff --git a/src/lib/policy/policy-live-state.test.ts b/src/lib/policy/policy-live-state.test.ts new file mode 100644 index 00000000000..5c6366332be --- /dev/null +++ b/src/lib/policy/policy-live-state.test.ts @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import YAML from "yaml"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PolicyObservationError } from "../adapters/openshell/policy-state"; +import { digestBaselineEntry } from "./baseline-exclusion"; + +const mocks = vi.hoisted(() => ({ + captureSandboxBasePolicy: vi.fn(), + getSandbox: vi.fn(), + inspectSandboxPolicy: vi.fn(), + resolveOpenshell: vi.fn(), + run: vi.fn(), + runCapture: vi.fn(), +})); + +vi.mock("../adapters/openshell/policy-state", async (importOriginal) => ({ + ...(await importOriginal()), + captureSandboxBasePolicy: mocks.captureSandboxBasePolicy, + inspectSandboxPolicy: mocks.inspectSandboxPolicy, +})); +vi.mock("../adapters/openshell/resolve", async (importOriginal) => ({ + ...(await importOriginal()), + resolveOpenshell: mocks.resolveOpenshell, +})); +vi.mock("../runner", async (importOriginal) => ({ + ...(await importOriginal()), + run: mocks.run, + runCapture: mocks.runCapture, +})); +vi.mock("../state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: mocks.getSandbox, +})); + +import { + applyPresetContent, + applyPresets, + excludeBaselineEntry, + inspectPolicyMutationContext, + removePreset, + restoreBaselineEntry, + setPolicyDocument, +} from "./index"; + +const sandboxName = "live-policy"; +const preset = `preset:\n name: weather\n description: Weather\nnetwork_policies:\n weather:\n endpoints:\n - host: wttr.in\n port: 443\n`; +const hostEntry = { endpoints: [{ host: "approved.example.com", port: 443 }] }; + +describe("live OpenShell policy mutations", () => { + let livePolicy: string; + + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + livePolicy = YAML.stringify({ + version: 1, + network_policies: { host_approval: hostEntry }, + }); + mocks.getSandbox.mockReturnValue({ name: sandboxName, gatewayName: "nemoclaw" }); + mocks.inspectSandboxPolicy.mockImplementation(() => ({ + policySource: "sandbox", + effectivePolicy: YAML.parse(livePolicy), + policy: YAML.parse(livePolicy), + policyIdentity: { hash: "sha256:live", activeVersion: 1 }, + })); + mocks.captureSandboxBasePolicy.mockImplementation(() => livePolicy); + mocks.runCapture.mockImplementation(() => livePolicy); + mocks.resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); + mocks.run.mockImplementation((command: readonly string[]) => { + const policyIndex = command.indexOf("--policy"); + livePolicy = fs.readFileSync(command[policyIndex + 1] as string, "utf8"); + return { status: 0 }; + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + it("uses live policy state without a registry owner or receipt", () => { + expect(inspectPolicyMutationContext(sandboxName, "inspect policy")).toEqual( + expect.objectContaining({ gatewayName: "nemoclaw" }), + ); + expect(inspectPolicyMutationContext(sandboxName, "inspect policy")).not.toHaveProperty( + "authority", + ); + }); + + it("preserves an out-of-band host entry while adding and removing a preset", () => { + expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(true); + expect(YAML.parse(livePolicy).network_policies).toEqual( + expect.objectContaining({ host_approval: hostEntry, weather: expect.any(Object) }), + ); + + expect(removePreset(sandboxName, "weather", { nonFatal: true })).toBe(true); + expect(YAML.parse(livePolicy).network_policies).toEqual({ host_approval: hostEntry }); + }); + + it("does not overwrite a host edit that races a prepared full-policy update", () => { + let observations = 0; + mocks.inspectSandboxPolicy.mockImplementation(() => { + observations += 1; + livePolicy = + observations === 2 + ? YAML.stringify({ + version: 1, + network_policies: { + host_approval: hostEntry, + concurrent_host_edit: { + endpoints: [{ host: "concurrent.example.com", port: 443 }], + }, + }, + }) + : livePolicy; + const policy = YAML.parse(livePolicy); + return { + policySource: "sandbox", + effectivePolicy: policy, + policy, + policyIdentity: { + hash: `sha256:live-${String(observations)}`, + activeVersion: observations, + }, + }; + }); + + expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(false); + expect(mocks.run).not.toHaveBeenCalled(); + expect(YAML.parse(livePolicy).network_policies).toHaveProperty("concurrent_host_edit"); + }); + + it("accepts an ambiguous write only when live readback matches", () => { + const desiredPolicy = YAML.stringify({ + version: 1, + network_policies: { host_approval: hostEntry, confirmed_after_reset: {} }, + }); + mocks.run.mockImplementation((command: readonly string[]) => { + const policyIndex = command.indexOf("--policy"); + livePolicy = fs.readFileSync(command[policyIndex + 1] as string, "utf8"); + return { status: 3, stderr: "openshell: response stream reset" }; + }); + + expect(setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(true); + expect(mocks.captureSandboxBasePolicy).toHaveBeenCalledWith(sandboxName, "nemoclaw"); + }); + + it("rejects an ambiguous write when live readback differs", () => { + const desiredPolicy = YAML.stringify({ + version: 1, + network_policies: { requested_but_absent: {} }, + }); + mocks.run.mockReturnValue({ status: 3, stderr: "openshell: response stream reset" }); + + expect(setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(false); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("The current live policy differs from the requested document"), + ); + }); + + it("rejects an ambiguous write when live readback is unavailable", () => { + const desiredPolicy = YAML.stringify({ version: 1, network_policies: {} }); + mocks.run.mockReturnValue({ status: 3, stderr: "openshell: response stream reset" }); + mocks.captureSandboxBasePolicy.mockImplementation(() => { + throw new PolicyObservationError("OpenShell policy read timed out"); + }); + + expect(setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(false); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("The current live policy could not be read"), + ); + }); + + it("removes one baseline entry from the bounded live policy", () => { + const baselineEntry = { + name: "npm_registry", + endpoints: [{ host: "registry.npmjs.org", port: 443 }], + }; + livePolicy = YAML.stringify({ + version: 1, + network_policies: { host_approval: hostEntry, npm_registry: baselineEntry }, + }); + + expect( + excludeBaselineEntry(sandboxName, "npm_registry", digestBaselineEntry(baselineEntry), { + nonFatal: true, + }), + ).toBe(true); + expect(YAML.parse(livePolicy).network_policies).toEqual({ host_approval: hostEntry }); + }); + + it("does not let baseline exclude or restore overwrite a concurrent host edit", () => { + const baselineEntry = { + name: "npm_registry", + endpoints: [{ host: "registry.npmjs.org", port: 443 }], + }; + const concurrentEntry = { + endpoints: [{ host: "concurrent.example.com", port: 443 }], + }; + const installRace = () => { + let observations = 0; + const observe = () => { + observations += 1; + const policy = YAML.parse(livePolicy); + return { + policySource: "sandbox", + effectivePolicy: policy, + policy, + policyIdentity: { + hash: `sha256:baseline-${String(observations)}`, + activeVersion: observations, + }, + }; + }; + const observeConcurrentEdit = () => { + const document = YAML.parse(livePolicy); + document.network_policies.concurrent_host_edit = concurrentEntry; + livePolicy = YAML.stringify(document); + return observe(); + }; + mocks.inspectSandboxPolicy + .mockReset() + .mockImplementationOnce(observe) + .mockImplementationOnce(observeConcurrentEdit) + .mockImplementation(observe); + }; + + livePolicy = YAML.stringify({ + version: 1, + network_policies: { host_approval: hostEntry, npm_registry: baselineEntry }, + }); + installRace(); + expect( + excludeBaselineEntry(sandboxName, "npm_registry", digestBaselineEntry(baselineEntry), { + nonFatal: true, + }), + ).toBe(false); + expect(YAML.parse(livePolicy).network_policies).toHaveProperty("concurrent_host_edit"); + + mocks.run.mockClear(); + livePolicy = YAML.stringify({ + version: 1, + network_policies: { host_approval: hostEntry }, + }); + installRace(); + expect(restoreBaselineEntry(sandboxName, "npm_registry", { nonFatal: true })).toBe(false); + expect(mocks.run).not.toHaveBeenCalled(); + expect(YAML.parse(livePolicy).network_policies).toHaveProperty("concurrent_host_edit"); + }); + + it("makes no mutation when the bounded base-policy adapter refuses the read", () => { + mocks.captureSandboxBasePolicy.mockImplementation(() => { + throw new PolicyObservationError("OpenShell policy read timed out"); + }); + + expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(false); + expect(removePreset(sandboxName, "weather", { nonFatal: true, presetContent: preset })).toBe( + false, + ); + expect(applyPresets(sandboxName, ["npm"])).toBe(false); + expect(mocks.run).not.toHaveBeenCalled(); + }); + + it("derives custom preset identity from namespaced OpenShell keys", () => { + expect( + applyPresetContent(sandboxName, "weather", preset, { + custom: { sourcePath: "/tmp/weather.yaml" }, + nonFatal: true, + }), + ).toBe(true); + expect(YAML.parse(livePolicy).network_policies).toHaveProperty( + "nemoclaw_custom__weather__weather", + ); + }); +}); diff --git a/src/lib/policy/policy-mutation-authority.test.ts b/src/lib/policy/policy-mutation-authority.test.ts deleted file mode 100644 index 873953b7381..00000000000 --- a/src/lib/policy/policy-mutation-authority.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -// 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"; -import YAML from "yaml"; -import fs from "node:fs"; - -const mocks = vi.hoisted(() => ({ - addCustomPolicy: vi.fn(), - beginBaselineExclusionTransition: vi.fn(), - getBaselineExclusions: vi.fn(), - getBaselineExclusionTransition: vi.fn(), - getSandbox: vi.fn(), - captureSandboxBasePolicy: vi.fn(), - inspectSandboxPolicyAuthority: vi.fn(), - inspectOpenShellSandboxIdentityFingerprint: vi.fn(), - compareAndSetSandboxPolicyCreationReceipt: vi.fn(), - resolveOpenshell: vi.fn(), - run: vi.fn(), - runCapture: vi.fn(), - updateSandbox: vi.fn(), -})); - -vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ - ...(await importOriginal()), - captureSandboxBasePolicy: mocks.captureSandboxBasePolicy, - inspectOpenShellSandboxIdentityFingerprint: mocks.inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority: mocks.inspectSandboxPolicyAuthority, -})); - -vi.mock("../adapters/openshell/resolve", async (importOriginal) => ({ - ...(await importOriginal()), - resolveOpenshell: mocks.resolveOpenshell, -})); - -vi.mock("../runner", async (importOriginal) => ({ - ...(await importOriginal()), - run: mocks.run, - runCapture: mocks.runCapture, -})); - -vi.mock("../state/registry", async (importOriginal) => ({ - ...(await importOriginal()), - addCustomPolicy: mocks.addCustomPolicy, - beginBaselineExclusionTransition: mocks.beginBaselineExclusionTransition, - compareAndSetSandboxPolicyCreationReceipt: mocks.compareAndSetSandboxPolicyCreationReceipt, - getBaselineExclusions: mocks.getBaselineExclusions, - getBaselineExclusionTransition: mocks.getBaselineExclusionTransition, - getSandbox: mocks.getSandbox, - updateSandbox: mocks.updateSandbox, -})); - -import { - applyPermissivePolicy, - applyPresetContent, - excludeBaselineEntry, - inspectPolicyMutationAuthority, - inspectPolicyRecoveryAuthority, - recheckPolicyMutationAuthority, - removePreset, - restoreBaselineEntry, -} from "./index"; -import { PolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; - -const SANDBOX = "authority-9833"; -const GATEWAY_PORT = 8080; -const LIFECYCLE_GENERATION = "00000000-0000-4000-8000-000000000001"; -const SANDBOX_IDENTITY = "a".repeat(64); -const INITIAL_POLICY_HASH = "policy-initial"; -const UPDATED_POLICY_HASH = "policy-updated"; -const BASE_POLICY = `version: 1 -network_policies: - existing: - endpoints: - - host: existing.example.com - port: 443 -`; -const WEATHER_PRESET = `preset: - name: weather - description: Read-only weather -network_policies: - weather: - name: weather - endpoints: - - host: wttr.in - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } -`; -const WEATHER_POLICY = YAML.parse(WEATHER_PRESET).network_policies.weather; - -function reportedErrors(): string { - return vi - .mocked(console.error) - .mock.calls.flat() - .map((entry) => String(entry)) - .join("\n"); -} - -describe("PolicyMutationAuthority", () => { - let sandbox: Record; - let livePolicyHash: string; - let liveBasePolicy: string; - - beforeEach(() => { - for (const mock of Object.values(mocks)) mock.mockReset(); - sandbox = { - name: SANDBOX, - gatewayName: "nemoclaw", - gatewayPort: GATEWAY_PORT, - lifecycleGeneration: LIFECYCLE_GENERATION, - lifecycleLiveIdentityFingerprint: SANDBOX_IDENTITY, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw", - gatewayPort: GATEWAY_PORT, - sandboxName: SANDBOX, - lifecycleGeneration: LIFECYCLE_GENERATION, - sandboxIdentityFingerprint: SANDBOX_IDENTITY, - policyHash: INITIAL_POLICY_HASH, - policyVersion: 1, - }, - policies: [], - }; - livePolicyHash = INITIAL_POLICY_HASH; - liveBasePolicy = BASE_POLICY; - mocks.getSandbox.mockImplementation(() => sandbox); - mocks.getBaselineExclusions.mockReturnValue([]); - mocks.getBaselineExclusionTransition.mockReturnValue(null); - mocks.inspectSandboxPolicyAuthority.mockReturnValue({ - authority: "owner-unknown", - effectivePolicy: {}, - policyIdentity: { hash: livePolicyHash, activeVersion: 1 }, - }); - mocks.inspectSandboxPolicyAuthority.mockImplementation(() => ({ - authority: "owner-unknown", - effectivePolicy: {}, - policyIdentity: { hash: livePolicyHash, activeVersion: 1 }, - })); - mocks.inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - mocks.captureSandboxBasePolicy.mockImplementation(() => liveBasePolicy); - mocks.resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); - mocks.runCapture.mockImplementation(() => liveBasePolicy); - mocks.run.mockImplementation((command: readonly string[]) => { - const policyFlag = command.indexOf("--policy"); - expect(policyFlag).toBeGreaterThanOrEqual(0); - liveBasePolicy = fs.readFileSync(command[policyFlag + 1] as string, "utf8"); - livePolicyHash = UPDATED_POLICY_HASH; - return { status: 0 }; - }); - mocks.updateSandbox.mockImplementation((_name, updates) => { - sandbox = { ...sandbox, ...updates }; - return true; - }); - mocks.compareAndSetSandboxPolicyCreationReceipt.mockImplementation( - (_name, expected, replacement) => { - expect(sandbox.policyCreationReceipt).toEqual(expected); - sandbox = { ...sandbox, policyCreationReceipt: replacement }; - return true; - }, - ); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - }); - - it("verifies an externally supplied preset without recording authority (#9833)", () => { - sandbox = { ...sandbox, policyAuthority: "externally-managed" }; - mocks.inspectSandboxPolicyAuthority.mockReturnValue({ - authority: "externally-managed", - effectivePolicy: { network_policies: { weather: WEATHER_POLICY } }, - policyIdentity: { hash: INITIAL_POLICY_HASH, activeVersion: 1 }, - }); - - expect( - applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { - custom: { sourcePath: "/tmp/weather.yaml" }, - }), - ).toBe(true); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.addCustomPolicy).not.toHaveBeenCalled(); - expect(mocks.updateSandbox).not.toHaveBeenCalled(); - }); - - it("reads verified global authority without changing the registry (#9833)", () => { - sandbox = { ...sandbox, policyAuthority: "externally-managed" }; - mocks.inspectSandboxPolicyAuthority.mockReturnValue({ - authority: "externally-managed", - effectivePolicy: { network_policies: { weather: WEATHER_POLICY } }, - policyIdentity: { hash: INITIAL_POLICY_HASH, activeVersion: 1 }, - }); - - expect(inspectPolicyRecoveryAuthority(SANDBOX, "verify Shields recovery")).toMatchObject({ - authority: "externally-managed", - authorityRecordedNow: false, - gatewayName: "nemoclaw", - }); - expect(mocks.updateSandbox).not.toHaveBeenCalled(); - }); - - it("rotates the receipt after a matching policy mutation (#9833)", () => { - mocks.captureSandboxBasePolicy.mockImplementation( - () => `Version: 2\nHash: updated\nStatus: Effective\n---\n${liveBasePolicy}`, - ); - - expect( - applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { - custom: { sourcePath: "/tmp/weather.yaml" }, - }), - ).toBe(true); - - expect(mocks.run).toHaveBeenCalledOnce(); - expect(mocks.captureSandboxBasePolicy).toHaveBeenCalledWith(SANDBOX, "nemoclaw"); - expect(mocks.compareAndSetSandboxPolicyCreationReceipt).toHaveBeenCalledOnce(); - expect(sandbox.policyCreationReceipt).toEqual( - expect.objectContaining({ policyHash: UPDATED_POLICY_HASH, policyVersion: 1 }), - ); - expect(mocks.addCustomPolicy).toHaveBeenCalledOnce(); - expect(console.log).toHaveBeenCalledWith(" Applied preset: weather"); - }); - - it.each([ - ["missing", undefined], - ["pending", { schemaVersion: 1, origin: "pending-sandbox-create" }], - ["malformed", { schemaVersion: 1, origin: "sandbox-create" }], - ])("refuses a sandbox policy with a %s receipt before mutation (#9833)", (_label, receipt) => { - sandbox = { ...sandbox, policyCreationReceipt: receipt }; - - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true })).toBe(false); - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.updateSandbox).not.toHaveBeenCalled(); - expect(reportedErrors()).toContain("policy creation receipt"); - }); - - it("refuses a replacement sandbox before mutation (#9833)", () => { - mocks.inspectOpenShellSandboxIdentityFingerprint.mockReturnValue("b".repeat(64)); - - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true })).toBe(false); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(reportedErrors()).toContain("live sandbox identity"); - }); - - it.each([ - ["gateway", { gatewayName: "replacement-gateway" }], - ["lifecycle", { lifecycleGeneration: "00000000-0000-4000-8000-000000000099" }], - ])("refuses a receipt bound to a different %s (#9833)", (_label, replacement) => { - sandbox = { - ...sandbox, - policyCreationReceipt: { - ...(sandbox.policyCreationReceipt as Record), - ...replacement, - }, - }; - - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true })).toBe(false); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(reportedErrors()).toContain("policy creation receipt"); - }); - - it("refuses a stable out-of-band policy update before mutation (#9833)", () => { - livePolicyHash = "policy-external-change"; - liveBasePolicy = `${BASE_POLICY} - external_approval: - endpoints: - - host: approved.example.com - port: 443 -`; - - const result = applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true }); - expect(result).toBe(false); - - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.compareAndSetSandboxPolicyCreationReceipt).not.toHaveBeenCalled(); - expect(reportedErrors()).toContain("creation receipt does not match the live sandbox policy"); - }); - - it("refuses live policy drift between inspection and mutation (#9833)", () => { - const recorded = inspectPolicyMutationAuthority(SANDBOX, "apply a policy preset"); - livePolicyHash = "policy-concurrent-change"; - - expect(() => - recheckPolicyMutationAuthority(SANDBOX, "apply a policy preset", recorded), - ).toThrow(/creation receipt does not match the live sandbox policy/u); - expect(mocks.run).not.toHaveBeenCalled(); - }); - - it("refuses a registry receipt that changes during live verification (#9833)", () => { - mocks.getSandbox.mockReturnValueOnce(sandbox).mockReturnValueOnce({ - ...sandbox, - policyCreationReceipt: { - ...(sandbox.policyCreationReceipt as Record), - policyHash: "concurrent-registry-change", - }, - }); - - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true })).toBe(false); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(reportedErrors()).toContain("changed during live verification"); - }); - - it("reports an incomplete update when receipt rotation fails (#9833)", () => { - mocks.compareAndSetSandboxPolicyCreationReceipt.mockReturnValue(false); - - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true })).toBe(false); - - expect(mocks.run).toHaveBeenCalledOnce(); - expect(mocks.addCustomPolicy).not.toHaveBeenCalled(); - expect(sandbox.policyCreationReceipt).toEqual( - expect.objectContaining({ policyHash: INITIAL_POLICY_HASH }), - ); - expect(reportedErrors()).toContain("policy update is incomplete"); - }); - - it("refuses external and owner-unknown mutations before side effects (#9833)", () => { - sandbox = { ...sandbox, policyAuthority: "externally-managed" }; - mocks.inspectSandboxPolicyAuthority.mockReturnValue({ - authority: "externally-managed", - effectivePolicy: {}, - policyIdentity: { hash: INITIAL_POLICY_HASH, activeVersion: 1 }, - }); - - expect(removePreset(SANDBOX, "weather", { nonFatal: true })).toBe(false); - expect(excludeBaselineEntry(SANDBOX, "existing", "reviewed-digest", { nonFatal: true })).toBe( - false, - ); - expect(restoreBaselineEntry(SANDBOX, "existing", { nonFatal: true })).toBe(false); - expect(() => applyPermissivePolicy(SANDBOX)).toThrow(PolicyAuthorityRefusalError); - - expect(mocks.getBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.updateSandbox).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/policy/preset-allowed-ips.test.ts b/src/lib/policy/preset-allowed-ips.test.ts index 11d0c54cfa2..0dc48cf6ce8 100644 --- a/src/lib/policy/preset-allowed-ips.test.ts +++ b/src/lib/policy/preset-allowed-ips.test.ts @@ -251,7 +251,7 @@ network_policies: ).toBe(false); }); - it("rejects a forged content-digest receipt before any side effects (#8176)", () => { + it("rejects a forged process-local pin capability before any side effects (#8176)", () => { const content = `preset: name: forged-private network_policies: diff --git a/src/lib/policy/preset-provenance.test.ts b/src/lib/policy/preset-provenance.test.ts index 05d4a1a4c98..028e92d531a 100644 --- a/src/lib/policy/preset-provenance.test.ts +++ b/src/lib/policy/preset-provenance.test.ts @@ -1,50 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; - -interface FakeTierPreset { - name: string; - access: string; -} -interface FakeTier { - name: string; - label: string; - description: string; - presets: FakeTierPreset[]; -} - -const { TIER_FIXTURES } = vi.hoisted(() => { - const fixtures: Record = { - balanced: { - name: "balanced", - label: "Balanced", - description: "balanced fixture", - presets: [ - { name: "npm", access: "read-write" }, - { name: "pypi", access: "read-write" }, - { name: "huggingface", access: "read-write" }, - { name: "brew", access: "read-write" }, - { name: "brave", access: "read-write" }, - ], - }, - open: { - name: "open", - label: "Open", - description: "open fixture", - presets: [ - { name: "npm", access: "read-write" }, - { name: "slack", access: "read-write" }, - { name: "weather", access: "read" }, - ], - }, - }; - return { TIER_FIXTURES: fixtures }; -}); - -vi.mock("./tiers", () => ({ - getTier: (name: string): FakeTier | undefined => TIER_FIXTURES[name], -})); +import { describe, expect, it } from "vitest"; import { classifyPresetProvenance, @@ -52,179 +9,32 @@ import { formatPresetProvenanceTag, } from "./preset-provenance"; -describe("classifyPresetProvenance", () => { - it("gives current tier-name matches precedence over fallback sources", () => { - expect(classifyPresetProvenance("npm", { tierName: "balanced" })).toEqual({ - source: "tier", - tier: "balanced", - }); - expect(classifyPresetProvenance("brave", { tierName: "balanced" })).toEqual({ - source: "tier", - tier: "balanced", - }); - }); - - it("documents current-tier attribution when a user-added preset shadows a tier name", () => { - const customPresetRegistry = { - getCustomPolicies: vi.fn(() => [ - { name: "npm", description: "sandbox-scoped custom npm policy" }, - ]), - }; - const [shadowingCustomPreset] = customPresetRegistry.getCustomPolicies(); - - // Application history is not persisted, so the display can only infer - // provenance from the current tier. Keep that limitation explicit until - // the policy registry stores per-preset source history. - expect(classifyPresetProvenance(shadowingCustomPreset.name, { tierName: "balanced" })).toEqual({ - source: "tier", - tier: "balanced", - }); - expect(customPresetRegistry.getCustomPolicies).toHaveBeenCalledOnce(); - }); - - it("classifies tier-default presets under the Open tier too", () => { - expect(classifyPresetProvenance("slack", { tierName: "open" })).toEqual({ - source: "tier", - tier: "open", - }); - }); - - it("classifies openclaw-pricing as agent-sourced for openclaw sandboxes", () => { - expect( - classifyPresetProvenance("openclaw-pricing", { - tierName: "balanced", - agentName: "openclaw", - }), - ).toEqual({ source: "agent", agent: "openclaw" }); - }); - - it("classifies openclaw-diagnostics-otel-local as openclaw-agent-sourced on openclaw sandboxes", () => { - expect( - classifyPresetProvenance("openclaw-diagnostics-otel-local", { - tierName: "balanced", - agentName: "openclaw", - }), - ).toEqual({ source: "agent", agent: "openclaw" }); - }); - - it("classifies nous-* gateway presets as hermes-agent-sourced on hermes sandboxes", () => { - expect(classifyPresetProvenance("nous-web", { tierName: "open", agentName: "hermes" })).toEqual( - { - source: "agent", - agent: "hermes", - }, - ); - expect(classifyPresetProvenance("nous-code", { agentName: "hermes" })).toEqual({ - source: "agent", - agent: "hermes", - }); - }); - - it("does not label openclaw-only presets as agent-sourced on hermes sandboxes", () => { - expect( - classifyPresetProvenance("openclaw-pricing", { - tierName: "open", - agentName: "hermes", - }), - ).toEqual({ source: "user" }); - }); - - it("does not label hermes-only presets as agent-sourced on openclaw sandboxes", () => { - expect( - classifyPresetProvenance("nous-web", { - tierName: "balanced", - agentName: "openclaw", - }), - ).toEqual({ source: "user" }); - }); - - it("does not label agent-only presets without a known agentName", () => { - expect(classifyPresetProvenance("openclaw-pricing", {})).toEqual({ source: "user" }); - expect(classifyPresetProvenance("nous-web", { agentName: null })).toEqual({ - source: "user", - }); - }); - - it("falls back to user-source for non-tier, non-agent presets", () => { - expect(classifyPresetProvenance("custom-private", { tierName: "balanced" })).toEqual({ - source: "user", - }); - }); - - it("treats missing tier context as no tier match", () => { - expect(classifyPresetProvenance("npm", {})).toEqual({ source: "user" }); - expect(classifyPresetProvenance("npm", { tierName: null })).toEqual({ - source: "user", - }); - }); - - it("normalises preset, tier, and agent casing", () => { - expect( - classifyPresetProvenance("OPENCLAW-PRICING", { - tierName: " BALANCED ", - agentName: "OpenClaw", - }), - ).toEqual({ +describe("live preset provenance", () => { + it("labels agent baseline presets", () => { + expect(classifyPresetProvenance("openclaw-pricing", { agentName: "openclaw" })).toEqual({ source: "agent", agent: "openclaw", }); - expect(classifyPresetProvenance("NPM", { tierName: " BALANCED " })).toEqual({ - source: "tier", - tier: "balanced", + expect(classifyPresetProvenance("nous-web", { agentName: "hermes" })).toEqual({ + source: "agent", + agent: "hermes", }); }); -}); - -describe("formatPresetProvenanceTag", () => { - it("renders the tier source as 'from tier'", () => { - expect(formatPresetProvenanceTag({ source: "tier", tier: "balanced" })).toBe( - "from balanced tier", - ); - }); - - it("renders the agent source as 'from agent'", () => { - expect(formatPresetProvenanceTag({ source: "agent", agent: "openclaw" })).toBe( - "from openclaw agent", - ); - expect(formatPresetProvenanceTag({ source: "agent", agent: "hermes" })).toBe( - "from hermes agent", - ); - }); - it("renders the user source as 'user-added'", () => { + it("labels every other live preset as operator-added", () => { + expect(classifyPresetProvenance("npm", { agentName: "openclaw" })).toEqual({ source: "user" }); expect(formatPresetProvenanceTag({ source: "user" })).toBe("user-added"); }); -}); -describe("formatPresetProvenanceSuffix", () => { - it("only reports inferred provenance for registry and gateway agreement", () => { - expect( - formatPresetProvenanceSuffix( - "npm", - { tierName: "balanced" }, - { active: true, inRegistry: true, inGateway: true }, - ), - ).toBe(" [from balanced tier]"); + it("reports provenance only when OpenShell confirms the active entry", () => { expect( - formatPresetProvenanceSuffix( - "npm", - { tierName: "balanced" }, - { active: true, inRegistry: false, inGateway: true }, - ), - ).toBe(" [source unverified]"); + formatPresetProvenanceSuffix("npm", {}, { active: true, observedInOpenShell: true }), + ).toBe(" [user-added]"); expect( - formatPresetProvenanceSuffix( - "npm", - { tierName: "balanced" }, - { active: true, inRegistry: true, inGateway: null }, - ), + formatPresetProvenanceSuffix("npm", {}, { active: true, observedInOpenShell: null }), ).toBe(" [source unverified (gateway unreachable)]"); expect( - formatPresetProvenanceSuffix( - "npm", - { tierName: "balanced" }, - { active: false, inRegistry: true, inGateway: false }, - ), + formatPresetProvenanceSuffix("npm", {}, { active: false, observedInOpenShell: false }), ).toBe(""); }); }); diff --git a/src/lib/policy/preset-provenance.ts b/src/lib/policy/preset-provenance.ts index 3ad059e2329..2a2d3d8aefa 100644 --- a/src/lib/policy/preset-provenance.ts +++ b/src/lib/policy/preset-provenance.ts @@ -3,43 +3,30 @@ import { HERMES_TOOL_GATEWAY_PRESET_NAMES } from "../onboard/hermes-managed-tools"; import { OPENCLAW_ONLY_POLICY_PRESETS } from "../onboard/openclaw-otel-policy-presets"; -import { getTier } from "./tiers"; export type PresetProvenance = - | { source: "tier"; tier: string } | { source: "agent"; agent: "openclaw" | "hermes" } | { source: "user" }; export interface PresetProvenanceContext { - tierName?: string | null; agentName?: string | null; } export interface PresetVerificationState { active: boolean; - inRegistry: boolean; - inGateway: boolean | null; + observedInOpenShell: boolean | null; } /** - * Infer display-only provenance from the sandbox's current tier and agent. - * A current tier-name match takes precedence over agent and user fallbacks; - * application history is not persisted, so a later user-added preset that - * shadows a tier name is intentionally displayed as tier-derived. + * Infer display-only provenance from the agent baseline. All other live + * entries are operator-added; NemoClaw does not persist policy history. */ export function classifyPresetProvenance( presetName: string, context: PresetProvenanceContext = {}, ): PresetProvenance { const name = presetName.trim().toLowerCase(); - const tierName = context.tierName?.trim().toLowerCase() || null; const agentName = context.agentName?.trim().toLowerCase() ?? null; - if (tierName) { - const tierDef = getTier(tierName); - if (tierDef?.presets.some((preset) => preset.name === name)) { - return { source: "tier", tier: tierDef.name }; - } - } if (agentName === "openclaw" && OPENCLAW_ONLY_POLICY_PRESETS.has(name)) { return { source: "agent", agent: "openclaw" }; } @@ -51,8 +38,6 @@ export function classifyPresetProvenance( export function formatPresetProvenanceTag(provenance: PresetProvenance): string { switch (provenance.source) { - case "tier": - return `from ${provenance.tier} tier`; case "agent": return `from ${provenance.agent} agent`; case "user": @@ -67,10 +52,8 @@ export function formatPresetProvenanceSuffix( state: PresetVerificationState, ): string { if (!state.active) return ""; - if (state.inRegistry && state.inGateway === true) { + if (state.observedInOpenShell === true) { return ` [${formatPresetProvenanceTag(classifyPresetProvenance(presetName, context))}]`; } - return state.inGateway === null - ? " [source unverified (gateway unreachable)]" - : " [source unverified]"; + return " [source unverified (gateway unreachable)]"; } diff --git a/src/lib/policy/trusted-private-endpoints.test.ts b/src/lib/policy/trusted-private-endpoints.test.ts index 3f4e1516d86..8a874d35885 100644 --- a/src/lib/policy/trusted-private-endpoints.test.ts +++ b/src/lib/policy/trusted-private-endpoints.test.ts @@ -1,18 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash } from "node:crypto"; - import { describe, expect, it } from "vitest"; import YAML from "yaml"; import type { EndpointDnsLookupFn } from "../security/trusted-private-endpoint"; import { - hasTrustedPrivatePolicyPinReceipt, isTrustedPrivatePolicyPinCapability, - normalizeTrustedPrivatePolicyPinReceipt, prepareTrustedPrivatePolicyPresets, - replayTrustedPrivatePolicyPinCapability, } from "./trusted-private-endpoints"; function preset(content: string) { @@ -49,31 +44,13 @@ network_policies: document.network_policies.services.endpoints.forEach((endpoint) => { expect(endpoint.allowed_ips).toEqual(["10.20.30.40", "fd00::40"]); }); - expect(prepared.trustedPrivatePins).toMatchObject({ - version: 1, - contentDigest: expect.stringMatching(/^[a-f0-9]{64}$/), - }); - expect(hasTrustedPrivatePolicyPinReceipt(prepared.content, prepared.trustedPrivatePins)).toBe( - true, - ); expect( isTrustedPrivatePolicyPinCapability(prepared.content, prepared.trustedPrivatePinCapability), ).toBe(true); - expect( - isTrustedPrivatePolicyPinCapability(prepared.content, { - receipt: prepared.trustedPrivatePins, - }), - ).toBe(false); expect( isTrustedPrivatePolicyPinCapability( - prepared.content, - replayTrustedPrivatePolicyPinCapability(prepared.content, prepared.trustedPrivatePins), - ), - ).toBe(true); - expect( - hasTrustedPrivatePolicyPinReceipt( `${prepared.content}\n# changed`, - prepared.trustedPrivatePins, + prepared.trustedPrivatePinCapability, ), ).toBe(false); expect(input.content).not.toContain("allowed_ips"); @@ -101,63 +78,13 @@ network_policies: "10.20.30.40", "8.8.8.8", ]); - expect(hasTrustedPrivatePolicyPinReceipt(prepared.content, prepared.trustedPrivatePins)).toBe( - true, - ); + expect( + isTrustedPrivatePolicyPinCapability(prepared.content, prepared.trustedPrivatePinCapability), + ).toBe(true); expect(input.content).not.toContain("allowed_ips"); }, ); - it.each([ - { version: 1, contentDigest: "a".repeat(64) }, - { version: 2, contentDigest: "a".repeat(64) }, - { contentDigest: "a".repeat(64) }, - { version: 1, contentDigest: "short" }, - ])( - "rejects a pin receipt that is stale, malformed, or unversioned [case %#] (#8176)", - (receipt) => { - const content = "network_policies: {}\n"; - - expect(() => normalizeTrustedPrivatePolicyPinReceipt(content, receipt)).toThrow( - /does not match its exact content/, - ); - }, - ); - - it("rejects durable replay receipts that pin reserved destinations (#8176)", () => { - const content = `network_policies: - private: - endpoints: - - host: metadata.local - allowed_ips: [169.254.169.254] -`; - const receipt = { - version: 1, - contentDigest: createHash("sha256").update(content).digest("hex"), - }; - - expect(() => replayTrustedPrivatePolicyPinCapability(content, receipt)).toThrow( - /disallowed address pin/, - ); - }); - - it("rejects durable replay receipts that pin only public addresses (#8176)", () => { - const content = `network_policies: - private: - endpoints: - - host: api.corp.example - allowed_ips: [93.184.216.34] -`; - const receipt = { - version: 1, - contentDigest: createHash("sha256").update(content).digest("hex"), - }; - - expect(() => replayTrustedPrivatePolicyPinCapability(content, receipt)).toThrow( - /disallowed address pin/, - ); - }); - it("preserves the reviewed host-gateway exception beside generated private pins (#8176)", async () => { const input = preset(`preset: name: private @@ -215,7 +142,6 @@ network_policies: }); expect(prepared.content).not.toContain("allowed_ips"); - expect(prepared.trustedPrivatePins).toBeUndefined(); expect(prepared.trustedPrivatePinCapability).toBeUndefined(); }); diff --git a/src/lib/policy/trusted-private-endpoints.ts b/src/lib/policy/trusted-private-endpoints.ts index e3e6b86606b..055ec2942e6 100644 --- a/src/lib/policy/trusted-private-endpoints.ts +++ b/src/lib/policy/trusted-private-endpoints.ts @@ -19,23 +19,16 @@ export interface ExternalPolicyPreset { filePath: string; presetName: string; content: string; - trustedPrivatePins?: TrustedPrivatePolicyPinReceipt; trustedPrivatePinCapability?: TrustedPrivatePolicyPinCapability; } -export interface TrustedPrivatePolicyPinReceipt { - version: 1; - contentDigest: string; -} - declare const trustedPrivatePolicyPinCapabilityBrand: unique symbol; export interface TrustedPrivatePolicyPinCapability { - readonly receipt: TrustedPrivatePolicyPinReceipt; readonly [trustedPrivatePolicyPinCapabilityBrand]: true; } -const TRUSTED_PRIVATE_POLICY_PIN_CAPABILITIES = new WeakSet(); +const TRUSTED_PRIVATE_POLICY_PIN_CAPABILITIES = new WeakMap(); export interface TrustedPrivatePolicyPreparationDependencies { lookup?: EndpointDnsLookupFn; @@ -48,8 +41,6 @@ type EndpointReference = { host: string; }; -const SHA256_DIGEST_PATTERN = /^[a-f0-9]{64}$/; - function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -58,31 +49,23 @@ function policyContentDigest(content: string): string { return createHash("sha256").update(content).digest("hex"); } -function createTrustedPrivatePolicyPinReceipt(content: string): TrustedPrivatePolicyPinReceipt { - validateTrustedPrivatePinnedContent(content); - return { version: 1, contentDigest: policyContentDigest(content) }; -} - function isHostGatewayBridge(host: string): boolean { return host === OPENSHELL_SANDBOX_HOST_BRIDGE; } /** - * Re-parse durable generated content before it can regain process-local pin - * authority. The digest binds bytes; this check binds semantics and prevents a - * crafted registry receipt from admitting loopback, metadata, link-local, or - * other reserved destinations. The existing host-gateway bridge exception is - * separate reviewed policy authority and is ignored here. + * Validate command-generated pinned content before granting a process-local + * capability. No receipt or policy copy is persisted outside OpenShell. */ function validateTrustedPrivatePinnedContent(content: string): void { let document: unknown; try { document = YAML.parse(content) as unknown; } catch { - throw new Error("trusted private policy pin receipt content is not valid YAML"); + throw new Error("trusted private pinned policy content is not valid YAML"); } if (!isObjectRecord(document) || !isObjectRecord(document.network_policies)) { - throw new Error("trusted private policy pin receipt content has no network policies"); + throw new Error("trusted private pinned policy content has no network policies"); } let validatedPinnedEndpoint = false; @@ -127,47 +110,17 @@ function validateTrustedPrivatePinnedContent(content: string): void { } } if (!validatedPinnedEndpoint) { - throw new Error("trusted private policy pin receipt content has no generated private pins"); + throw new Error("trusted private pinned policy content has no generated private pins"); } } function issueTrustedPrivatePolicyPinCapability( - receipt: TrustedPrivatePolicyPinReceipt, -): TrustedPrivatePolicyPinCapability { - const capability = Object.freeze({ - receipt: Object.freeze({ ...receipt }), - }) as unknown as TrustedPrivatePolicyPinCapability; - TRUSTED_PRIVATE_POLICY_PIN_CAPABILITIES.add(capability); - return capability; -} - -/** Validate and clone the durable receipt bound to generated policy content. */ -export function normalizeTrustedPrivatePolicyPinReceipt( content: string, - value: unknown, -): TrustedPrivatePolicyPinReceipt | undefined { - if (value === undefined) return undefined; - if ( - !isObjectRecord(value) || - value.version !== 1 || - typeof value.contentDigest !== "string" || - !SHA256_DIGEST_PATTERN.test(value.contentDigest) || - Object.keys(value).some((key) => key !== "version" && key !== "contentDigest") || - value.contentDigest !== policyContentDigest(content) - ) { - throw new Error("trusted private policy pin receipt does not match its exact content"); - } +): TrustedPrivatePolicyPinCapability { validateTrustedPrivatePinnedContent(content); - return { version: 1, contentDigest: value.contentDigest }; -} - -/** True when a durable receipt is valid for this exact generated content. */ -export function hasTrustedPrivatePolicyPinReceipt(content: string, value: unknown): boolean { - try { - return normalizeTrustedPrivatePolicyPinReceipt(content, value) !== undefined; - } catch { - return false; - } + const capability = Object.freeze({}) as TrustedPrivatePolicyPinCapability; + TRUSTED_PRIVATE_POLICY_PIN_CAPABILITIES.set(capability, policyContentDigest(content)); + return capability; } /** True only for process-local authority issued for this exact policy content. */ @@ -178,25 +131,10 @@ export function isTrustedPrivatePolicyPinCapability( return ( typeof value === "object" && value !== null && - TRUSTED_PRIVATE_POLICY_PIN_CAPABILITIES.has(value) && - hasTrustedPrivatePolicyPinReceipt(content, (value as TrustedPrivatePolicyPinCapability).receipt) + TRUSTED_PRIVATE_POLICY_PIN_CAPABILITIES.get(value) === policyContentDigest(content) ); } -/** - * Reissue process-local authority from a validated durable registry receipt. - * The host registry is the operator-approved authority boundary for rebuild; - * snapshot content alone is deliberately insufficient to reach this function. - */ -export function replayTrustedPrivatePolicyPinCapability( - content: string, - receipt: unknown, -): TrustedPrivatePolicyPinCapability { - const normalized = normalizeTrustedPrivatePolicyPinReceipt(content, receipt); - if (!normalized) throw new Error("trusted private policy pin receipt is missing"); - return issueTrustedPrivatePolicyPinCapability(normalized); -} - function endpointUrl(host: string): string { return `https://${isIP(host) === 6 ? `[${host}]` : host}/`; } @@ -339,12 +277,10 @@ export async function prepareTrustedPrivatePolicyPresets( const content = YAML.stringify(document); const injectedPins = injectedEndpoints.size > 0; if (!injectedPins) return { ...preset, content }; - const trustedPrivatePins = createTrustedPrivatePolicyPinReceipt(content); return { ...preset, content, - trustedPrivatePins, - trustedPrivatePinCapability: issueTrustedPrivatePolicyPinCapability(trustedPrivatePins), + trustedPrivatePinCapability: issueTrustedPrivatePolicyPinCapability(content), }; }); } diff --git a/src/lib/registry-recovery-action.test.ts b/src/lib/registry-recovery-action.test.ts index 85e3b7ea417..db16c3b3a37 100644 --- a/src/lib/registry-recovery-action.test.ts +++ b/src/lib/registry-recovery-action.test.ts @@ -98,7 +98,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { sandboxName: "interrupt-test", provider: "nvidia", model: "nemotron", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, @@ -117,7 +116,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { sandboxName: "alpha", provider: "nvidia", model: "nemotron", - policyPresets: ["npm"], nimContainer: null, observabilityEnabled: true, agent: "langchain-deepagents-code", @@ -131,7 +129,7 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { expect(result.recoveredFromSession).toBe(true); const recovered = result.sandboxes.find((s) => s.name === "alpha"); expect(recovered).toBeDefined(); - expect(recovered?.policies).toEqual(["npm"]); + expect(recovered).not.toHaveProperty("policies"); expect(recovered?.observabilityEnabled).toBe(true); }); @@ -143,7 +141,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { endpointUrl: "https://inference.example.test/v1", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -171,7 +168,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { endpointUrl: null, credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: null, - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -207,7 +203,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", gpuEnabled: false, - policies: ["npm", "pypi"], nimContainer: null, agent: "hermes", agentVersion: "2026.5.16", @@ -216,7 +211,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { sandboxName: "my-hermes", provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", - policyPresets: ["npm", "pypi"], nimContainer: null, agent: "hermes", steps: { @@ -238,7 +232,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", gpuEnabled: false, - policies: [], nimContainer: null, agent: "hermes", }; @@ -246,7 +239,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { sandboxName: "my-hermes", provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -264,7 +256,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", gpuEnabled: false, - policies: [], nimContainer: null, agent: "langchain-deepagents-code", observabilityEnabled: true, @@ -273,7 +264,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { sandboxName: "alpha", provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", - policyPresets: [], nimContainer: null, agent: "langchain-deepagents-code", steps: { @@ -297,7 +287,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { provider: "nvidia", model: "nemotron", gpuEnabled: false, - policies: [], nimContainer: null, agent: null, }; @@ -305,7 +294,6 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => { sandboxName: "alpha", provider: "nvidia", model: "nemotron", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, @@ -377,7 +365,6 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", provider: "nvidia", model: "nemotron", gpuEnabled: false, - policies: [], nimContainer: null, agent: "openclaw", }; @@ -385,7 +372,6 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", sandboxName: "phantom", provider: "nvidia", model: "nemotron", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, diff --git a/src/lib/registry-recovery-action.ts b/src/lib/registry-recovery-action.ts index fbae893846a..d0fb732b023 100644 --- a/src/lib/registry-recovery-action.ts +++ b/src/lib/registry-recovery-action.ts @@ -44,7 +44,6 @@ type RecoveredSandboxMetadata = Partial< | "model" | "provider" | "gpuEnabled" - | "policies" | "nimContainer" | "agent" | "observabilityEnabled" @@ -52,9 +51,7 @@ type RecoveredSandboxMetadata = Partial< | "credentialEnv" | "preferredInferenceApi" > -> & { - policyPresets?: string[] | null; -}; +>; /** * Build a minimal-safe registry entry for a recovered sandbox from whatever @@ -70,11 +67,6 @@ function buildRecoveredSandboxEntry( model: metadata.model || null, provider: metadata.provider || null, gpuEnabled: metadata.gpuEnabled === true, - policies: Array.isArray(metadata.policies) - ? metadata.policies - : Array.isArray(metadata.policyPresets) - ? metadata.policyPresets - : [], nimContainer: metadata.nimContainer || null, endpointUrl: metadata.endpointUrl ?? null, credentialEnv: metadata.credentialEnv ?? null, @@ -228,7 +220,6 @@ function seedRecoveryMetadata( model: session.model || null, provider: session.provider || null, nimContainer: session.nimContainer || null, - policyPresets: session.policyPresets || null, agent: session.agent || null, endpointUrl: session.endpointUrl ?? null, credentialEnv: session.credentialEnv ?? null, diff --git a/src/lib/registry-recovery-seeded-paths.test.ts b/src/lib/registry-recovery-seeded-paths.test.ts index c16ee2562dc..2c2c7b47ccd 100644 --- a/src/lib/registry-recovery-seeded-paths.test.ts +++ b/src/lib/registry-recovery-seeded-paths.test.ts @@ -65,20 +65,18 @@ import { import { recoverRegistryEntries } from "./registry-recovery-action.js"; import { loadSession } from "./state/onboard-session.js"; -const gammaEntry = (policies: string[]): SandboxEntry => ({ +const gammaEntry = (_legacyPolicies: string[]): SandboxEntry => ({ name: "gamma", provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", gpuEnabled: false, - policies, }); -const completedSession = (sandboxName: string, policyPresets: string[]) => +const completedSession = (sandboxName: string, _legacyPolicyPresets: string[]) => ({ sandboxName, provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", - policyPresets, nimContainer: null, steps: { sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -122,7 +120,7 @@ describe("recoverRegistryEntries seeded recovery paths", () => { "beta", "gamma", ]); - expect(mockRegistryState.sandboxes.alpha?.policies).toEqual(["pypi"]); + expect(mockRegistryState.sandboxes.alpha).not.toHaveProperty("policies"); expect(mockRegistryState.defaultSandbox).toBe("gamma"); }); @@ -153,7 +151,6 @@ describe("recoverRegistryEntries seeded recovery paths", () => { credentialEnv: "NVIDIA_API_KEY", preferredInferenceApi: null, gpuEnabled: false, - policies: [], }; vi.mocked(loadSession).mockReturnValue({ sandboxName: "alpha", @@ -162,7 +159,6 @@ describe("recoverRegistryEntries seeded recovery paths", () => { endpointUrl: "https://historical.example.test/v1", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -207,7 +203,6 @@ describe("recoverRegistryEntries seeded recovery paths", () => { sandboxName: "phantom", provider: "nvidia", model: "nemotron", - policyPresets: [], nimContainer: null, steps: { sandbox: { status: "pending", startedAt: null, completedAt: null, error: null }, diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index d426be6ff51..bc560c3f78f 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; @@ -29,6 +30,52 @@ function createHarness(options: ShieldsFlowHarnessOptions = {}) { return createShieldsFlowHarness(requireDist, tmpDir, options); } +function writeBoundPolicySnapshot( + snapshotPath: string, + content = "version: 1\nnetwork_policies:\n test: {}\n", +) { + fs.writeFileSync(snapshotPath, content, { mode: 0o600 }); + fs.chmodSync(snapshotPath, 0o600); + const metadata = fs.statSync(snapshotPath); + return { + schemaVersion: 1 as const, + path: snapshotPath, + sha256: createHash("sha256").update(content).digest("hex"), + size: Buffer.byteLength(content), + mode: 0o600, + uid: metadata.uid, + gid: metadata.gid, + nlink: 1 as const, + }; +} + +function writeActivePolicyTransition( + stateDir: string, + sandboxName: string, + processToken: string, + snapshotPath: string, + snapshotPolicy: ReturnType, +): void { + const forwardPolicy = writeBoundPolicySnapshot( + path.join(stateDir, `policy-forward-${processToken.slice(0, 8)}.yaml`), + ); + fs.writeFileSync( + path.join(stateDir, `shields-transition-${sandboxName}-${processToken}.json`), + JSON.stringify({ + version: 1, + phase: "active", + ownerPid: 2_147_483_647, + ownerStartIdentity: "test-timer-owner", + processToken, + sandboxName, + snapshotPath, + snapshotPolicy, + forwardPolicy, + }), + { mode: 0o600 }, + ); +} + const timerAuthorityFixtures: ReadonlyArray void]> = [ ["missing", () => undefined], ["malformed", (markerPath) => fs.writeFileSync(markerPath, "{not-json")], @@ -46,7 +93,7 @@ function writeExpiredShieldsFixture( const timerMarkerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); const transitionLockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); fs.writeFileSync( path.join(stateDir, `shields-${sandboxName}.json`), JSON.stringify({ @@ -56,6 +103,7 @@ function writeExpiredShieldsFixture( shieldsDownReason: reason, shieldsDownPolicy: "permissive", shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( @@ -101,45 +149,49 @@ describe("shields command flow", () => { delete require.cache[requireDist.resolve("../cli/branding.js")]; }); - it("shieldsDown captures policy, unlocks config, saves state, and authorizes a timer", { - timeout: 30_000, - }, () => { - const harness = createHarness({ confirmOpenClawInodeFlags: true }); + it( + "shieldsDown captures policy, unlocks config, saves state, and authorizes a timer", + { + timeout: 30_000, + }, + () => { + const harness = createHarness({ confirmOpenClawInodeFlags: true }); - harness.shieldsDown("openclaw", { - timeout: "5m", - reason: "coverage", - throwOnError: true, - }); + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "coverage", + throwOnError: true, + }); - const statePath = path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"); - const state = JSON.parse(fs.readFileSync(statePath, "utf-8")); - expect(state).toMatchObject({ - shieldsDown: true, - shieldsDownTimeout: 300, - shieldsDownReason: "coverage", - shieldsDownPolicy: "permissive", - }); - expect(fs.existsSync(state.shieldsPolicySnapshotPath)).toBe(true); - expect(harness.isShieldsDown("openclaw")).toBe(true); - expect(harness.logSpy.mock.calls.flat().join("\n")).toContain( - "Config unlocked for openclaw (auto-lockdown in: 5m)", - ); - }); + const statePath = path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf-8")); + expect(state).toMatchObject({ + shieldsDown: true, + shieldsDownTimeout: 300, + shieldsDownReason: "coverage", + shieldsDownPolicy: "permissive", + }); + expect(fs.existsSync(state.shieldsPolicySnapshotPath)).toBe(true); + expect(harness.isShieldsDown("openclaw")).toBe(true); + expect(harness.logSpy.mock.calls.flat().join("\n")).toContain( + "Config unlocked for openclaw (auto-lockdown in: 5m)", + ); + }, + ); - it("restores restrictive policy when receipt finalization fails (#9833)", () => { + it("restores restrictive policy when policy verification fails (#9833)", () => { const harness = createHarness(); - harness.policyReceiptFinalizeSpy.mockImplementationOnce(() => { - throw new Error("policy receipt persistence failed"); + harness.policyVerificationSpy.mockImplementationOnce(() => { + throw new Error("policy verification failed"); }); expect(() => harness.shieldsDown("openclaw", { timeout: "5m", - reason: "receipt finalization coverage", + reason: "policy verification coverage", throwOnError: true, }), - ).toThrow("policy receipt persistence failed"); + ).toThrow("policy verification failed"); expect(harness.policySetBodies.map((body) => YAML.parse(body).network_policies)).toEqual([ {}, @@ -155,35 +207,35 @@ describe("shields command flow", () => { ).toEqual([]); }); - it("shieldsDown preserves an exact managed MCP policy and records its snapshot key (#7952)", { - timeout: 15_000, - }, () => { - const alpha = managedMcpPolicy("alpha"); - const harness = createHarness({ - livePolicyYaml: YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - mcp_bridge_alpha: alpha.networkPolicy, - }, - }), - sandboxEntry: managedMcpSandbox([alpha]), - }); + it( + "shieldsDown preserves an exact live MCP policy without shadow ownership state (#7952)", + { + timeout: 15_000, + }, + () => { + const alpha = managedMcpPolicy("alpha"); + const harness = createHarness({ + livePolicyYaml: YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + mcp_bridge_alpha: alpha.networkPolicy, + }, + }), + sandboxEntry: managedMcpSandbox([alpha]), + }); - harness.shieldsDown("openclaw", { - timeout: "5m", - reason: "managed MCP transition coverage", - throwOnError: true, - }); + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "managed MCP transition coverage", + throwOnError: true, + }); - const state = JSON.parse( - fs.readFileSync(path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"), "utf-8"), - ); - expect(state.shieldsManagedMcpPolicyKeys).toEqual(["mcp_bridge_alpha"]); - const applied = YAML.parse(harness.policySetBodies.at(-1)!); - expect(applied.network_policies.mcp_bridge_alpha).toEqual(alpha.networkPolicy); - expect(applied.network_policies).not.toHaveProperty("restrictive_baseline"); - }); + const applied = YAML.parse(harness.policySetBodies.at(-1)!); + expect(applied.network_policies.mcp_bridge_alpha).toEqual(alpha.networkPolicy); + expect(applied.network_policies).not.toHaveProperty("restrictive_baseline"); + }, + ); it("isolates same-millisecond policy snapshots and cleanup across sandboxes", () => { const fixedNow = Date.now(); @@ -302,77 +354,6 @@ describe("shields command flow", () => { expect(fs.existsSync(path.dirname(stagedPolicyPath))).toBe(false); }); - it("manual restore uses persisted MCP ownership after its transition marker clears (#7952)", () => { - const alpha = managedMcpPolicy("alpha", "8.8.8.8"); - const beta = managedMcpPolicy("beta", "1.1.1.1"); - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-managed-restore.yaml"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - snapshotPath, - YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - mcp_bridge_alpha: alpha.networkPolicy, - }, - }), - ); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: ["mcp_bridge_alpha"], - }), - ); - const harness = createHarness({ - livePolicyYaml: YAML.stringify({ - version: 1, - network_policies: { - permissive_baseline: { endpoints: [{ host: "*" }] }, - mcp_bridge_alpha: alpha.networkPolicy, - mcp_bridge_beta: beta.networkPolicy, - }, - }), - sandboxEntry: managedMcpSandbox([alpha, beta]), - }); - - const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { - transitionProcessToken: "6".repeat(32), - }); - - expect(result.status).toBe(0); - const restored = YAML.parse(harness.policySetBodies.at(-1)!); - expect(Object.keys(restored.network_policies).sort()).toEqual([ - "mcp_bridge_alpha", - "mcp_bridge_beta", - "restrictive_baseline", - ]); - expect(restored.network_policies.mcp_bridge_beta).toEqual(beta.networkPolicy); - }); - - it("refuses manual restoration when persisted MCP ownership is malformed (#7952)", () => { - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-corrupt-state.yaml"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n"); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: ["../not-a-managed-key"], - }), - ); - const harness = createHarness(); - - expect(() => harness.applyShieldsPolicySnapshot("openclaw", snapshotPath)).toThrow( - /Saved Shields MCP policy ownership is invalid/, - ); - expect(harness.policySetBodies).toHaveLength(0); - }); - it("refuses a legacy restore whose persisted state names a different snapshot (#7952)", () => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); const expectedSnapshotPath = path.join(stateDir, "policy-snapshot-expected.yaml"); @@ -390,19 +371,19 @@ describe("shields command flow", () => { const harness = createHarness(); expect(() => harness.applyShieldsPolicySnapshot("openclaw", requestedSnapshotPath)).toThrow( - /does not match the policy snapshot/, + /does not match the saved policy snapshot/, ); expect(harness.policySetBodies).toHaveLength(0); }); - it("uses token-bound transition ownership when the forward owner dies before state commit (#7952)", () => { + it("refuses recovery without a forward-policy artifact and preserves later host edits", () => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); const processToken = "8".repeat(32); const snapshotPath = path.join(stateDir, "policy-snapshot-new-cycle.yaml"); const oldSnapshotPath = path.join(stateDir, "policy-snapshot-old-cycle.yaml"); const alpha = managedMcpPolicy("alpha", "8.8.8.8"); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( + const snapshotPolicy = writeBoundPolicySnapshot( snapshotPath, YAML.stringify({ version: 1, @@ -415,7 +396,6 @@ describe("shields command flow", () => { JSON.stringify({ shieldsDown: false, shieldsPolicySnapshotPath: oldSnapshotPath, - shieldsManagedMcpPolicyKeys: [], }), ); fs.writeFileSync( @@ -428,60 +408,29 @@ describe("shields command flow", () => { processToken, sandboxName: "openclaw", snapshotPath, - managedMcpPolicyKeys: ["mcp_bridge_alpha"], + snapshotPolicy, }), ); const harness = createHarness({ livePolicyYaml: YAML.stringify({ version: 1, - network_policies: { mcp_bridge_alpha: alpha.networkPolicy }, + network_policies: { + mcp_bridge_alpha: alpha.networkPolicy, + later_host_edit: { endpoints: [{ host: "host.example.test", port: 443 }] }, + }, }), sandboxEntry: managedMcpSandbox([alpha]), }); - const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { - transitionProcessToken: processToken, - }); - - expect(result.status).toBe(0); - expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies.mcp_bridge_alpha).toEqual( - alpha.networkPolicy, - ); - }); - - it("reconciles 257 saved managed keys with the current policy (#7952)", { - timeout: 15_000, - }, () => { - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-many-managed-keys.yaml"); - const keys = Array.from({ length: 257 }, (_, index) => `mcp_bridge_server${index}`); - const currentPolicy = managedMcpPolicy("server256"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - snapshotPath, - YAML.stringify({ network_policies: Object.fromEntries(keys.map((key) => [key, {}])) }), - ); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: keys, - }), - ); - const harness = createHarness({ - livePolicyYaml: YAML.stringify({ - version: 1, - network_policies: { [currentPolicy.key]: currentPolicy.networkPolicy }, + expect(() => + harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + transitionProcessToken: processToken, }), - sandboxEntry: managedMcpSandbox([currentPolicy]), - }); - - expect(harness.applyShieldsPolicySnapshot("openclaw", snapshotPath).status).toBe(0); - const applied = YAML.parse(harness.policySetBodies.at(-1)!); - const appliedKeys = Object.keys(applied.network_policies); - expect(appliedKeys).toEqual([currentPolicy.key]); - expect(appliedKeys).toContain("mcp_bridge_server256"); + ).toThrow(/no bound forward-policy artifact/); + expect(harness.policySetBodies).toHaveLength(0); + expect( + fs.existsSync(path.join(stateDir, `shields-transition-openclaw-${processToken}.json`)), + ).toBe(true); }); it("binds manual shields-up to the active auto-restore timer generation", () => { @@ -489,9 +438,11 @@ describe("shields command flow", () => { const sandboxName = "openclaw"; const processToken = "9".repeat(32); const snapshotPath = path.join(stateDir, "policy-snapshot-manual-up.yaml"); + const forwardPath = path.join(stateDir, "policy-forward-manual-up.yaml"); const lockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + const forwardPolicy = writeBoundPolicySnapshot(forwardPath); const timerControl = requireDist("./timer-control.js") as typeof import("./timer-control.js"); vi.spyOn(timerControl, "isProcessAlive").mockReturnValue(true); vi.spyOn(timerControl, "readProcessStartIdentity").mockReturnValue("live-timer-start"); @@ -513,6 +464,7 @@ describe("shields command flow", () => { shieldsDownReason: "manual-up-token-test", shieldsDownPolicy: "permissive", shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( @@ -527,6 +479,20 @@ describe("shields command flow", () => { }), ); writeShieldsTimerAuthorizationProof(requireDist, sandboxName); + fs.writeFileSync( + path.join(stateDir, `shields-transition-${sandboxName}-${processToken}.json`), + JSON.stringify({ + version: 1, + phase: "active", + ownerPid: process.pid, + ownerStartIdentity: currentProcessStartIdentity, + processToken, + sandboxName, + snapshotPath, + snapshotPolicy, + forwardPolicy, + }), + ); let observedOwner: Record | null = null; const harness = createHarness({ @@ -582,6 +548,12 @@ describe("shields command flow", () => { stateDir, `shields-transition-${sandboxName}-${processToken}.json`, ); + const snapshotPolicy = JSON.parse( + fs.readFileSync(path.join(stateDir, `shields-${sandboxName}.json`), "utf8"), + ).shieldsPolicySnapshot; + const forwardPolicy = writeBoundPolicySnapshot( + path.join(stateDir, "policy-forward-lifecycle-owner.yaml"), + ); fs.writeFileSync( transitionPath, JSON.stringify({ @@ -592,6 +564,8 @@ describe("shields command flow", () => { processToken, sandboxName, snapshotPath: marker.snapshotPath, + snapshotPolicy, + forwardPolicy, }), ); vi.spyOn(timerControl, "isProcessAlive").mockReturnValue(true); @@ -651,11 +625,13 @@ describe("shields command flow", () => { const sandboxName = "openclaw"; const processToken = "a".repeat(32); const snapshotPath = path.join(stateDir, "policy-snapshot-race.yaml"); + const forwardPath = path.join(stateDir, "policy-forward-race.yaml"); const transitionPath = path.join( stateDir, `shields-transition-${sandboxName}-${processToken}.json`, ); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + const forwardPolicy = writeBoundPolicySnapshot(forwardPath); fs.writeFileSync( path.join(stateDir, `shields-timer-${sandboxName}.json`), JSON.stringify({ @@ -700,6 +676,8 @@ describe("shields command flow", () => { processToken, sandboxName, snapshotPath, + snapshotPolicy, + forwardPolicy, }), { mode: 0o600 }, ); @@ -774,214 +752,229 @@ describe("shields command flow", () => { it.each([ ["matching", "c".repeat(32)], ["different", "d".repeat(32)], - ])("enters durable containment for a %s-token transition whose owner exited in the recovery gap", (_tokenRelationship, transitionOwnerToken) => { - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - fs.mkdirSync(stateDir, { recursive: true }); - const sandboxName = "dead-owner"; - const processToken = "c".repeat(32); - const transitionLockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); - fs.writeFileSync( - transitionLockPath, - JSON.stringify({ - version: 1, + ])( + "enters durable containment for a %s-token transition whose owner exited in the recovery gap", + (_tokenRelationship, transitionOwnerToken) => { + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + const sandboxName = "dead-owner"; + const processToken = "c".repeat(32); + const transitionLockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); + fs.writeFileSync( + transitionLockPath, + JSON.stringify({ + version: 1, + sandboxName, + pid: 2_147_483_647, + processStartIdentity: "dead-owner", + command: "config set write", + acquiredAtMs: Date.now(), + takeoverToken: transitionOwnerToken, + }), + { mode: 0o600 }, + ); + createHarness(); + const transitionLock = requireDist("./transition-lock.js") as { + withShieldsTransitionLock: ( + sandboxName: string, + command: string, + fn: () => void, + options: { recoverStaleOwner: boolean; waitTimeoutMs: number }, + ) => void; + }; + const shields = requireDist(shieldsModulePath) as { + prepareAutoRestoreTransitionTakeover: ( + sandboxName: string, + processToken: string, + snapshotPath: string, + ) => void; + }; + const lifecycleLock = requireDist("../state/mcp-lifecycle-lock.js") as { + getMcpLifecycleLockPath: (sandboxName: string, stateDir: string) => string; + }; + const containmentPath = `${lifecycleLock.getMcpLifecycleLockPath( sandboxName, - pid: 2_147_483_647, - processStartIdentity: "dead-owner", - command: "config set write", - acquiredAtMs: Date.now(), - takeoverToken: transitionOwnerToken, - }), - { mode: 0o600 }, - ); - createHarness(); - const transitionLock = requireDist("./transition-lock.js") as { - withShieldsTransitionLock: ( - sandboxName: string, - command: string, - fn: () => void, - options: { recoverStaleOwner: boolean; waitTimeoutMs: number }, - ) => void; - }; - const shields = requireDist(shieldsModulePath) as { - prepareAutoRestoreTransitionTakeover: ( - sandboxName: string, - processToken: string, - snapshotPath: string, - ) => void; - }; - const lifecycleLock = requireDist("../state/mcp-lifecycle-lock.js") as { - getMcpLifecycleLockPath: (sandboxName: string, stateDir: string) => string; - }; - const containmentPath = `${lifecycleLock.getMcpLifecycleLockPath( - sandboxName, - stateDir, - )}.containment`; + stateDir, + )}.containment`; - expect(() => - transitionLock.withShieldsTransitionLock( - sandboxName, - "shields auto-restore contender", - () => undefined, - { - recoverStaleOwner: false, - waitTimeoutMs: 0, - }, - ), - ).toThrow("recorded owner PID"); - expect(fs.existsSync(transitionLockPath)).toBe(true); - expect(fs.existsSync(containmentPath)).toBe(false); + expect(() => + transitionLock.withShieldsTransitionLock( + sandboxName, + "shields auto-restore contender", + () => undefined, + { + recoverStaleOwner: false, + waitTimeoutMs: 0, + }, + ), + ).toThrow("recorded owner PID"); + expect(fs.existsSync(transitionLockPath)).toBe(true); + expect(fs.existsSync(containmentPath)).toBe(false); - expect(() => - shields.prepareAutoRestoreTransitionTakeover( - sandboxName, - processToken, - path.join(stateDir, "unused-snapshot.yaml"), - ), - ).toThrow("durable containment"); - expect(fs.existsSync(transitionLockPath)).toBe(true); - expect(fs.existsSync(containmentPath)).toBe(true); - }); + expect(() => + shields.prepareAutoRestoreTransitionTakeover( + sandboxName, + processToken, + path.join(stateDir, "unused-snapshot.yaml"), + ), + ).toThrow("durable containment"); + expect(fs.existsSync(transitionLockPath)).toBe(true); + expect(fs.existsSync(containmentPath)).toBe(true); + }, + ); - it("waits for a token-bound destroy owner without signaling it, then restores lockdown", { - timeout: 10_000, - }, async () => { - const transitionLockPath = path.join(import.meta.dirname, "transition-lock.ts"); - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const sandboxName = "destroy-deadline"; - const processToken = "e".repeat(32); - const snapshotPath = path.join(stateDir, "policy-snapshot-destroy.yaml"); - const readyPath = path.join(stateDir, "destroy-owner.ready"); - const releasePath = path.join(stateDir, "destroy-owner.release"); - const lockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); - const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); - const statePath = path.join(stateDir, `shields-${sandboxName}.json`); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); - fs.writeFileSync( - statePath, - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: new Date(Date.now() - 60_000).toISOString(), - shieldsDownTimeout: 60, - shieldsDownReason: "destroy takeover coverage", - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, - updatedAt: new Date().toISOString(), - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - markerPath, - JSON.stringify({ - pid: 999_999, + it( + "waits for a token-bound destroy owner without signaling it, then restores lockdown", + { + timeout: 10_000, + }, + async () => { + const transitionLockPath = path.join(import.meta.dirname, "transition-lock.ts"); + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const sandboxName = "destroy-deadline"; + const processToken = "e".repeat(32); + const snapshotPath = path.join(stateDir, "policy-snapshot-destroy.yaml"); + const readyPath = path.join(stateDir, "destroy-owner.ready"); + const releasePath = path.join(stateDir, "destroy-owner.release"); + const lockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); + const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); + const statePath = path.join(stateDir, `shields-${sandboxName}.json`); + fs.mkdirSync(stateDir, { recursive: true }); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + writeActivePolicyTransition( + stateDir, sandboxName, - snapshotPath, - restoreAt: new Date(Date.now() - 1_000).toISOString(), processToken, - }), - { mode: 0o600 }, - ); + snapshotPath, + snapshotPolicy, + ); + fs.writeFileSync( + statePath, + JSON.stringify({ + shieldsDown: true, + shieldsDownAt: new Date(Date.now() - 60_000).toISOString(), + shieldsDownTimeout: 60, + shieldsDownReason: "destroy takeover coverage", + shieldsDownPolicy: "permissive", + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, + updatedAt: new Date().toISOString(), + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + markerPath, + JSON.stringify({ + pid: 999_999, + sandboxName, + snapshotPath, + restoreAt: new Date(Date.now() - 1_000).toISOString(), + processToken, + }), + { mode: 0o600 }, + ); - const owner = spawn( - process.execPath, - [ - "--import", - "tsx", - "-e", + const owner = spawn( + process.execPath, [ - `const {withShieldsTransitionLock}=require(${JSON.stringify(transitionLockPath)})`, - "const fs=require('fs')", - "const [name,token,ready,release]=process.argv.slice(1)", - "const waitBuffer=new Int32Array(new SharedArrayBuffer(4))", - "withShieldsTransitionLock(name,'destroy sandbox',()=>{fs.writeFileSync(ready,'ready');const deadline=Date.now()+5000;while(!fs.existsSync(release)){if(Date.now()>=deadline)throw new Error('release handshake timed out');Atomics.wait(waitBuffer,0,0,10)}},{takeoverToken:token})", - ].join(";"), - sandboxName, - processToken, - readyPath, - releasePath, - ], - { env: { ...process.env, HOME: tmpDir }, stdio: "ignore" }, - ); - - try { - await vi.waitFor( - () => { - expect(fs.existsSync(readyPath)).toBe(true); - expect(fs.existsSync(lockPath)).toBe(true); - }, - { timeout: 5_000, interval: 10 }, + "--import", + "tsx", + "-e", + [ + `const {withShieldsTransitionLock}=require(${JSON.stringify(transitionLockPath)})`, + "const fs=require('fs')", + "const [name,token,ready,release]=process.argv.slice(1)", + "const waitBuffer=new Int32Array(new SharedArrayBuffer(4))", + "withShieldsTransitionLock(name,'destroy sandbox',()=>{fs.writeFileSync(ready,'ready');const deadline=Date.now()+5000;while(!fs.existsSync(release)){if(Date.now()>=deadline)throw new Error('release handshake timed out');Atomics.wait(waitBuffer,0,0,10)}},{takeoverToken:token})", + ].join(";"), + sandboxName, + processToken, + readyPath, + releasePath, + ], + { env: { ...process.env, HOME: tmpDir }, stdio: "ignore" }, ); - const timerControl = requireDist("./timer-control.js"); - const ownerStartIdentity = timerControl.readProcessStartIdentity(owner.pid); - expect(ownerStartIdentity).toBeTypeOf("string"); - const processKillSpy = vi.spyOn(process, "kill"); - const nativeAtomicsWait = Atomics.wait; - const atomicsWaitSpy = vi - .spyOn(Atomics, "wait") - .mockImplementationOnce(() => { - const ownerState = timerControl.readProcessState(owner.pid); - expect(ownerState).not.toBeNull(); - expect(ownerState?.startsWith("Z")).toBe(false); - expect(fs.existsSync(lockPath)).toBe(true); - expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGSTOP"); - expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGKILL"); - fs.writeFileSync(releasePath, "release"); - const releaseDeadline = Date.now() + 5_000; - const waitBuffer = new Int32Array(new SharedArrayBuffer(4)); - while (fs.existsSync(lockPath) && Date.now() < releaseDeadline) { - nativeAtomicsWait(waitBuffer, 0, 0, 10); - } - expect(fs.existsSync(lockPath)).toBe(false); - return "timed-out"; - }) - .mockReturnValue("timed-out"); - const harness = createHarness({ - dockerExecFileSync: (argv: unknown) => { - const args = Array.isArray(argv) ? argv.map(String) : []; - switch (true) { - case args.includes("sha256sum"): - return `${"a".repeat(64)} ${String(args.at(-1))}\n`; - case args.includes("lsattr"): - return `----i---------e----- ${String(args.at(-1))}\n`; - case args.includes("stat"): - return args.at(-1) === "/sandbox" - ? "1775 root:sandbox\n" - : args.at(-1) === "/sandbox/.openclaw" - ? "755 root:root\n" - : "444 root:root\n"; - default: - return ""; - } - }, - }); - - harness.shieldsStatus(sandboxName); - expect(atomicsWaitSpy).toHaveBeenCalled(); - expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGSTOP"); - expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGKILL"); - await vi.waitFor( - () => { - const ownerState = timerControl.readProcessState(owner.pid); - expect(ownerState === null || ownerState.startsWith("Z")).toBe(true); - }, - { timeout: 2_000, interval: 10 }, - ); - expect(fs.existsSync(lockPath)).toBe(false); - expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ - shieldsDown: false, - shieldsDownAt: null, - }); - expect(fs.existsSync(markerPath)).toBe(false); - expect(harness.runSpy).toHaveBeenCalledWith( - ["openshell", "policy", "set", "-g", "nemoclaw"], - expect.objectContaining({ ignoreError: true }), - ); - expect(harness.logSpy).toHaveBeenCalledWith(" Shields: UP (lockdown active)"); - } finally { - owner.kill("SIGKILL"); - } - }); + try { + await vi.waitFor( + () => { + expect(fs.existsSync(readyPath)).toBe(true); + expect(fs.existsSync(lockPath)).toBe(true); + }, + { timeout: 5_000, interval: 10 }, + ); + const timerControl = requireDist("./timer-control.js"); + const ownerStartIdentity = timerControl.readProcessStartIdentity(owner.pid); + expect(ownerStartIdentity).toBeTypeOf("string"); + const processKillSpy = vi.spyOn(process, "kill"); + const nativeAtomicsWait = Atomics.wait; + const atomicsWaitSpy = vi + .spyOn(Atomics, "wait") + .mockImplementationOnce(() => { + const ownerState = timerControl.readProcessState(owner.pid); + expect(ownerState).not.toBeNull(); + expect(ownerState?.startsWith("Z")).toBe(false); + expect(fs.existsSync(lockPath)).toBe(true); + expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGSTOP"); + expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGKILL"); + fs.writeFileSync(releasePath, "release"); + const releaseDeadline = Date.now() + 5_000; + const waitBuffer = new Int32Array(new SharedArrayBuffer(4)); + while (fs.existsSync(lockPath) && Date.now() < releaseDeadline) { + nativeAtomicsWait(waitBuffer, 0, 0, 10); + } + expect(fs.existsSync(lockPath)).toBe(false); + return "timed-out"; + }) + .mockReturnValue("timed-out"); + const harness = createHarness({ + dockerExecFileSync: (argv: unknown) => { + const args = Array.isArray(argv) ? argv.map(String) : []; + switch (true) { + case args.includes("sha256sum"): + return `${"a".repeat(64)} ${String(args.at(-1))}\n`; + case args.includes("lsattr"): + return `----i---------e----- ${String(args.at(-1))}\n`; + case args.includes("stat"): + return args.at(-1) === "/sandbox" + ? "1775 root:sandbox\n" + : args.at(-1) === "/sandbox/.openclaw" + ? "755 root:root\n" + : "444 root:root\n"; + default: + return ""; + } + }, + }); + + harness.shieldsStatus(sandboxName); + + expect(atomicsWaitSpy).toHaveBeenCalled(); + expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGSTOP"); + expect(processKillSpy).not.toHaveBeenCalledWith(owner.pid, "SIGKILL"); + await vi.waitFor( + () => { + const ownerState = timerControl.readProcessState(owner.pid); + expect(ownerState === null || ownerState.startsWith("Z")).toBe(true); + }, + { timeout: 2_000, interval: 10 }, + ); + expect(fs.existsSync(lockPath)).toBe(false); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ + shieldsDown: false, + shieldsDownAt: null, + }); + expect(fs.existsSync(markerPath)).toBe(false); + expect(harness.runSpy).toHaveBeenCalledWith( + ["openshell", "policy", "set", "-g", "nemoclaw"], + expect.objectContaining({ ignoreError: true }), + ); + expect(harness.logSpy).toHaveBeenCalledWith(" Shields: UP (lockdown active)"); + } finally { + owner.kill("SIGKILL"); + } + }, + ); it("publishes preparing recovery ownership before weakening and active only after unlock", () => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); @@ -1057,7 +1050,6 @@ describe("shields command flow", () => { ownerPid: process.pid, sandboxName: "openclaw", snapshotPath: expect.stringContaining("policy-snapshot-"), - managedMcpPolicyKeys: [], }); expect(fs.existsSync(path.join(stateDir, "shields-timer-openclaw.json"))).toBe(true); expect( @@ -1077,7 +1069,7 @@ describe("shields command flow", () => { const snapshotPath = path.join(stateDir, "stale-policy-snapshot.yaml"); const lockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + writeBoundPolicySnapshot(snapshotPath); fs.writeFileSync( path.join(stateDir, `shields-${sandboxName}.json`), JSON.stringify({ shieldsDown: false, updatedAt: new Date().toISOString() }), @@ -1205,7 +1197,14 @@ describe("shields command flow", () => { const markerPath = path.join(stateDir, "shields-timer-openclaw.json"); const processToken = "6".repeat(32); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + writeActivePolicyTransition( + stateDir, + "openclaw", + processToken, + snapshotPath, + snapshotPolicy, + ); fs.writeFileSync( path.join(stateDir, "shields-openclaw.json"), JSON.stringify({ @@ -1215,6 +1214,7 @@ describe("shields command flow", () => { shieldsDownReason: "dead future timer", shieldsDownPolicy: "permissive", shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( @@ -1245,39 +1245,41 @@ describe("shields command flow", () => { expect(harness.logSpy).toHaveBeenCalledWith(" Shields: UP (lockdown active)"); }); - it.each( - timerAuthorityFixtures, - )("restores lockdown when DOWN timer authority is %s", (markerState, writeMarker) => { - const harness = createHarness({ confirmOpenClawInodeFlags: true }); - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, `policy-snapshot-${markerState}-timer.yaml`); - const markerPath = path.join(stateDir, "shields-timer-openclaw.json"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: new Date().toISOString(), - shieldsDownTimeout: 300, - shieldsDownReason: `${markerState} timer marker`, - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, - }), - ); - writeMarker(markerPath); + it.each(timerAuthorityFixtures)( + "keeps lockdown recovery pending when DOWN timer authority is %s and no forward policy is bound", + (markerState, writeMarker) => { + const harness = createHarness({ confirmOpenClawInodeFlags: true }); + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, `policy-snapshot-${markerState}-timer.yaml`); + const markerPath = path.join(stateDir, "shields-timer-openclaw.json"); + fs.mkdirSync(stateDir, { recursive: true }); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsDownAt: new Date().toISOString(), + shieldsDownTimeout: 300, + shieldsDownReason: `${markerState} timer marker`, + shieldsDownPolicy: "permissive", + shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, + }), + ); + writeMarker(markerPath); - harness.shieldsStatus("openclaw"); + harness.shieldsStatus("openclaw"); - expect( - JSON.parse(fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8")), - ).toMatchObject({ shieldsDown: false, shieldsDownAt: null }); - expect(fs.existsSync(markerPath)).toBe(false); - expect(harness.runSpy).toHaveBeenCalledWith( - ["openshell", "policy", "set", "-g", "nemoclaw"], - expect.objectContaining({ ignoreError: true }), - ); - }); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8")), + ).toMatchObject({ shieldsDown: true }); + expect(fs.existsSync(markerPath)).toBe(markerState === "malformed"); + expect(harness.runSpy).not.toHaveBeenCalledWith( + ["openshell", "policy", "set", "-g", "nemoclaw"], + expect.anything(), + ); + }, + ); it("restores lockdown when a future marker PID has the wrong timer command identity", () => { const harness = createHarness({ confirmOpenClawInodeFlags: true }); @@ -1289,7 +1291,14 @@ describe("shields command flow", () => { const startIdentity = timerControl.readProcessStartIdentity(process.pid); expect(startIdentity).toBeTypeOf("string"); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); + writeActivePolicyTransition( + stateDir, + "openclaw", + processToken, + snapshotPath, + snapshotPolicy, + ); fs.writeFileSync( path.join(stateDir, "shields-openclaw.json"), JSON.stringify({ @@ -1299,6 +1308,7 @@ describe("shields command flow", () => { shieldsDownReason: "wrong timer command", shieldsDownPolicy: "permissive", shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index baa421db862..b39bebba91b 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -407,10 +407,7 @@ describe("shields — unit logic", () => { expect(deriveShieldsMode({}, false)).toBe("mutable_default"); expect(deriveShieldsMode({ shieldsDown: true }, true)).toBe("temporarily_unlocked"); expect( - deriveShieldsMode( - { shieldsDown: true, policyRecoveryConfigLocked: true }, - true, - ), + deriveShieldsMode({ shieldsDown: true, policyRecoveryConfigLocked: true }, true), ).toBe("locked_recovery"); expect(deriveShieldsMode({ shieldsDown: false }, true)).toBe("locked"); expect(deriveShieldsMode({}, true)).toBe("mutable_default"); @@ -563,16 +560,6 @@ describe("shields — unit logic", () => { expect(logSpy).toHaveBeenCalledWith(" Shields: DOWN (temporarily unlocked)"); }); - it("deadline composition removes an unproven MCP add from the restrictive policy", async () => { - const snapshot = - "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n mcp_bridge_beta: {}\n"; - const { composeDeadlineManagedMcpPolicies } = await import("./mcp-policy-transition"); - const composition = composeDeadlineManagedMcpPolicies(snapshot, [], ["mcp_bridge_beta"]); - - expect(composition.yaml).toContain("restrictive_baseline"); - expect(composition.yaml).not.toContain("mcp_bridge_beta"); - }); - it("deadline restore refuses policy mutation when authority cannot be read (#9833)", async () => { const sandboxName = "openclaw"; const processToken = "b".repeat(32); @@ -585,7 +572,6 @@ describe("shields — unit logic", () => { writeState(sandboxName, { shieldsDown: true, shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: ["mcp_bridge_alpha"], }); writeMarker(sandboxName, { pid: 2_147_483_647, @@ -616,7 +602,7 @@ describe("shields — unit logic", () => { deadlineAuthoritative: true, expiredTimerRecovery: true, }), - ).toThrow(/policy authority/i); + ).toThrow(/policy state/i); expect(run).not.toHaveBeenCalled(); expect(appliedPolicy).toBe(""); @@ -631,7 +617,6 @@ describe("shields — unit logic", () => { writeState(sandboxName, { shieldsDown: true, shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: [], }); writeMarker(sandboxName, { pid: 2_147_483_647, @@ -655,52 +640,12 @@ describe("shields — unit logic", () => { deadlineAuthoritative: true, expiredTimerRecovery: true, }), - ).toThrow(/policy authority is unavailable/i); + ).toThrow(/policy state is unavailable/i); expect(createTempDirectory).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); }); - it("reuses the snapshot without staging when the snapshot and current policy have no managed MCP entries (#7952)", async () => { - const snapshotPath = "/state/policy-snapshot-no-managed-mcp.yaml"; - const snapshotYaml = "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"; - const writeTempPolicy = vi.fn(() => { - throw new Error("policy staging is unavailable"); - }); - const { buildDeadlineRuntimeManagedMcpPolicy } = await import("./permissive-runtime"); - - const result = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { - managedMcpPolicies: [], - snapshotManagedPolicyKeys: [], - readBasePolicy: () => snapshotYaml, - writeTempPolicy, - }); - - expect(result).toEqual({ path: snapshotPath, omissions: [] }); - expect(writeTempPolicy).not.toHaveBeenCalled(); - }); - - it("deadline restore reuses an unchanged snapshot without temporary storage when no managed MCP entries exist (#7952)", async () => { - const snapshotPath = path.join(stateDir(), "policy-snapshot-no-managed-mcp.yaml"); - fs.mkdirSync(stateDir(), { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"); - const createTempDirectory = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => { - throw Object.assign(new Error("ENOSPC: simulated temporary storage full"), { - code: "ENOSPC", - }); - }); - const { buildDeadlineRuntimeManagedMcpPolicy } = await import("./permissive-runtime"); - - const result = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { - managedMcpPolicies: [], - snapshotManagedPolicyKeys: [], - readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), - }); - - expect(result).toEqual({ path: snapshotPath, omissions: [] }); - expect(createTempDirectory).not.toHaveBeenCalled(); - }); - it("shieldsStatus warns and stays DOWN when the restrictive snapshot is missing", async () => { const sandboxName = "openclaw"; const missingSnapshotPath = path.join(stateDir(), "missing-snapshot.yaml"); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index ce1bb499d1d..c5a96f191e4 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -41,15 +41,12 @@ const { const { buildPolicyGetCommand, buildPolicySetCommand, - finalizePolicyMutationReceipt, + verifyAppliedPolicyDocument, parseCurrentPolicy, resolvePermissivePolicyPath, - assertNemoClawManagedPolicy, - inspectPolicyMutationAuthority, - inspectPolicyRecoveryAuthority, - isExternalPolicyAuthorityRefusalError, - isPolicyAuthorityRefusalError, - recheckPolicyMutationAuthority, + inspectPolicyMutationContext, + isPolicyObservationError, + recheckPolicyMutationContext, rejectFinalPolicySetResult: rejectFinalShieldsPolicySetResult, } = require("../policy"); const { parseDuration, MAX_SECONDS, DEFAULT_SECONDS } = require("../domain/duration"); @@ -78,18 +75,10 @@ const { resolveAgentStateLockContract, }: typeof import("../sandbox/agent-config") = require("../sandbox/agent-config"); const { - assertLegacyMcpPolicyRestoreSafe, - buildDeadlineRuntimeManagedMcpPolicy, - buildRuntimeManagedMcpPolicy, + buildRuntimePolicyWithLiveMcpEntries, buildRuntimePermissivePolicy, - describeCanonicalPolicyReference, - hasManagedMcpPolicyClaims, - inspectExactManagedMcpPolicies, - inspectProvableManagedMcpPoliciesForDeadline, - inspectRecordedManagedMcpPolicies, - serializeCanonicalPolicy, }: typeof import("./permissive-runtime") = require("./permissive-runtime"); -const { cleanupTempDir } = require("../onboard/temp-files"); +const { cleanupTempDir, secureTempFile } = require("../onboard/temp-files"); const { verifyShieldsLockState }: typeof import("./verify-lock") = require("./verify-lock"); const { relockAndReconfirm, @@ -151,271 +140,18 @@ type MutableConfigPermsInspection = import("./mutable-config-perms").MutableConf type MutableConfigRepairResult = import("./mutable-config-perms").MutableConfigRepairResult; type MutableConfigPostureMode = import("./mutable-config-perms").MutableConfigPostureMode; type AgentStateLockPlan = import("../agent/definition-types").AgentStateLockPlan; -type ManagedMcpPolicyOmission = import("./permissive-runtime").ManagedMcpPolicyOmission; type TimerMarker = import("./timer-control").TimerMarker; -type PolicyMutationAuthority = ReturnType; +type PolicyMutationContext = ReturnType; -/** Require the registry-bound live authority before a Shields-owned policy mutation. */ -function assertShieldsPolicyMutationAuthority( +/** Re-read current OpenShell state before a Shields-owned policy mutation. */ +function assertShieldsPolicyMutationContext( sandboxName: string, operation: string, - recorded?: PolicyMutationAuthority, -): PolicyMutationAuthority { - const authority = recorded - ? recheckPolicyMutationAuthority(sandboxName, operation, recorded) - : inspectPolicyMutationAuthority(sandboxName, operation); - assertNemoClawManagedPolicy(authority, operation); - return authority; -} - -function readShieldsPolicySnapshot(snapshotPath: string): Record { - let parsed: unknown; - try { - parsed = YAML.parse(fs.readFileSync(snapshotPath, "utf-8")); - } catch (error) { - throw new Error("The saved restrictive Shields policy snapshot is unreadable or invalid", { - cause: error, - }); - } - if (!isObjectRecord(parsed)) { - throw new Error("The saved restrictive Shields policy snapshot is not a policy mapping"); - } - return parsed; -} - -function externalPolicyMatchesShieldsPolicy( - authority: PolicyMutationAuthority, - requiredPolicy: Record, -): boolean { - return isDeepStrictEqual(authority.inspection.effectivePolicy, requiredPolicy); -} - -function requiredPolicyReference(requiredPolicy: Record): string { - return describeCanonicalPolicyReference(requiredPolicy); -} - -type ExternalPolicyRecoveryReason = "authority-drift" | "policy-mismatch"; - -class ExternalShieldsPolicyRecoveryError extends Error { - constructor( - readonly reason: ExternalPolicyRecoveryReason, - message: string, - readonly recoveryArtifact?: BoundShieldsPolicyArtifact, - ) { - super(message); - this.name = "ExternalShieldsPolicyRecoveryError"; - } -} - -function externalPolicyRecoveryArtifactText( - recoveryArtifact: BoundShieldsPolicyArtifact | undefined, -): string { - return recoveryArtifact - ? ` Complete required policy: ${recoveryArtifact.path}.` - : " The complete policy handoff is unavailable; run Shields up to regenerate it only after the recorded external authority is restored."; -} - -function externalPolicyRecoveryHandoff( - sandboxName: string, - requiredPolicy: Record, - reason: ExternalPolicyRecoveryReason, - recoveryArtifact?: BoundShieldsPolicyArtifact, -): string { - const reference = `Required effective policy reference: ${requiredPolicyReference(requiredPolicy)}.`; - const artifact = externalPolicyRecoveryArtifactText(recoveryArtifact); - if (reason === "authority-drift") { - return ( - `Policy authority changed while NemoClaw verified recovery for sandbox '${sandboxName}'. ${reference}${artifact} ` + - "Stop without applying the handoff or retrying Shields. Restore the recorded externally managed authority through its owning OpenShell configuration, or ask a NemoClaw maintainer for recovery direction if the authority change was intentional. NemoClaw will not change policy authority." - ); - } - return ( - `The effective policy for sandbox '${sandboxName}' does not match the required restrictive policy. ${reference}${artifact} ` + - "The external policy authority must make the effective policy for this named sandbox match the complete handoff, including current managed MCP entries. " + - `Then run \`${CLI_NAME} ${sandboxName} shields up\`. NemoClaw will verify the exact effective policy without changing policy authority before it completes Shields recovery.` - ); -} - -function externalPolicyVerifiedHandoff( - sandboxName: string, - requiredPolicy: Record, - configAlreadyLocked: boolean, - recoveryArtifact?: BoundShieldsPolicyArtifact, -): string { - const completion = configAlreadyLocked - ? `Run \`${CLI_NAME} ${sandboxName} shields up\` to commit Shields UP. Configuration is already locked; NemoClaw will reverify policy without changing policy authority.` - : `Run \`${CLI_NAME} ${sandboxName} shields up\` to lock configuration and commit Shields UP. NemoClaw will reverify policy without changing policy authority.`; - return ( - `NemoClaw verified the required effective policy for sandbox '${sandboxName}' (${requiredPolicyReference(requiredPolicy)}). ` + - `${externalPolicyRecoveryArtifactText(recoveryArtifact).trimStart()} ` + - completion - ); -} - -type ShieldsPolicySnapshotRestoreAuthority = { - authority: PolicyMutationAuthority; - policyMutationAllowed: boolean; -}; - -function inspectShieldsPolicySnapshotRestoreAuthority( - sandboxName: string, - recorded?: PolicyMutationAuthority, -): PolicyMutationAuthority { - if (recorded?.authority === "externally-managed") { - return inspectPolicyRecoveryAuthority( - sandboxName, - "verify the externally restored Shields policy snapshot", - recorded.gatewayName, - ); - } - let authority: PolicyMutationAuthority; - try { - authority = recorded - ? recheckPolicyMutationAuthority(sandboxName, "restore the Shields policy snapshot", recorded) - : inspectPolicyMutationAuthority(sandboxName, "restore the Shields policy snapshot"); - } catch (error) { - if (!isExternalPolicyAuthorityRefusalError(error)) throw error; - authority = inspectPolicyRecoveryAuthority( - sandboxName, - "verify the externally restored Shields policy snapshot", - recorded?.gatewayName, - ); - } - return authority; -} - -function resolveShieldsPolicySnapshotRestoreAuthority( - sandboxName: string, - requiredPolicy: Record, - recorded?: PolicyMutationAuthority, - recoveryArtifact?: BoundShieldsPolicyArtifact, -): ShieldsPolicySnapshotRestoreAuthority { - if ( - recorded?.authority === "externally-managed" && - !externalPolicyMatchesShieldsPolicy(recorded, requiredPolicy) - ) { - throw new ExternalShieldsPolicyRecoveryError( - "policy-mismatch", - externalPolicyRecoveryHandoff( - sandboxName, - requiredPolicy, - "policy-mismatch", - recoveryArtifact, - ), - recoveryArtifact, - ); - } - const authority = inspectShieldsPolicySnapshotRestoreAuthority(sandboxName, recorded); - if (recorded && authority.authority !== recorded.authority) { - throw new ExternalShieldsPolicyRecoveryError( - "authority-drift", - externalPolicyRecoveryHandoff( - sandboxName, - requiredPolicy, - "authority-drift", - recoveryArtifact, - ), - recoveryArtifact, - ); - } - if (authority.authority === "nemoclaw-managed") { - return { authority, policyMutationAllowed: true }; - } - if (!externalPolicyMatchesShieldsPolicy(authority, requiredPolicy)) { - throw new ExternalShieldsPolicyRecoveryError( - "policy-mismatch", - externalPolicyRecoveryHandoff( - sandboxName, - requiredPolicy, - "policy-mismatch", - recoveryArtifact, - ), - recoveryArtifact, - ); - } - const revalidated = inspectPolicyRecoveryAuthority( - sandboxName, - "finish the externally restored Shields policy snapshot verification", - authority.gatewayName, - ); - if (revalidated.authority !== "externally-managed") { - throw new ExternalShieldsPolicyRecoveryError( - "authority-drift", - externalPolicyRecoveryHandoff( - sandboxName, - requiredPolicy, - "authority-drift", - recoveryArtifact, - ), - recoveryArtifact, - ); - } - if (!externalPolicyMatchesShieldsPolicy(revalidated, requiredPolicy)) { - throw new ExternalShieldsPolicyRecoveryError( - "policy-mismatch", - externalPolicyRecoveryHandoff( - sandboxName, - requiredPolicy, - "policy-mismatch", - recoveryArtifact, - ), - recoveryArtifact, - ); - } - return { authority: revalidated, policyMutationAllowed: false }; -} - -type ShieldsPolicyRecoveryInspection = - | { status: "ready" } - | { status: "external"; handoff: string } - | { status: "unavailable"; detail: string }; - -function inspectShieldsPolicyRecovery(sandboxName: string): ShieldsPolicyRecoveryInspection { - const state = loadShieldsState(sandboxName); - let authority: PolicyMutationAuthority; - try { - authority = inspectShieldsPolicySnapshotRestoreAuthority(sandboxName); - } catch (error) { - return { - status: "unavailable", - detail: error instanceof Error ? error.message : String(error), - }; - } - if (authority.authority === "nemoclaw-managed" && !state.policyRecoveryConfigLocked) { - return { status: "ready" }; - } - const snapshotPath = state.shieldsPolicySnapshotPath; - if (!snapshotPath || !fs.existsSync(snapshotPath)) { - return { - status: "external", - handoff: `The saved restrictive policy snapshot for sandbox '${sandboxName}' is unavailable. Rebuild the sandbox before finishing the Shields transition.`, - }; - } - try { - const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { - externalVerificationOnly: true, - }); - if (!result.externalRequiredPolicy) { - throw new Error("External Shields policy verification did not return its required policy"); - } - return { - status: "external", - handoff: externalPolicyVerifiedHandoff( - sandboxName, - result.externalRequiredPolicy, - state.policyRecoveryConfigLocked === true, - result.externalPolicyRecoveryArtifact, - ), - }; - } catch (error) { - if (error instanceof ExternalShieldsPolicyRecoveryError) { - return { status: "external", handoff: error.message }; - } - return { - status: "unavailable", - detail: error instanceof Error ? error.message : String(error), - }; - } + recorded?: PolicyMutationContext, +): PolicyMutationContext { + return recorded + ? recheckPolicyMutationContext(sandboxName, operation, recorded) + : inspectPolicyMutationContext(sandboxName, operation); } const STATE_DIR = resolveShieldsStateDir(); @@ -482,9 +218,9 @@ type ShieldsDownTransition = { processToken: string; sandboxName: string; snapshotPath: string; - /** Exact generated MCP keys owned when snapshotPath was captured. */ - managedMcpPolicyKeys?: string[]; - /** Durable exact policy replay authority for interrupted forward recovery. */ + /** Exact restrictive snapshot used by Shields-up, rollback, and timer recovery. */ + snapshotPolicy: BoundShieldsPolicyArtifact; + /** Transaction-bound forward document used only to reverse this Shields delta. */ forwardPolicy?: BoundShieldsPolicyArtifact; }; @@ -616,19 +352,11 @@ function isShieldsDownTransition(value: unknown): value is ShieldsDownTransition /^[0-9a-f]{32}$/.test(value.processToken) && typeof value.sandboxName === "string" && typeof value.snapshotPath === "string" && - isOptionalManagedMcpPolicyKeys(value.managedMcpPolicyKeys) && + isBoundShieldsPolicyArtifact(value.snapshotPolicy) && (value.forwardPolicy === undefined || isBoundShieldsPolicyArtifact(value.forwardPolicy)) ); } -function sameManagedMcpPolicyKeys( - left: readonly string[] | undefined, - right: readonly string[] | undefined, -): boolean { - if (left === undefined || right === undefined) return left === right; - return left.length === right.length && left.every((key, index) => key === right[index]); -} - function sameBoundShieldsPolicyArtifact( left: BoundShieldsPolicyArtifact | undefined, right: BoundShieldsPolicyArtifact | undefined, @@ -659,7 +387,7 @@ function sameShieldsDownTransitionAuthority( left.processToken === right.processToken && left.sandboxName === right.sandboxName && left.snapshotPath === right.snapshotPath && - sameManagedMcpPolicyKeys(left.managedMcpPolicyKeys, right.managedMcpPolicyKeys) && + sameBoundShieldsPolicyArtifact(left.snapshotPolicy, right.snapshotPolicy) && sameBoundShieldsPolicyArtifact(left.forwardPolicy, right.forwardPolicy) ); } @@ -785,10 +513,14 @@ function readBoundShieldsPolicyArtifact( } let fd: number | undefined; try { - fd = fs.openSync( - binding.path, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, - ); + try { + fd = fs.openSync( + binding.path, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + } catch (error) { + throw new Error(`${label} has unsafe metadata`, { cause: error }); + } const before = fs.fstatSync(fd); if ( !before.isFile() || @@ -835,9 +567,7 @@ function requireShieldsDownForwardPolicy(transition: ShieldsDownTransition): str transition.processToken, ); if (!binding || binding.path !== expectedPath || path.normalize(binding.path) !== binding.path) { - throw new Error( - "Interrupted Shields down recovery is missing its exact forward-policy authority", - ); + throw new Error("Interrupted Shields down recovery is missing its exact forward-policy state"); } try { return requireBoundShieldsPolicyArtifact( @@ -875,6 +605,39 @@ function readTimerBoundShieldsDownTransition(sandboxName: string): ShieldsDownTr return transition?.snapshotPath === marker.snapshotPath ? transition : null; } +function readStateBoundActiveShieldsDownTransition( + sandboxName: string, + state: ShieldsState, +): ShieldsDownTransition | null { + const snapshotPath = state.shieldsPolicySnapshotPath; + const snapshotPolicy = state.shieldsPolicySnapshot; + if (!snapshotPath || !snapshotPolicy) return null; + + const prefix = `shields-transition-${sandboxName}-`; + const suffix = ".json"; + let entries: import("node:fs").Dirent[]; + try { + entries = fs.readdirSync(STATE_DIR, { withFileTypes: true }); + } catch { + return null; + } + const matches = entries.flatMap((entry) => { + if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(suffix)) { + return []; + } + const processToken = entry.name.slice(prefix.length, -suffix.length); + if (!/^[0-9a-f]{32}$/.test(processToken)) return []; + const transition = readShieldsDownTransition(sandboxName, processToken); + return transition?.phase === "active" && + transition.snapshotPath === snapshotPath && + sameBoundShieldsPolicyArtifact(transition.snapshotPolicy, snapshotPolicy) && + transition.forwardPolicy + ? [transition] + : []; + }); + return matches.length === 1 ? matches[0]! : null; +} + function writeShieldsDownTransition( transition: ShieldsDownTransition, expectedPhase: ShieldsDownTransition["phase"] | null, @@ -889,7 +652,6 @@ function writeShieldsDownTransition( current.ownerPid !== transition.ownerPid || current.ownerStartIdentity !== transition.ownerStartIdentity || current.snapshotPath !== transition.snapshotPath || - !sameManagedMcpPolicyKeys(current.managedMcpPolicyKeys, transition.managedMcpPolicyKeys) || !sameBoundShieldsPolicyArtifact(current.forwardPolicy, transition.forwardPolicy) ) { throw new Error("Shields-down recovery ownership changed during the transition"); @@ -1019,7 +781,6 @@ function waitForShieldsDownForwardCommit( next.ownerStartIdentity !== observed.ownerStartIdentity || next.snapshotPath !== observed.snapshotPath || next.processToken !== observed.processToken || - !sameManagedMcpPolicyKeys(next.managedMcpPolicyKeys, observed.managedMcpPolicyKeys) || !sameBoundShieldsPolicyArtifact(next.forwardPolicy, observed.forwardPolicy) ) { throw new Error("Shields-down recovery ownership changed while waiting for forward commit"); @@ -1773,145 +1534,6 @@ function stateFilePath(sandboxName: string): string { return path.join(STATE_DIR, `shields-${sandboxName}.json`); } -function externalPolicyRecoveryArtifactPath(sandboxName: string): string { - return path.join(STATE_DIR, `shields-external-policy-${sandboxName}.yaml`); -} - -function publishExternalPolicyRecoveryArtifact( - sandboxName: string, - requiredPolicy: Record, -): BoundShieldsPolicyArtifact { - const artifactPath = externalPolicyRecoveryArtifactPath(sandboxName); - const content = serializeCanonicalPolicy(requiredPolicy); - writeShieldsFileAtomicDurable(artifactPath, content); - return describeBoundShieldsPolicyArtifact( - artifactPath, - content, - fs.lstatSync(artifactPath), - "External Shields policy recovery artifact", - ); -} - -function validatedExternalPolicyRecoveryArtifact( - sandboxName: string, - requiredPolicy: Record, - binding: BoundShieldsPolicyArtifact | undefined, -): BoundShieldsPolicyArtifact | undefined { - if (!binding) return undefined; - try { - requireBoundShieldsPolicyArtifact( - binding, - externalPolicyRecoveryArtifactPath(sandboxName), - "External Shields policy recovery artifact", - ); - return isDeepStrictEqual(readShieldsPolicySnapshot(binding.path), requiredPolicy) - ? binding - : undefined; - } catch { - return undefined; - } -} - -function restoreExternalPolicyRecoveryArtifact( - binding: BoundShieldsPolicyArtifact, - content: Buffer, -): void { - writeShieldsFileAtomicDurable(binding.path, content); - requireBoundShieldsPolicyArtifact( - binding, - binding.path, - "External Shields policy recovery artifact", - ); -} - -function commitExternalPolicyRecoveryArtifactRetirement( - sandboxName: string, - commitState: () => void, -): void { - const artifactPath = externalPolicyRecoveryArtifactPath(sandboxName); - const originalState = loadShieldsState(sandboxName); - const binding = originalState.externalPolicyRecoveryArtifact; - let content: Buffer | undefined; - if (binding) { - try { - content = readBoundShieldsPolicyArtifact( - binding, - artifactPath, - "External Shields policy recovery artifact", - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - } - - try { - fs.rmSync(artifactPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - commitState(); - return; - } - const detail = error instanceof Error ? error.message : String(error); - throw new Error( - `Could not remove external Shields policy recovery artifact '${artifactPath}': ${detail}`, - { cause: error }, - ); - } - try { - fsyncShieldsStateDirectory(); - } catch (error) { - let rollbackDetail = "the artifact had no durable state binding to restore"; - if (binding && content) { - try { - restoreExternalPolicyRecoveryArtifact(binding, content); - rollbackDetail = "restored its bound content"; - } catch (rollbackError) { - rollbackDetail = `could not restore its bound content: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`; - } - } - const detail = error instanceof Error ? error.message : String(error); - throw new Error( - `Could not make removal of external Shields policy recovery artifact '${artifactPath}' durable; ${rollbackDetail}: ${detail}`, - { cause: error }, - ); - } - - try { - commitState(); - } catch (error) { - const rollbackErrors: string[] = []; - let artifactRestored = false; - if (binding && content) { - try { - restoreExternalPolicyRecoveryArtifact(binding, content); - artifactRestored = true; - } catch (rollbackError) { - rollbackErrors.push( - `artifact restore failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); - } - } - try { - restoreShieldsStateSnapshot(sandboxName, originalState); - } catch (rollbackError) { - rollbackErrors.push( - `state restore failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); - } - const detail = error instanceof Error ? error.message : String(error); - const rollbackDetail = - rollbackErrors.length === 0 - ? artifactRestored - ? "restored the bound artifact and Shields state" - : "restored Shields state; no bound artifact was available to restore" - : `rollback incomplete (${rollbackErrors.join("; ")})`; - throw new Error( - `Could not commit Shields state after removing external policy recovery artifact '${artifactPath}'; ${rollbackDetail}: ${detail}`, - { cause: error }, - ); - } -} - // Shields posture model: // "mutable_default" — fresh sandbox, shields never configured (the default) // "locked" — shields up has been run and verified @@ -1927,10 +1549,8 @@ interface ShieldsState { shieldsDownReason?: string | null; shieldsDownPolicy?: string | null; shieldsPolicySnapshotPath?: string | null; - /** Exact generated MCP keys owned in the restrictive snapshot. */ - shieldsManagedMcpPolicyKeys?: string[]; + shieldsPolicySnapshot?: BoundShieldsPolicyArtifact | null; policyRecoveryConfigLocked?: boolean; - externalPolicyRecoveryArtifact?: BoundShieldsPolicyArtifact; chattrApplied?: boolean; // SHA-256 seal of each locked file, captured by `shields up` after the // lock verification passes. `shields status` re-hashes the same files @@ -2230,7 +1850,8 @@ function issueShieldsPolicySnapshotRecovery( if ( transition.phase !== "active" || transition.sandboxName !== sandboxName || - transition.snapshotPath !== snapshotPolicy.path + transition.snapshotPath !== snapshotPolicy.path || + !sameBoundShieldsPolicyArtifact(transition.snapshotPolicy, snapshotPolicy) ) { throw new Error("Cannot issue backup recovery outside its active Shields transition"); } @@ -2689,20 +2310,6 @@ function isOptionalHashMap(value: unknown): value is { [path: string]: string } return true; } -function isOptionalManagedMcpPolicyKeys(value: unknown): value is string[] | undefined { - if (value === undefined) return true; - // Preserve string entries exactly so deadline recovery can strip and audit - // malformed or duplicate ownership without delaying restrictive lockdown. - // Manual restoration validates the same entries strictly during composition. - return Array.isArray(value) && value.every((key) => typeof key === "string"); -} - -function isOptionalBoundShieldsPolicyArtifact( - value: unknown, -): value is BoundShieldsPolicyArtifact | undefined { - return value === undefined || isBoundShieldsPolicyArtifact(value); -} - function isShieldsState(value: unknown): value is ShieldsState { return ( isObjectRecord(value) && @@ -2712,9 +2319,10 @@ function isShieldsState(value: unknown): value is ShieldsState { isOptionalNullableString(value.shieldsDownReason) && isOptionalNullableString(value.shieldsDownPolicy) && isOptionalNullableString(value.shieldsPolicySnapshotPath) && - isOptionalManagedMcpPolicyKeys(value.shieldsManagedMcpPolicyKeys) && + (value.shieldsPolicySnapshot === undefined || + value.shieldsPolicySnapshot === null || + isBoundShieldsPolicyArtifact(value.shieldsPolicySnapshot)) && isOptionalBoolean(value.policyRecoveryConfigLocked) && - isOptionalBoundShieldsPolicyArtifact(value.externalPolicyRecoveryArtifact) && isOptionalBoolean(value.chattrApplied) && isOptionalHashMap(value.fileHashes) && isOptionalString(value.updatedAt) @@ -4561,295 +4169,126 @@ function describeRollbackTimerAuthority( ? " Auto-restore timer authority was revoked." : " The scheduled auto-restore remains authoritative."; } -function resolveExactManagedMcpPolicies( - sandboxName: string, - livePolicyYaml?: string, - gatewayName?: string, -): ReturnType { - let effectiveLivePolicy = livePolicyYaml; - if (!effectiveLivePolicy) { - let rawPolicy: string; - try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName, gatewayName)); - } catch (error) { - throw new Error("Cannot read the live gateway policy for managed MCP reconciliation", { - cause: error, - }); - } - effectiveLivePolicy = parseCurrentPolicy(rawPolicy); - } - if (!effectiveLivePolicy) { - throw new Error("Cannot parse the live gateway policy for managed MCP reconciliation"); - } - return inspectExactManagedMcpPolicies(sandboxName, effectiveLivePolicy); -} - -function resolveProvableManagedMcpPoliciesForDeadline( - sandboxName: string, - gatewayName?: string, -): ReturnType { - try { - let effectiveLivePolicy = ""; - try { - effectiveLivePolicy = parseCurrentPolicy( - runCapture(buildPolicyGetCommand(sandboxName, gatewayName)), - ); - } catch { - // The tolerant deadline inspector records exact omissions for every claim - // when the live policy cannot be parsed or read. - } - return inspectProvableManagedMcpPoliciesForDeadline(sandboxName, effectiveLivePolicy); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - policies: [], - omissions: [ - { - reason: `Managed MCP registry inspection failed at the auto-restore deadline: ${message}`, - }, - ], - }; - } -} - -/** - * Restore a saved complete policy while reconciling only exact generated MCP - * entries. Snapshot-time keys are removed before currently owned entries are - * overlaid, so changes made during the shields-down window survive both manual - * and timer restoration. - */ interface ShieldsPolicySnapshotRestoreOptions { transitionProcessToken?: string; deadlineAuthoritative?: boolean; expiredTimerRecovery?: boolean; - externalVerificationOnly?: boolean; - persistExternalRecoveryArtifact?: boolean; buildPolicySet?: typeof buildPolicySetCommand; runPolicySet?: typeof run; } -type ShieldsPolicySnapshotRestoreResult = ReturnType & { - managedMcpOmissions?: ManagedMcpPolicyOmission[]; - externalPolicyVerified?: true; - externalRequiredPolicy?: Record; - externalPolicyRecoveryArtifact?: BoundShieldsPolicyArtifact; -}; +type ShieldsPolicySnapshotRestoreResult = ReturnType; +function restoreShieldsDelta( + snapshotPolicy: string, + forwardPolicy: string | null, + livePolicy: string, +): string { + if (!forwardPolicy) { + throw new Error("Shields recovery has no bound forward-policy artifact"); + } + const before = YAML.parse(snapshotPolicy) as Record; + const forward = YAML.parse(forwardPolicy) as Record; + const live = YAML.parse(livePolicy) as Record; + if (!isObjectRecord(before) || !isObjectRecord(forward) || !isObjectRecord(live)) { + throw new Error("Shields policy delta contains a non-mapping policy document"); + } + const beforeNetwork = isObjectRecord(before.network_policies) ? before.network_policies : {}; + const forwardNetwork = isObjectRecord(forward.network_policies) ? forward.network_policies : {}; + const liveNetwork = isObjectRecord(live.network_policies) ? { ...live.network_policies } : {}; + for (const key of new Set([...Object.keys(beforeNetwork), ...Object.keys(forwardNetwork)])) { + const beforeValue = beforeNetwork[key]; + const forwardValue = forwardNetwork[key]; + if (isDeepStrictEqual(beforeValue, forwardValue)) continue; + if (!isDeepStrictEqual(liveNetwork[key], forwardValue)) continue; + if (beforeValue === undefined) delete liveNetwork[key]; + else liveNetwork[key] = structuredClone(beforeValue); + } + live.network_policies = liveNetwork; + return YAML.stringify(live); +} + +/** Restore only the live policy delta introduced by the active Shields command. */ function applyShieldsPolicySnapshot( sandboxName: string, snapshotPath: string, options: ShieldsPolicySnapshotRestoreOptions = {}, ): ShieldsPolicySnapshotRestoreResult { - const buildPolicySet = options.buildPolicySet ?? buildPolicySetCommand; - const runPolicySet = options.runPolicySet ?? run; const state = loadShieldsState(sandboxName); - let transition: ShieldsDownTransition | null = null; - if (options.transitionProcessToken !== undefined) { - if (!/^[0-9a-f]{32}$/.test(options.transitionProcessToken)) { - throw new Error("Invalid Shields transition recovery token"); - } - transition = readShieldsDownTransition(sandboxName, options.transitionProcessToken); - if ( - !transition && - fs.existsSync(shieldsDownTransitionPath(sandboxName, options.transitionProcessToken)) - ) { - throw new Error("Shields transition recovery authority is invalid"); - } - if (transition && transition.snapshotPath !== snapshotPath) { - throw new Error("Shields transition does not authorize the policy snapshot being restored"); - } + let transition = + options.transitionProcessToken !== undefined + ? readShieldsDownTransition(sandboxName, options.transitionProcessToken) + : readTimerBoundShieldsDownTransition(sandboxName); + if ( + options.transitionProcessToken !== undefined && + !/^[0-9a-f]{32}$/.test(options.transitionProcessToken) + ) { + throw new Error("Invalid Shields transition recovery token"); + } + if (transition && transition.snapshotPath !== snapshotPath) { + throw new Error("Shields transition does not match the saved policy snapshot"); + } + if (!transition && state.shieldsPolicySnapshotPath !== snapshotPath) { + throw new Error("Shields state does not match the saved policy snapshot"); + } + if (state._isCorrupt && !transition) { + throw new Error( + `Cannot restore a Shields policy while persisted state is corrupt: ${state._corruptError ?? "invalid state"}`, + ); } if (options.deadlineAuthoritative) { const marker = readTimerMarker(sandboxName); - const markerMatchesRecovery = + const matches = marker?.sandboxName === sandboxName && marker.snapshotPath === snapshotPath && marker.processToken === options.transitionProcessToken; - const timerAuthorityIsInactive = - options.expiredTimerRecovery === true && - markerMatchesRecovery && - !isExactLiveFutureTimerAuthority(marker!); - if ( - options.transitionProcessToken === undefined || - !markerMatchesRecovery || - (marker!.pid !== process.pid && !timerAuthorityIsInactive) - ) { + const expired = + options.expiredTimerRecovery === true && matches && !isExactLiveFutureTimerAuthority(marker!); + if (!matches || (marker!.pid !== process.pid && !expired)) { throw new Error("The active auto-restore timer does not authorize deadline restoration"); } } - if (state._isCorrupt && !transition) { - throw new Error( - `Cannot restore a Shields policy while persisted state is corrupt: ${ - state._corruptError ?? "invalid state" - }`, - ); - } - // A preparing transition can outlive its owner before Shields state is - // committed; its token-bound marker is then the recovery authority. - // Every ordinary restore remains bound to the exact persisted snapshot. - if (!transition && state.shieldsPolicySnapshotPath !== snapshotPath) { - throw new Error("Shields state does not match the policy snapshot being restored"); - } - const persistedSnapshotMatches = state.shieldsPolicySnapshotPath === snapshotPath; - const policyAuthority = inspectShieldsPolicySnapshotRestoreAuthority(sandboxName); - const policyMutationAllowed = policyAuthority.authority === "nemoclaw-managed"; - const ownershipOmissions: ManagedMcpPolicyOmission[] = []; - if ( - transition?.managedMcpPolicyKeys !== undefined && - persistedSnapshotMatches && - state.shieldsManagedMcpPolicyKeys !== undefined && - !sameManagedMcpPolicyKeys(transition.managedMcpPolicyKeys, state.shieldsManagedMcpPolicyKeys) - ) { - if (!options.deadlineAuthoritative) { - throw new Error("Shields transition ownership does not match persisted policy ownership"); - } - ownershipOmissions.push({ - reason: - "Shields transition ownership did not match persisted policy ownership at the auto-restore deadline", - }); - } - let snapshotManagedPolicyKeys = - transition?.managedMcpPolicyKeys ?? - (persistedSnapshotMatches ? state.shieldsManagedMcpPolicyKeys : undefined); - // Older Shields state has no exact snapshot-time ownership manifest. - // A manual restore preserves raw-snapshot behavior only when neither current - // state nor the snapshot can involve managed MCP. Deadline restoration - // instead strips every reserved key and overlays only independently proven - // current entries so legacy metadata cannot delay restrictive lockdown. - if (snapshotManagedPolicyKeys === undefined) { - if (options.deadlineAuthoritative) { - snapshotManagedPolicyKeys = []; - ownershipOmissions.push({ - reason: - "Legacy Shields state had no managed MCP ownership manifest at the auto-restore deadline", - }); - } else if (policyMutationAllowed && !options.externalVerificationOnly) { - assertLegacyMcpPolicyRestoreSafe( - fs.readFileSync(snapshotPath, "utf-8"), - hasManagedMcpPolicyClaims(sandboxName), - ); - assertShieldsPolicyMutationAuthority( - sandboxName, - "restore the Shields policy snapshot", - policyAuthority, - ); - const result = runPolicySet( - buildPolicySet(snapshotPath, sandboxName, policyAuthority.gatewayName), - { - ignoreError: true, - }, - ); - rejectFinalShieldsPolicySetResult(result, "restore the Shields policy snapshot"); - finalizePolicyMutationReceipt( - sandboxName, - fs.readFileSync(snapshotPath, "utf-8"), - policyAuthority, - ); - return result; - } else { - assertLegacyMcpPolicyRestoreSafe( - fs.readFileSync(snapshotPath, "utf-8"), - hasManagedMcpPolicyClaims(sandboxName), - ); - snapshotManagedPolicyKeys = []; - } + const context = inspectPolicyMutationContext(sandboxName, "restore the Shields policy snapshot"); + const rawLive = runCapture(buildPolicyGetCommand(sandboxName, context.gatewayName)); + const livePolicy = parseCurrentPolicy(rawLive); + if (!livePolicy) throw new Error("Cannot read the current OpenShell policy for Shields restore"); + const snapshotBinding = transition?.snapshotPolicy ?? state.shieldsPolicySnapshot; + if (!snapshotBinding) { + throw new Error("Shields recovery has no bound restrictive policy snapshot"); } - let managedMcpOmissions: ManagedMcpPolicyOmission[] = []; - let runtimePolicyPath: string; - if (options.deadlineAuthoritative) { - const inspection = resolveProvableManagedMcpPoliciesForDeadline( - sandboxName, - policyAuthority.gatewayName, + const snapshotPolicy = readBoundShieldsPolicyArtifact( + snapshotBinding, + snapshotPath, + "Restrictive policy snapshot", + ).toString("utf-8"); + if (!transition?.forwardPolicy) { + throw new Error( + "Shields recovery has no bound forward-policy artifact; refusing to replace live OpenShell policy", ); - const runtime = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { - managedMcpPolicies: inspection.policies, - snapshotManagedPolicyKeys, - readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), - }); - runtimePolicyPath = runtime.path; - managedMcpOmissions = [...ownershipOmissions, ...inspection.omissions, ...runtime.omissions]; - } else { - const managedMcpPolicies = - policyMutationAllowed && !options.externalVerificationOnly - ? resolveExactManagedMcpPolicies(sandboxName, undefined, policyAuthority.gatewayName) - : inspectRecordedManagedMcpPolicies(sandboxName); - runtimePolicyPath = buildRuntimeManagedMcpPolicy(snapshotPath, { - managedMcpPolicies, - snapshotManagedPolicyKeys, - readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), - }); } - const runtimePolicyIsTemp = runtimePolicyPath !== snapshotPath; + const forwardPolicy = readBoundShieldsPolicyArtifact( + transition.forwardPolicy, + transition.forwardPolicy.path, + "Shields-down forward policy", + ).toString("utf-8"); + const restoredPolicy = restoreShieldsDelta(snapshotPolicy, forwardPolicy, livePolicy); + const stagedPath = secureTempFile("nemoclaw-shields-restore", ".yaml"); try { - const externalRequiredPolicy = readShieldsPolicySnapshot(runtimePolicyPath); - let externalPolicyRecoveryArtifact = validatedExternalPolicyRecoveryArtifact( - sandboxName, - externalRequiredPolicy, - state.externalPolicyRecoveryArtifact, - ); - if ( - options.persistExternalRecoveryArtifact === true && - (!policyMutationAllowed || options.externalVerificationOnly === true) - ) { - externalPolicyRecoveryArtifact = publishExternalPolicyRecoveryArtifact( + fs.writeFileSync(stagedPath, restoredPolicy, { mode: 0o600 }); + const result = (options.runPolicySet ?? run)( + (options.buildPolicySet ?? buildPolicySetCommand)( + stagedPath, sandboxName, - externalRequiredPolicy, - ); - } - if (options.externalVerificationOnly && policyMutationAllowed) { - throw new ExternalShieldsPolicyRecoveryError( - "authority-drift", - externalPolicyRecoveryHandoff( - sandboxName, - externalRequiredPolicy, - "authority-drift", - externalPolicyRecoveryArtifact, - ), - externalPolicyRecoveryArtifact, - ); - } - if (!policyMutationAllowed) { - resolveShieldsPolicySnapshotRestoreAuthority( - sandboxName, - externalRequiredPolicy, - policyAuthority, - externalPolicyRecoveryArtifact, - ); - return { - pid: process.pid, - output: [null, "", ""], - stdout: "", - stderr: "", - status: 0, - signal: null, - externalPolicyVerified: true, - externalRequiredPolicy: structuredClone(externalRequiredPolicy), - ...(externalPolicyRecoveryArtifact ? { externalPolicyRecoveryArtifact } : {}), - }; - } - assertShieldsPolicyMutationAuthority( - sandboxName, - "restore the Shields policy snapshot", - policyAuthority, - ); - const result = runPolicySet( - buildPolicySet(runtimePolicyPath, sandboxName, policyAuthority.gatewayName), - { - ignoreError: true, - }, + context.gatewayName, + ), + { ignoreError: true }, ); rejectFinalShieldsPolicySetResult(result, "restore the Shields policy snapshot"); - finalizePolicyMutationReceipt( - sandboxName, - fs.readFileSync(runtimePolicyPath, "utf-8"), - policyAuthority, - ); - return managedMcpOmissions.length > 0 ? { ...result, managedMcpOmissions } : result; + if (result.status === 0) verifyAppliedPolicyDocument(sandboxName, restoredPolicy, context); + return result; } finally { - if (runtimePolicyIsTemp) { - cleanupTempDir(runtimePolicyPath, "nemoclaw-permissive-runtime"); - } + cleanupTempDir(stagedPath, "nemoclaw-shields-restore"); } } @@ -4934,6 +4373,8 @@ function rollbackShieldsDown( shieldsDownTimeout: null, shieldsDownReason: null, shieldsDownPolicy: null, + shieldsPolicySnapshotPath: null, + shieldsPolicySnapshot: null, chattrApplied: rollbackChattrApplied, fileHashes: rollbackFileHashes, }); @@ -4954,8 +4395,6 @@ interface LockdownActivationResult { error?: string; chattrApplied?: boolean; fileHashes?: { [path: string]: string }; - managedMcpOmissions?: ManagedMcpPolicyOmission[]; - externalPolicyRecoveryArtifact?: BoundShieldsPolicyArtifact; } function activateLockdownFromSnapshot( @@ -4974,7 +4413,6 @@ function activateLockdownFromSnapshot( try { restoreResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { ...restoreOptions, - persistExternalRecoveryArtifact: true, }); } catch (error) { return { @@ -4982,9 +4420,6 @@ function activateLockdownFromSnapshot( error: `policy restore preparation failed: ${ error instanceof Error ? error.message : String(error) }`, - ...(error instanceof ExternalShieldsPolicyRecoveryError && error.recoveryArtifact - ? { externalPolicyRecoveryArtifact: error.recoveryArtifact } - : {}), }; } const restoreStatus = typeof restoreResult.status === "number" ? restoreResult.status : 1; @@ -4992,9 +4427,6 @@ function activateLockdownFromSnapshot( return { ok: false, error: `policy restore exited with status ${String(restoreStatus)}`, - ...(restoreResult.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } - : {}), }; } @@ -5011,9 +4443,6 @@ function activateLockdownFromSnapshot( return { ok: false, error: error instanceof Error ? error.message : String(error), - ...(restoreResult.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } - : {}), }; } // Re-confirm the lock after a settle window. This restore feeds the @@ -5028,48 +4457,12 @@ function activateLockdownFromSnapshot( return { ok: false, error: relock.error ?? "config re-lock did not re-confirm after the settle window", - ...(restoreResult.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } - : {}), - }; - } - try { - if (restoreResult.externalPolicyVerified) { - applyShieldsPolicySnapshot(sandboxName, snapshotPath, { - ...restoreOptions, - externalVerificationOnly: true, - }); - } else { - resolveShieldsPolicySnapshotRestoreAuthority( - sandboxName, - readShieldsPolicySnapshot(snapshotPath), - ); - } - } catch (error) { - return { - ok: false, - error: `policy verification after config lock failed: ${ - error instanceof Error ? error.message : String(error) - }`, - chattrApplied: relock.lastResult.chattrApplied, - fileHashes: relock.lastResult.fileHashes, - ...(error instanceof ExternalShieldsPolicyRecoveryError && error.recoveryArtifact - ? { externalPolicyRecoveryArtifact: error.recoveryArtifact } - : restoreResult.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } - : {}), }; } return { ok: true, chattrApplied: relock.lastResult.chattrApplied, fileHashes: relock.lastResult.fileHashes, - ...(restoreResult.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } - : {}), - ...(restoreResult.managedMcpOmissions - ? { managedMcpOmissions: restoreResult.managedMcpOmissions } - : {}), }; } @@ -5099,6 +4492,14 @@ function recoverExpiredAutoRestoreInline( : " Warning: DOWN state has a missing or malformed auto-restore marker; attempting inline restore under the lifecycle gate.", ); + const stateBoundTransition = marker + ? null + : readStateBoundActiveShieldsDownTransition(sandboxName, state); + const recoveryProcessToken = + marker?.processToken && /^[0-9a-f]{32}$/.test(marker.processToken) + ? marker.processToken + : stateBoundTransition?.processToken; + if (marker?.processToken && /^[0-9a-f]{32}$/.test(marker.processToken)) { try { synchronizeAutoRestoreTransition(sandboxName, marker.processToken, marker.snapshotPath, { @@ -5129,11 +4530,10 @@ function recoverExpiredAutoRestoreInline( marker?.allowLegacyHermesProtocol === true, cachedTarget, undefined, - marker?.processToken && /^[0-9a-f]{32}$/.test(marker.processToken) + recoveryProcessToken ? { - transitionProcessToken: marker.processToken, - deadlineAuthoritative: true, - expiredTimerRecovery: true, + transitionProcessToken: recoveryProcessToken, + ...(marker ? { deadlineAuthoritative: true, expiredTimerRecovery: true } : {}), } : {}, ); @@ -5141,18 +4541,11 @@ function recoverExpiredAutoRestoreInline( if (!activation.ok) { const configLocked = activation.fileHashes !== undefined && typeof activation.chattrApplied === "boolean"; - if (configLocked || activation.externalPolicyRecoveryArtifact) { + if (configLocked) { saveShieldsState(sandboxName, { - ...(configLocked - ? { - policyRecoveryConfigLocked: true, - chattrApplied: activation.chattrApplied, - fileHashes: activation.fileHashes, - } - : {}), - ...(activation.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: activation.externalPolicyRecoveryArtifact } - : {}), + policyRecoveryConfigLocked: true, + chattrApplied: activation.chattrApplied, + fileHashes: activation.fileHashes, }); } appendAuditEntry({ @@ -5173,25 +4566,23 @@ function recoverExpiredAutoRestoreInline( return { attempted: true, restored: false }; } - commitExternalPolicyRecoveryArtifactRetirement(sandboxName, () => { - saveShieldsState(sandboxName, { - shieldsDown: false, - shieldsDownAt: null, - shieldsDownTimeout: null, - shieldsDownReason: null, - shieldsDownPolicy: null, - policyRecoveryConfigLocked: false, - externalPolicyRecoveryArtifact: undefined, - ...(activation.fileHashes && typeof activation.chattrApplied === "boolean" - ? { - chattrApplied: activation.chattrApplied, - fileHashes: activation.fileHashes, - } - : {}), - }); + saveShieldsState(sandboxName, { + shieldsDown: false, + shieldsDownAt: null, + shieldsDownTimeout: null, + shieldsDownReason: null, + shieldsDownPolicy: null, + shieldsPolicySnapshotPath: null, + shieldsPolicySnapshot: null, + ...(activation.fileHashes && typeof activation.chattrApplied === "boolean" + ? { + chattrApplied: activation.chattrApplied, + fileHashes: activation.fileHashes, + } + : {}), }); - if (marker?.processToken && /^[0-9a-f]{32}$/.test(marker.processToken)) { - clearShieldsDownTransition(sandboxName, marker.processToken); + if (recoveryProcessToken) { + clearShieldsDownTransition(sandboxName, recoveryProcessToken); } clearTimerMarker(sandboxName); appendAuditEntry({ @@ -5201,13 +4592,6 @@ function recoverExpiredAutoRestoreInline( restored_by: "auto_timer", policy_snapshot: snapshotPath, restored_at: nowIso, - ...(activation.managedMcpOmissions?.length - ? { - warning: `Inline auto-restore omitted ${String( - activation.managedMcpOmissions.length, - )} unproven managed MCP policy entries`, - } - : {}), }); return { attempted: true, restored: true }; } @@ -5303,11 +4687,7 @@ function prepareRecoveredShieldsDownCompletion( ); } const transition = readShieldsDownTransition(sandboxName, marker.processToken); - if ( - !transition || - transition.snapshotPath !== marker.snapshotPath || - !sameManagedMcpPolicyKeys(transition.managedMcpPolicyKeys, state.shieldsManagedMcpPolicyKeys) - ) { + if (!transition || transition.snapshotPath !== marker.snapshotPath) { throw new Error( "Interrupted Hermes Shields down recovery no longer matches its timer-bound transition", ); @@ -5330,25 +4710,25 @@ function applyRecoveredShieldsDownForwardPolicy( completion: RecoveredShieldsDownCompletion, ): void { if (!completion.authority) return; - const policyAuthority = assertShieldsPolicyMutationAuthority( + const policyContext = assertShieldsPolicyMutationContext( sandboxName, "reapply the interrupted Shields down policy", ); assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); const policyPath = requireShieldsDownForwardPolicy(completion.authority); - assertShieldsPolicyMutationAuthority( + assertShieldsPolicyMutationContext( sandboxName, "reapply the interrupted Shields down policy", - policyAuthority, + policyContext, ); - const result = run(buildPolicySetCommand(policyPath, sandboxName, policyAuthority.gatewayName), { + const result = run(buildPolicySetCommand(policyPath, sandboxName, policyContext.gatewayName), { ignoreError: true, }); rejectFinalShieldsPolicySetResult(result, "reapply the interrupted Shields down policy"); if (result.status !== 0) { throw new Error("Interrupted Shields down forward policy could not be reapplied"); } - finalizePolicyMutationReceipt(sandboxName, fs.readFileSync(policyPath, "utf-8"), policyAuthority); + verifyAppliedPolicyDocument(sandboxName, fs.readFileSync(policyPath, "utf-8"), policyContext); requireShieldsDownForwardPolicy(completion.authority); assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); } @@ -5474,10 +4854,10 @@ function startFreshShieldsDownTimer(input: { timeoutSeconds: number; processToken: string; snapshotPath: string; + snapshotPolicy: BoundShieldsPolicyArtifact; target: AgentConfigTarget; allowLegacyHermesProtocol: boolean; deferAutoRestoreWhileOwnerAlive: boolean; - managedMcpPolicyKeys: string[]; policyFile: string; }): FreshShieldsDownTimerStart { const { @@ -5485,10 +4865,10 @@ function startFreshShieldsDownTimer(input: { timeoutSeconds, processToken, snapshotPath, + snapshotPolicy, target, allowLegacyHermesProtocol, deferAutoRestoreWhileOwnerAlive, - managedMcpPolicyKeys, policyFile, } = input; const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); @@ -5513,7 +4893,7 @@ function startFreshShieldsDownTimer(input: { processToken, sandboxName, snapshotPath, - managedMcpPolicyKeys, + snapshotPolicy, forwardPolicy, }; const leaseOwnerPid = deferAutoRestoreWhileOwnerAlive ? transition.ownerPid : null; @@ -5752,7 +5132,7 @@ function shieldsDownWithoutHostLock( return failShieldsCommand(`Config is already unlocked for ${sandboxName}`, opts.throwOnError); } - const policyAuthority = assertShieldsPolicyMutationAuthority(sandboxName, "lower Shields"); + const policyContext = assertShieldsPolicyMutationContext(sandboxName, "lower Shields"); // Resolve the old-image compatibility contract before touching timers, // host state, policy, or sandbox files. A transport failure or an @@ -5774,10 +5154,10 @@ function shieldsDownWithoutHostLock( // Kill stale auto-restore markers only when this command will actually // transition into shields-down. A repeated shields-down must not cancel the // active timer and leave the sandbox unlocked indefinitely. - assertShieldsPolicyMutationAuthority( + assertShieldsPolicyMutationContext( sandboxName, "revoke stale Shields timer authority", - policyAuthority, + policyContext, ); const timerCancellation = killTimer(sandboxName); if (!timerCancellation.authorityRevoked) { @@ -5798,11 +5178,11 @@ function shieldsDownWithoutHostLock( console.log(" Capturing current policy snapshot..."); let rawPolicy: string; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName, policyAuthority.gatewayName)); + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName, policyContext.gatewayName)); } catch { rawPolicy = ""; } - assertShieldsPolicyMutationAuthority(sandboxName, "continue lowering Shields", policyAuthority); + assertShieldsPolicyMutationContext(sandboxName, "continue lowering Shields", policyContext); const policyYaml = parseCurrentPolicy(rawPolicy); if (!policyYaml) { @@ -5810,26 +5190,10 @@ function shieldsDownWithoutHostLock( return failShieldsCommand("Cannot capture current policy", opts.throwOnError); } - let managedMcpPolicies: ReturnType; - try { - managedMcpPolicies = resolveExactManagedMcpPolicies( - sandboxName, - policyYaml, - policyAuthority.gatewayName, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(` Cannot preserve managed MCP policy state: ${message}`); - return failShieldsCommand( - `Cannot preserve managed MCP policy state: ${message}`, - opts.throwOnError, - ); - } - const snapshotManagedMcpPolicyKeys = managedMcpPolicies.map((policy) => policy.key); - assertShieldsPolicyMutationAuthority( + assertShieldsPolicyMutationContext( sandboxName, "capture the Shields policy snapshot", - policyAuthority, + policyContext, ); const snapshotPath = path.join( @@ -5861,15 +5225,14 @@ function shieldsDownWithoutHostLock( // entries are overlaid without copying any unrelated live egress. policyFile = buildRuntimePermissivePolicy(basePath, { livePolicyYaml: policyYaml, - managedMcpPolicies, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), ...(target.agentName === "hermes" ? { sandboxName } : {}), }); policyFileIsTemp = policyFile !== basePath; } else if (fs.existsSync(policyName)) { const basePath = path.resolve(policyName); - policyFile = buildRuntimeManagedMcpPolicy(basePath, { - managedMcpPolicies, + policyFile = buildRuntimePolicyWithLiveMcpEntries(basePath, { + livePolicyYaml: policyYaml, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), }); policyFileIsTemp = policyFile !== basePath; @@ -5903,20 +5266,20 @@ function shieldsDownWithoutHostLock( // down. A crash can therefore never leave an untracked mutable window. let timerStart: FreshShieldsDownTimerStart; try { - assertShieldsPolicyMutationAuthority( + assertShieldsPolicyMutationContext( sandboxName, "start the Shields auto-restore timer", - policyAuthority, + policyContext, ); timerStart = startFreshShieldsDownTimer({ sandboxName, timeoutSeconds, processToken, snapshotPath, + snapshotPolicy, target, allowLegacyHermesProtocol: opts.allowLegacyHermesProtocol === true, deferAutoRestoreWhileOwnerAlive: opts.deferAutoRestoreWhileOwnerAlive === true, - managedMcpPolicyKeys: snapshotManagedMcpPolicyKeys, policyFile, }); } catch (error) { @@ -5932,10 +5295,10 @@ function shieldsDownWithoutHostLock( if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); } - assertShieldsPolicyMutationAuthority( + assertShieldsPolicyMutationContext( sandboxName, "record the provisional Shields down state", - policyAuthority, + policyContext, ); saveShieldsState(sandboxName, { shieldsDown: true, @@ -5944,7 +5307,7 @@ function shieldsDownWithoutHostLock( shieldsDownReason: reason, shieldsDownPolicy: policyName, shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: snapshotManagedMcpPolicyKeys, + shieldsPolicySnapshot: snapshotPolicy, }); } catch (error) { if (transition) { @@ -5993,13 +5356,9 @@ function shieldsDownWithoutHostLock( const appliedPolicyDocument = fs.readFileSync(policyPathForApply, "utf-8"); let policySetResult: ReturnType; try { - assertShieldsPolicyMutationAuthority( - sandboxName, - "apply the Shields down policy", - policyAuthority, - ); + assertShieldsPolicyMutationContext(sandboxName, "apply the Shields down policy", policyContext); policySetResult = run( - buildPolicySetCommand(policyPathForApply, sandboxName, policyAuthority.gatewayName), + buildPolicySetCommand(policyPathForApply, sandboxName, policyContext.gatewayName), { ignoreError: true, }, @@ -6007,12 +5366,12 @@ function shieldsDownWithoutHostLock( } finally { cleanupRuntimePolicyFile(); } - let policyAuthorityRefusal: unknown = null; + let policyObservationFailure: unknown = null; try { rejectFinalShieldsPolicySetResult(policySetResult, "apply the Shields down policy"); } catch (error) { - if (!isPolicyAuthorityRefusalError(error)) throw error; - policyAuthorityRefusal = error; + if (!isPolicyObservationError(error)) throw error; + policyObservationFailure = error; } if (policySetResult.status !== 0) { // The permissive policy was rejected before it applied — for example, @@ -6032,6 +5391,7 @@ function shieldsDownWithoutHostLock( shieldsDownReason: null, shieldsDownPolicy: null, shieldsPolicySnapshotPath: null, + shieldsPolicySnapshot: null, }); } catch (stateErr) { // Clearing the provisional Shields down record failed, so on disk the @@ -6056,7 +5416,7 @@ function shieldsDownWithoutHostLock( ` ERROR: Could not apply the ${policyName} policy, and clearing the provisional Shields down record failed: ${stateMessage}`, ); console.error(" The scheduled auto-restore remains authoritative."); - if (policyAuthorityRefusal !== null) throw policyAuthorityRefusal; + if (policyObservationFailure !== null) throw policyObservationFailure; return failShieldsCommand(`Could not apply ${policyName} policy`, opts.throwOnError); } const timerCancellation = killTimer(sandboxName); @@ -6067,10 +5427,10 @@ function shieldsDownWithoutHostLock( ` ERROR: Could not apply the ${policyName} policy; the sandbox remains in the Shields up state.`, ); console.error(" Shields down did not take effect. `shields status` continues to report `UP`."); - if (policyAuthorityRefusal !== null) throw policyAuthorityRefusal; + if (policyObservationFailure !== null) throw policyObservationFailure; return failShieldsCommand(`Could not apply ${policyName} policy`, opts.throwOnError); } - if (policyAuthorityRefusal !== null) throw policyAuthorityRefusal; + if (policyObservationFailure !== null) throw policyObservationFailure; // 2b. Return config to default mutable state. // OpenClaw uses sandbox:sandbox 0660/2770 here so the gateway UID, which @@ -6078,7 +5438,7 @@ function shieldsDownWithoutHostLock( console.log(` Unlocking ${target.agentName} config (${target.configPath})...`); let inferenceRouteConvergenceFailed = false; try { - finalizePolicyMutationReceipt(sandboxName, appliedPolicyDocument, policyAuthority); + verifyAppliedPolicyDocument(sandboxName, appliedPolicyDocument, policyContext); if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); } @@ -6477,18 +5837,11 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) if (!activation.ok) { const configLocked = activation.fileHashes !== undefined && typeof activation.chattrApplied === "boolean"; - if (configLocked || activation.externalPolicyRecoveryArtifact) { + if (configLocked) { saveShieldsState(sandboxName, { - ...(configLocked - ? { - policyRecoveryConfigLocked: true, - chattrApplied: activation.chattrApplied, - fileHashes: activation.fileHashes, - } - : {}), - ...(activation.externalPolicyRecoveryArtifact - ? { externalPolicyRecoveryArtifact: activation.externalPolicyRecoveryArtifact } - : {}), + policyRecoveryConfigLocked: true, + chattrApplied: activation.chattrApplied, + fileHashes: activation.fileHashes, }); } console.error(` ERROR: ${activation.error ?? "unknown restore error"}`); @@ -6545,22 +5898,20 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) // captured chattrApplied + fileHashes into the persisted state so // drift detection on the next `shields status` has a seal to compare // against. The non-snapshot branch already persisted those above. - commitExternalPolicyRecoveryArtifactRetirement(sandboxName, () => { - saveShieldsState(sandboxName, { - shieldsDown: false, - shieldsDownAt: null, - shieldsDownTimeout: null, - shieldsDownReason: null, - shieldsDownPolicy: null, - policyRecoveryConfigLocked: false, - externalPolicyRecoveryArtifact: undefined, - ...(snapshotLockResult - ? { - chattrApplied: snapshotLockResult.chattrApplied, - fileHashes: snapshotLockResult.fileHashes, - } - : {}), - }); + saveShieldsState(sandboxName, { + shieldsDown: false, + shieldsDownAt: null, + shieldsDownTimeout: null, + shieldsDownReason: null, + shieldsDownPolicy: null, + shieldsPolicySnapshotPath: null, + shieldsPolicySnapshot: null, + ...(snapshotLockResult + ? { + chattrApplied: snapshotLockResult.chattrApplied, + fileHashes: snapshotLockResult.fileHashes, + } + : {}), }); killTimer(sandboxName); if (timerMarker?.processToken && /^[0-9a-f]{32}$/.test(timerMarker.processToken)) { @@ -6611,7 +5962,6 @@ type ShieldsStatusDeps = { resolveConfig?: typeof resolveAgentConfig; verifyStateLockPlan?: (sandboxName: string, target: AgentConfigTarget) => string[]; assertCommandAvailable?: () => void; - inspectPolicyRecovery?: typeof inspectShieldsPolicyRecovery; }; function verifyHermesProviderMutableStatus( @@ -6664,7 +6014,6 @@ function shieldsStatusWithoutHostLock( const verify = deps.verifyLockState ?? verifyShieldsLockState; const resolveConfig = deps.resolveConfig ?? resolveAgentConfig; - const inspectPolicyRecovery = deps.inspectPolicyRecovery ?? inspectShieldsPolicyRecovery; const posture = getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery); const { state } = posture; @@ -6796,19 +6145,12 @@ function shieldsStatusWithoutHostLock( throw new DeferredShieldsExit("Locked shields state has filesystem drift", 2); } if (policyRecoveryLocked) { - const policyRecovery = inspectPolicyRecovery(sandboxName); console.error(" Shields: DOWN (CONFIG LOCKED — POLICY RECOVERY REQUIRED)"); console.error(policyLine); console.error(" Config: locked and verified"); - if (policyRecovery.status === "unavailable") { - console.error(` Policy authority: ${policyRecovery.detail}`); - } else if (policyRecovery.status === "external") { - console.error(` Recovery: ${policyRecovery.handoff}`); - } else { - console.error( - ` Recovery: run \`${CLI_NAME} ${sandboxName} shields up\` to verify policy and complete Shields up.`, - ); - } + console.error( + ` Recovery: run \`${CLI_NAME} ${sandboxName} shields up\` to verify policy and complete Shields up.`, + ); throw new DeferredShieldsExit("Locked config is waiting for policy recovery", 2); } if (!state.fileHashes) { @@ -6843,24 +6185,7 @@ function shieldsStatusWithoutHostLock( const elapsed = downSince ? Math.floor((Date.now() - downSince.getTime()) / 1000) : 0; const remaining = state.shieldsDownTimeout != null ? Math.max(0, state.shieldsDownTimeout - elapsed) : null; - const policyRecovery = inspectPolicyRecovery(sandboxName); - - if (policyRecovery.status === "unavailable") { - console.error(" Shields: DOWN (RECOVERY REQUIRED — policy authority unavailable)"); - console.error(` Policy authority: ${policyRecovery.detail}`); - console.error( - ` Recovery: restore policy authority inspection for sandbox '${sandboxName}', then retry \`${CLI_NAME} ${sandboxName} shields status\` before relying on automatic lockdown.`, - ); - throw new DeferredShieldsExit("Policy authority inspection is required", 2); - } - - const recoveryHandoff = policyRecovery.status === "external" ? policyRecovery.handoff : null; - - console.log( - recoveryHandoff - ? " Shields: DOWN (RECOVERY REQUIRED — policy is externally managed)" - : ` Shields: ${posture.statusText}`, - ); + console.log(` Shields: ${posture.statusText}`); console.log(` Since: ${state.shieldsDownAt ?? "unknown"}`); if (remaining !== null) { const mins = Math.floor(remaining / 60); @@ -6869,10 +6194,6 @@ function shieldsStatusWithoutHostLock( } console.log(` Reason: ${state.shieldsDownReason ?? "not specified"}`); console.log(` Policy: ${state.shieldsDownPolicy ?? "permissive"}`); - if (recoveryHandoff) { - console.error(` Recovery: ${recoveryHandoff}`); - throw new DeferredShieldsExit("External policy restoration is required", 2); - } return; } } @@ -6931,12 +6252,10 @@ function isShieldsDown(sandboxName: string, allowInlineRecovery = false): boolea function clearShieldsStateWithoutHostLock(sandboxName: string): void { validateName(sandboxName, "sandbox name"); const timerMarker = readTimerMarker(sandboxName); - commitExternalPolicyRecoveryArtifactRetirement(sandboxName, () => { - const filePath = stateFilePath(sandboxName); - const stateFileExists = fs.existsSync(filePath); - fs.rmSync(filePath, { force: true }); - if (stateFileExists) fsyncShieldsStateDirectory(); - }); + const filePath = stateFilePath(sandboxName); + const stateFileExists = fs.existsSync(filePath); + fs.rmSync(filePath, { force: true }); + if (stateFileExists) fsyncShieldsStateDirectory(); killTimer(sandboxName); if (timerMarker?.processToken && /^[0-9a-f]{32}$/.test(timerMarker.processToken)) { clearShieldsDownTransition(sandboxName, timerMarker.processToken); @@ -6950,13 +6269,15 @@ function clearShieldsState(sandboxName: string): void { ); } +export const shieldsPolicyDeltaInternals = { restoreShieldsDelta }; + // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- export { applyShieldsPolicySnapshot, - assertShieldsPolicyMutationAuthority, + assertShieldsPolicyMutationContext, clearShieldsState, completeAutoRestoreTransition, DEFAULT_TIMEOUT_SECONDS, diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index 5274e003090..d83a692c320 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -17,6 +17,7 @@ import { hermesProviderConsumerSandbox as sandbox, hermesProviderConsumerTarget as target, writeBoundForwardPolicy, + writeBoundPolicySnapshot, writeTimerAuthorizationProof, } from "../../../test/helpers/hermes-shields-provider-consumer-harness"; import * as shieldsFlow from "../../../test/helpers/shields-flow-harness"; @@ -164,7 +165,7 @@ function expectTimerReplacementRejectedAfterMutation({ expect(runSpy).toHaveBeenCalled(); expect(transitionSpy).toHaveBeenCalled(); expect(routeSpy).toHaveBeenCalledTimes(1); - expect(fs.existsSync(transitionPath)).toBe(false); + expect(fs.existsSync(transitionPath)).toBe(true); } const forwardPolicyFailureFixtures: ReadonlyArray< @@ -251,13 +252,12 @@ describe("legacy Hermes shields compatibility", () => { ]), vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)), vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue(permissivePolicyPath), - ...shieldsFlow.bindManagedPolicyMutationAuthority(policy), + ...shieldsFlow.bindLivePolicyMutationContext(policy), vi.spyOn(agentConfig, "resolveAgentConfig").mockImplementation(() => hermesTarget()), vi.spyOn(registry, "getSandbox").mockImplementation((name: unknown) => ({ name: String(name), agent: "hermes", openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", lifecycleGeneration: "legacy-generation", workload: { kind: "managed-image" }, })), @@ -855,7 +855,7 @@ describe("legacy Hermes shields compatibility", () => { recursive: true, mode: 0o700, }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); const forwardPolicy = writeBoundForwardPolicy(stateDir, sandbox.name, processToken); fs.writeFileSync( path.join(stateDir, `shields-${sandbox.name}.json`), @@ -865,7 +865,7 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "crash retry", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( @@ -893,7 +893,7 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "dead-provider-owner", processToken, sandboxName: sandbox.name, - snapshotPath, + snapshotPath, snapshotPolicy, forwardPolicy, }), ); @@ -976,7 +976,7 @@ describe("legacy Hermes shields compatibility", () => { `shields-transition-${sandbox.name}-${processToken}.json`, ); fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); const forwardPolicy = writeBoundForwardPolicy(stateDir, sandbox.name, processToken); fs.writeFileSync( path.join(stateDir, `shields-${sandbox.name}.json`), @@ -986,7 +986,7 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "post-release crash", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, }), ); fs.writeFileSync( @@ -1014,7 +1014,7 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "dead-post-release-owner", processToken, sandboxName: sandbox.name, - snapshotPath, + snapshotPath, snapshotPolicy, forwardPolicy, }), ); @@ -1060,7 +1060,7 @@ describe("legacy Hermes shields compatibility", () => { `shields-transition-${sandbox.name}-${processToken}.json`, ); fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); const forwardPolicy = writeBoundForwardPolicy(stateDir, sandbox.name, processToken); fs.writeFileSync( path.join(stateDir, `shields-${sandbox.name}.json`), @@ -1070,7 +1070,7 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "invalid forward policy", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, }), ); const timerPath = path.join(stateDir, `shields-timer-${sandbox.name}.json`); @@ -1099,7 +1099,7 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "dead-forward-owner", processToken, sandboxName: sandbox.name, - snapshotPath, + snapshotPath, snapshotPolicy, forwardPolicy, }), ); @@ -1277,7 +1277,7 @@ describe("legacy Hermes shields compatibility", () => { `shields-transition-${sandbox.name}-${processToken}.json`, ); fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive: {}\n"); + const snapshotPolicy = writeBoundPolicySnapshot(snapshotPath); const forwardPolicy = writeBoundForwardPolicy(stateDir, sandbox.name, processToken); fs.writeFileSync( path.join(stateDir, `shields-${sandbox.name}.json`), @@ -1287,7 +1287,7 @@ describe("legacy Hermes shields compatibility", () => { shieldsDownTimeout: 300, shieldsDownReason: "timed mutable status", shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshotPath: snapshotPath, shieldsPolicySnapshot: snapshotPolicy, updatedAt: new Date().toISOString(), }), ); @@ -1316,8 +1316,7 @@ describe("legacy Hermes shields compatibility", () => { ownerStartIdentity: "timed-status-owner", processToken, sandboxName: sandbox.name, - snapshotPath, - managedMcpPolicyKeys: [], + snapshotPath, snapshotPolicy, forwardPolicy, }), ); diff --git a/src/lib/shields/mcp-policy-transition.test.ts b/src/lib/shields/mcp-policy-transition.test.ts index 49431d1b5bb..97d9ac86f37 100644 --- a/src/lib/shields/mcp-policy-transition.test.ts +++ b/src/lib/shields/mcp-policy-transition.test.ts @@ -3,824 +3,60 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; -import { testTimeoutOptions } from "../../../test/helpers/timeouts"; -import { - hasManagedMcpPolicyClaims, - inspectProvableManagedMcpPoliciesForDeadline, - inspectExactManagedMcpPolicies as inspectRegisteredManagedMcpPolicies, - inspectRecordedManagedMcpPolicies, - MCP_BRIDGE_POLICY_SOURCE, -} from "../actions/sandbox/mcp-bridge-policy"; -import { - buildMcpBridgePolicyKey, - buildMcpBridgePolicyName, - buildMcpBridgePolicyYaml, -} from "../actions/sandbox/mcp-bridge-policy-render"; -import { - isOperatorTrustablePrivateIp, - replayTrustedPrivateEndpoint, -} from "../security/trusted-private-endpoint"; -import type { SandboxEntry } from "../state/registry"; -import { - assertLegacyMcpPolicyRestoreSafe, - composeDeadlineManagedMcpPolicies, - composeManagedMcpPolicies, -} from "./mcp-policy-transition"; +import { composeLiveMcpPolicies } from "./mcp-policy-transition"; -const ADAPTER = "hermes-config"; - -function registeredPolicy( - server: string, - address: string, -): NonNullable[number] { - const host = `${server}.example.com`; - const target = isOperatorTrustablePrivateIp(address) - ? (() => { - const replay = replayTrustedPrivateEndpoint(host, [address]); - return { - addresses: [...replay.addresses], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }; - })() - : { addresses: [address] }; - return { - name: buildMcpBridgePolicyName(server), - content: buildMcpBridgePolicyYaml( - server, - `https://${host}/mcp`, - ADAPTER, - target, - `sandbox-mcp-${server}`, - ), - sourcePath: MCP_BRIDGE_POLICY_SOURCE, - }; -} - -function bridge(server: string): NonNullable["bridges"]>[string] { - return { - server, - agent: "hermes", - adapter: ADAPTER, - url: `https://${server}.example.com/mcp`, - env: ["MCP_SECRET"], - providerName: `sandbox-mcp-${server}`, - providerId: `provider-${server}`, - policyName: buildMcpBridgePolicyName(server), - addedAt: "2026-07-30T00:00:00.000Z", - }; -} - -function sandboxWithPolicies( - policies: Array>, - bridgeServers = policies.map((policy) => policy.name.replace(/^mcp-bridge-/, "")), -): SandboxEntry { - return { - name: "alpha", - agent: "hermes", - customPolicies: policies, - mcp: { - bridges: Object.fromEntries(bridgeServers.map((server) => [server, bridge(server)])), - }, - }; -} - -function networkEntry(content: string, server: string): unknown { - return YAML.parse(content).network_policies[buildMcpBridgePolicyKey(server)]; -} - -function mutateRegisteredNetworkPolicy( - policy: ReturnType, - server: string, - mutate: (entry: Record) => void, -): void { - const document = YAML.parse(policy.content) as { - network_policies: Record>; - }; - mutate(document.network_policies[buildMcpBridgePolicyKey(server)]!); - policy.content = YAML.stringify(document); -} - -function livePolicy( - entries: Array<{ content: string; server: string }>, - extra: Record = {}, -): string { - return YAML.stringify({ - version: 1, - network_policies: { - ...extra, - ...Object.fromEntries( - entries.map(({ content, server }) => [ - buildMcpBridgePolicyKey(server), - networkEntry(content, server), - ]), - ), - }, - }); -} - -function inspectExactManagedMcpPolicies(sandbox: SandboxEntry, livePolicyYaml: string) { - return inspectRegisteredManagedMcpPolicies("alpha", livePolicyYaml, { - getSandbox: () => sandbox, - }); -} - -describe("managed MCP Shields policy transitions (#7952)", () => { - it("renders a canonical recorded entry for an external policy handoff (#9833)", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - - expect( - inspectRecordedManagedMcpPolicies("alpha", { - getSandbox: () => sandboxWithPolicies([alpha]), - }), - ).toEqual([ - expect.objectContaining({ - key: "mcp_bridge_alpha", - networkPolicy: networkEntry(alpha.content, "alpha"), - policyName: "mcp-bridge-alpha", - server: "alpha", - }), - ]); - }); - - it("admits only canonical committed registrations that exactly match the live policy", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const sandbox = sandboxWithPolicies([alpha]); - - const exact = inspectExactManagedMcpPolicies( - sandbox, - livePolicy([{ content: alpha.content, server: "alpha" }], { - unrelated_live_entry: { endpoints: [{ host: "unrelated.example.com" }] }, - }), - ); - - expect(exact).toEqual([ - expect.objectContaining({ - key: "mcp_bridge_alpha", - policyName: "mcp-bridge-alpha", - server: "alpha", - }), - ]); - }); - - it("admits exact recorded private pins only for a trusted-private bridge", () => { - const alpha = registeredPolicy("alpha", "10.20.30.40"); - const sandbox = sandboxWithPolicies([alpha]); - Object.assign(sandbox.mcp!.bridges.alpha!, { - trustedPrivateHost: "alpha.example.com", - allowedIps: ["10.20.30.40"], - }); - - expect( - inspectExactManagedMcpPolicies( - sandbox, - livePolicy([{ content: alpha.content, server: "alpha" }]), - ), - ).toEqual([ - expect.objectContaining({ - key: "mcp_bridge_alpha", - server: "alpha", - }), - ]); - }); - - it("rejects a trusted-private policy that differs from its durable pins", () => { - const alpha = registeredPolicy("alpha", "10.20.30.40"); - const sandbox = sandboxWithPolicies([alpha]); - Object.assign(sandbox.mcp!.bridges.alpha!, { - trustedPrivateHost: "alpha.example.com", - allowedIps: ["10.20.30.41"], - }); - - expect(() => - inspectExactManagedMcpPolicies( - sandbox, - livePolicy([{ content: alpha.content, server: "alpha" }]), - ), - ).toThrow(/does not match its recorded trusted-private address pins/); - }); - - it.each([ - { - label: "pending policy content", - mutate: (sandbox: SandboxEntry) => { - sandbox.customPolicies![0]!.pendingContent = sandbox.customPolicies![0]!.content; - }, - expected: /incomplete policy transition/, - }, - { - label: "an orphaned generated registration", - mutate: (sandbox: SandboxEntry) => { - sandbox.customPolicies!.push(registeredPolicy("orphan", "1.1.1.1")); - }, - expected: /no committed managed bridge ownership/, - }, - { - label: "an incomplete bridge add", - mutate: (sandbox: SandboxEntry) => { - sandbox.mcp!.bridges.alpha!.addState = "prepared"; - }, - expected: /lifecycle transition is incomplete/, - }, - ])("fails closed on $label", ({ mutate, expected }) => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const sandbox = sandboxWithPolicies([alpha]); - mutate(sandbox); - - expect(() => - inspectExactManagedMcpPolicies( - sandbox, - livePolicy( - (sandbox.customPolicies ?? []).map((policy) => ({ - content: policy.content, - server: policy.name.replace(/^mcp-bridge-/, ""), - })), - ), - ), - ).toThrow(expected); - }); - - it("fails closed when the live policy differs from the ownership record", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const drifted = registeredPolicy("alpha", "1.1.1.1"); - - expect(() => - inspectExactManagedMcpPolicies( - sandboxWithPolicies([alpha]), - livePolicy([{ content: drifted.content, server: "alpha" }]), - ), - ).toThrow(/drifted from its ownership record/); - }); - - it("rejects matching registry and live documents with weakened generated semantics", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - mutateRegisteredNetworkPolicy(alpha, "alpha", (entry) => { - const endpoint = (entry.endpoints as Array>)[0]!; - endpoint.enforcement = "observe"; - }); - const sandbox = sandboxWithPolicies([alpha]); - const live = livePolicy([{ content: alpha.content, server: "alpha" }]); - - expect(() => inspectExactManagedMcpPolicies(sandbox, live)).toThrow( - /non-canonical generated content/, - ); - expect( - inspectProvableManagedMcpPoliciesForDeadline("alpha", live, { - getSandbox: () => sandbox, - }), - ).toEqual({ - policies: [], - omissions: [ - expect.objectContaining({ - server: "alpha", - reason: expect.stringMatching(/non-canonical generated content/), - }), - ], - }); - }); - - it.each([ - { - label: "a private literal", - pins: ["127.0.0.1"], - expected: /invalid public address pins/, - }, - { - label: "a scoped public IPv6 literal", - pins: ["2001:4860:4860::8888%lo0"], - expected: /invalid public address pins/, - }, - { - label: "duplicate literals", - pins: ["8.8.8.8", "8.8.8.8"], - expected: /non-canonical public address pins/, - }, - { - label: "unsorted literals", - pins: ["8.8.8.8", "1.1.1.1"], - expected: /non-canonical public address pins/, - }, - ])("rejects matching registry and live documents with $label", ({ pins, expected }) => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - mutateRegisteredNetworkPolicy(alpha, "alpha", (entry) => { - const endpoint = (entry.endpoints as Array>)[0]!; - endpoint.allowed_ips = pins; - }); - - expect(() => - inspectExactManagedMcpPolicies( - sandboxWithPolicies([alpha]), - livePolicy([{ content: alpha.content, server: "alpha" }]), - ), - ).toThrow(expected); - }); - - it("fails closed on a generated policy record without managed MCP state", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const sandbox: SandboxEntry = { - name: "alpha", - agent: "hermes", - customPolicies: [alpha], - }; - const deps = { getSandbox: () => sandbox }; - - expect(hasManagedMcpPolicyClaims("alpha", deps)).toBe(true); - expect(() => - inspectRegisteredManagedMcpPolicies( - "alpha", - livePolicy([{ content: alpha.content, server: "alpha" }]), - deps, - ), - ).toThrow(/no committed managed bridge ownership/); - }); - - it("treats residual managed server history as an ownership claim", () => { - const sandbox: SandboxEntry = { - name: "alpha", - agent: "hermes", - mcp: { bridges: {}, managedServerNames: ["retired"] }, - }; - const deps = { getSandbox: () => sandbox }; - - expect(hasManagedMcpPolicyClaims("alpha", deps)).toBe(true); - expect( - inspectRegisteredManagedMcpPolicies( - "alpha", - livePolicy([], { unrelated_live_entry: {} }), - deps, - ), - ).toEqual([]); - }); - - it.each([ - { - label: "no sandbox registry entry", - sandbox: undefined, - }, - { - label: "only residual ownership history", - sandbox: { - name: "alpha", - agent: "hermes", - mcp: { bridges: {}, managedServerNames: ["retired"] }, - } satisfies SandboxEntry, - }, - ])("rejects an unclassified reserved live key with $label", ({ sandbox }) => { - expect(() => - inspectRegisteredManagedMcpPolicies("alpha", livePolicy([], { mcp_bridge_retired: {} }), { - getSandbox: () => sandbox ?? null, - }), - ).toThrow( - /Reserved MCP policy key "mcp_bridge_retired".*no committed managed bridge ownership/, - ); - }); - - it("escapes unclassified live policy keys in operator diagnostics", () => { - const maliciousKey = "mcp_bridge_\u001b[31mforged\nline\u0085"; - - let failure: unknown; - try { - inspectRegisteredManagedMcpPolicies("alpha", livePolicy([], { [maliciousKey]: {} }), { - getSandbox: () => null, - }); - } catch (error) { - failure = error; - } - - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain( - String.raw`"mcp_bridge_\u001b[31mforged\u000aline\u0085"`, - ); - expect((failure as Error).message).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/u); - }); - - it("escapes malformed registry bridge keys in strict ownership diagnostics", () => { - const maliciousServer = "alpha\u001b[31mforged\nline\u0085"; - const sandbox = sandboxWithPolicies([]); - sandbox.mcp!.bridges = { - [maliciousServer]: bridge("different-server"), - }; - - let failure: unknown; - try { - inspectExactManagedMcpPolicies(sandbox, livePolicy([])); - } catch (error) { - failure = error; - } - - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toContain( - String.raw`"alpha\u001b[31mforged\u000aline\u0085"`, - ); - expect((failure as Error).message).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/u); - }); - - it("retains additions while restoring the restrictive snapshot", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const beta = registeredPolicy("beta", "1.1.1.1"); - const current = inspectExactManagedMcpPolicies( - sandboxWithPolicies([alpha, beta]), - livePolicy([ - { content: alpha.content, server: "alpha" }, - { content: beta.content, server: "beta" }, - ]), - ); - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - mcp_bridge_alpha: networkEntry(alpha.content, "alpha"), - }, - }); - - const restored = YAML.parse(composeManagedMcpPolicies(snapshot, current, ["mcp_bridge_alpha"])); - - expect(Object.keys(restored.network_policies).sort()).toEqual([ - "mcp_bridge_alpha", - "mcp_bridge_beta", - "restrictive_baseline", - ]); - }); - - function reconcilePolicies(): void { - const policies = Array.from({ length: 257 }, (_, index) => - registeredPolicy(`server${index}`, "8.8.8.8"), - ); - const current = inspectExactManagedMcpPolicies( - sandboxWithPolicies(policies), - livePolicy( - policies.map((policy, index) => ({ - content: policy.content, - server: `server${index}`, - })), - ), - ); - const snapshot = YAML.stringify({ - version: 1, - network_policies: { restrictive_baseline: {} }, - }); - - const restored = YAML.parse(composeManagedMcpPolicies(snapshot, current)); - - expect(Object.keys(restored.network_policies).sort()).toEqual( - ["restrictive_baseline", ...current.map(({ key }) => key)].sort(), - ); - expect(restored.network_policies.mcp_bridge_server256).toEqual( - current.find(({ key }) => key === "mcp_bridge_server256")?.networkPolicy, - ); - } - - const stressTest = testTimeoutOptions(15_000); - it("reconciles 257 managed policies without loss (#7952)", stressTest, reconcilePolicies); - - it("does not restore a managed MCP policy removed during the shields-down window", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const snapshot = YAML.stringify({ +describe("live MCP Shields policy composition", () => { + it("preserves externally edited MCP entries directly from OpenShell", () => { + const target = YAML.stringify({ version: 1, - network_policies: { - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - mcp_bridge_alpha: networkEntry(alpha.content, "alpha"), - }, - }); - - const restored = YAML.parse(composeManagedMcpPolicies(snapshot, [], ["mcp_bridge_alpha"])); - - expect(restored.network_policies).toEqual({ - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + network_policies: { permissive_baseline: {} }, }); - }); - - it("replaces a stale snapshot entry with the current exact registration", () => { - const oldAlpha = registeredPolicy("alpha", "8.8.8.8"); - const currentAlpha = registeredPolicy("alpha", "1.1.1.1"); - const current = inspectExactManagedMcpPolicies( - sandboxWithPolicies([currentAlpha]), - livePolicy([{ content: currentAlpha.content, server: "alpha" }]), - ); - const snapshot = YAML.stringify({ + const edited = { endpoints: [{ host: "operator-edited.example.com" }] }; + const live = YAML.stringify({ version: 1, network_policies: { - mcp_bridge_alpha: networkEntry(oldAlpha.content, "alpha"), + mcp_bridge_alpha: edited, + unrelated_live_entry: { endpoints: [{ host: "unrelated.example.com" }] }, }, }); - const restored = YAML.parse(composeManagedMcpPolicies(snapshot, current, ["mcp_bridge_alpha"])); - - expect(restored.network_policies.mcp_bridge_alpha).toEqual( - networkEntry(currentAlpha.content, "alpha"), - ); - }); + const composed = YAML.parse(composeLiveMcpPolicies(target, live)); - it("rejects an unclassified reserved key in the restrictive snapshot", () => { - const currentAlpha = registeredPolicy("alpha", "1.1.1.1"); - const current = inspectExactManagedMcpPolicies( - sandboxWithPolicies([currentAlpha]), - livePolicy([{ content: currentAlpha.content, server: "alpha" }]), - ); - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - mcp_bridge_alpha: { - name: "operator-owned-alpha", - endpoints: [{ host: "operator.example.com" }], - }, - }, + expect(composed.network_policies).toEqual({ + permissive_baseline: {}, + mcp_bridge_alpha: edited, }); - - expect(() => composeManagedMcpPolicies(snapshot, current, [])).toThrow( - /Reserved MCP policy key "mcp_bridge_alpha".*absent from the saved ownership manifest/, - ); }); - it("renders unclassified reserved policy keys without terminal control characters", () => { - const maliciousKey = "mcp_bridge_\u001b[31mforged\nline\u0085break"; - const snapshot = YAML.stringify({ + it("lets the live OpenShell value replace a stale custom-policy value", () => { + const target = YAML.stringify({ version: 1, - network_policies: { - [maliciousKey]: {}, - }, + network_policies: { mcp_bridge_alpha: { endpoints: [{ host: "stale.example.com" }] } }, }); - - expect(() => composeManagedMcpPolicies(snapshot, [], [])).toThrow( - 'Reserved MCP policy key "mcp_bridge_\\u001b[31mforged\\u000aline\\u0085break" is absent from the saved ownership manifest', - ); - }); - - it("accepts an empty ownership manifest when the snapshot has no reserved keys", () => { - const snapshot = YAML.stringify({ + const live = YAML.stringify({ version: 1, - network_policies: { restrictive_baseline: {} }, + network_policies: { mcp_bridge_alpha: { endpoints: [{ host: "live.example.com" }] } }, }); - expect(YAML.parse(composeManagedMcpPolicies(snapshot, [], [])).network_policies).toEqual({ - restrictive_baseline: {}, - }); - }); - - it("rejects a saved managed key that is absent from its policy snapshot", () => { - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - }, - }); - - expect(() => composeManagedMcpPolicies(snapshot, [], ["mcp_bridge_alpha"])).toThrow( - /absent from its policy snapshot/, - ); - }); - - it.each([ - { - label: "current managed MCP ownership", - hasCurrentManagedClaims: true, - networkPolicies: { restrictive_baseline: {} }, - }, - { - label: "a managed-shaped key in the snapshot", - hasCurrentManagedClaims: false, - networkPolicies: { mcp_bridge_alpha: {} }, - }, - ])("refuses legacy restore with $label", ({ hasCurrentManagedClaims, networkPolicies }) => { - expect(() => - assertLegacyMcpPolicyRestoreSafe( - YAML.stringify({ version: 1, network_policies: networkPolicies }), - hasCurrentManagedClaims, - ), - ).toThrow(/no managed MCP ownership manifest/); + expect( + YAML.parse(composeLiveMcpPolicies(target, live)).network_policies.mcp_bridge_alpha, + ).toEqual({ endpoints: [{ host: "live.example.com" }] }); }); - it("allows a legacy restore with no current or snapshot MCP ownership", () => { - expect(() => - assertLegacyMcpPolicyRestoreSafe( - YAML.stringify({ - version: 1, - network_policies: { restrictive_baseline: {} }, - }), - false, + it("preserves every live key in the MCP namespace without an ownership manifest", () => { + const result = YAML.parse( + composeLiveMcpPolicies( + "version: 1\nnetwork_policies: {}\n", + "version: 1\nnetwork_policies:\n mcp_bridge_: {}\n", ), - ).not.toThrow(); - }); - - it("proves committed bridges independently while omitting an incomplete add at the deadline", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const beta = registeredPolicy("beta", "1.1.1.1"); - const sandbox = sandboxWithPolicies([alpha, beta]); - sandbox.mcp!.bridges.beta!.addState = "prepared"; - - const result = inspectProvableManagedMcpPoliciesForDeadline( - "alpha", - livePolicy([ - { content: alpha.content, server: "alpha" }, - { content: beta.content, server: "beta" }, - ]), - { getSandbox: () => sandbox }, - ); - - expect(result.policies.map((policy) => policy.server)).toEqual(["alpha"]); - expect(result.omissions).toEqual([ - expect.objectContaining({ server: "beta", reason: expect.stringMatching(/incomplete/) }), - ]); - }); - - it("omits every deadline claimant whose canonical policy identity collides", () => { - const collidingPolicy = registeredPolicy("foo-bar", "8.8.8.8"); - const sandbox = sandboxWithPolicies([collidingPolicy], ["foo-bar", "foo_bar"]); - - const result = inspectProvableManagedMcpPoliciesForDeadline( - "alpha", - livePolicy([{ content: collidingPolicy.content, server: "foo-bar" }]), - { getSandbox: () => sandbox }, - ); - - expect(result.policies).toEqual([]); - expect(result.omissions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - server: "foo-bar", - reason: expect.stringMatching(/ambiguous bridge ownership/), - }), - expect.objectContaining({ - server: "foo_bar", - reason: expect.stringMatching(/ambiguous bridge ownership/), - }), - ]), ); + expect(result.network_policies).toEqual({ mcp_bridge_: {} }); }); - it.each(["destroyPreparedAt", "destroyPendingAt"] as const)( - "omits every generated policy while %s is present", - (marker) => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const sandbox = sandboxWithPolicies([alpha]); - sandbox.mcp![marker] = "2026-07-30T01:00:00.000Z"; - - const result = inspectProvableManagedMcpPoliciesForDeadline( - "alpha", - livePolicy([{ content: alpha.content, server: "alpha" }]), - { getSandbox: () => sandbox }, - ); - - expect(result.policies).toEqual([]); - expect(result.omissions).toEqual([ - expect.objectContaining({ server: "alpha", reason: expect.stringMatching(/destruction/) }), - ]); - }, - ); - - it("omits drift and orphan claims without discarding another exact bridge", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const beta = registeredPolicy("beta", "1.1.1.1"); - const driftedBeta = registeredPolicy("beta", "9.9.9.9"); - const orphan = registeredPolicy("orphan", "4.4.4.4"); - const sandbox = sandboxWithPolicies([alpha, beta, orphan], ["alpha", "beta"]); - - const result = inspectProvableManagedMcpPoliciesForDeadline( - "alpha", - livePolicy([ - { content: alpha.content, server: "alpha" }, - { content: driftedBeta.content, server: "beta" }, - { content: orphan.content, server: "orphan" }, - ]), - { getSandbox: () => sandbox }, - ); - - expect(result.policies.map((policy) => policy.server)).toEqual(["alpha"]); - expect(result.omissions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ server: "beta", reason: expect.stringMatching(/drifted/) }), - expect.objectContaining({ - policyName: "mcp-bridge-orphan", - reason: expect.stringMatching(/no committed managed bridge ownership/), - }), - ]), - ); - }); - - it("deadline inspection reports an unclassified reserved live key", () => { - const result = inspectProvableManagedMcpPoliciesForDeadline( - "alpha", - livePolicy([], { mcp_bridge_residual: {} }), - { getSandbox: () => null }, - ); - - expect(result).toEqual({ - policies: [], - omissions: [ - expect.objectContaining({ - key: "mcp_bridge_residual", - reason: expect.stringMatching(/no committed managed bridge ownership/), - }), - ], - }); - }); - - it("deadline composition strips unclassified reserved keys before overlaying proven entries", () => { - const alpha = registeredPolicy("alpha", "8.8.8.8"); - const beta = registeredPolicy("beta", "1.1.1.1"); - const current = inspectExactManagedMcpPolicies( - sandboxWithPolicies([alpha, beta]), - livePolicy([ - { content: alpha.content, server: "alpha" }, - { content: beta.content, server: "beta" }, - ]), - ); - const operatorEntry = { endpoints: [{ host: "operator.example.com" }] }; - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - mcp_bridge_alpha: networkEntry(alpha.content, "alpha"), - mcp_bridge_beta: operatorEntry, - restrictive_baseline: {}, - }, - }); - - const result = composeDeadlineManagedMcpPolicies(snapshot, current, ["mcp_bridge_alpha"]); - const restored = YAML.parse(result.yaml); - - expect(restored.network_policies.mcp_bridge_alpha).toEqual( - networkEntry(alpha.content, "alpha"), - ); - expect(restored.network_policies.mcp_bridge_beta).toEqual(networkEntry(beta.content, "beta")); - expect(result.omissions).toEqual([ - expect.objectContaining({ - key: "mcp_bridge_beta", - reason: expect.stringMatching(/absent from the saved ownership manifest/), - }), - ]); - }); - - it("deadline composition strips every reserved shape with an empty manifest", () => { - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - mcp_bridge_: {}, - mcp_bridge_legacy_invalid_name: {}, - restrictive_baseline: {}, - }, - }); - - const result = composeDeadlineManagedMcpPolicies(snapshot, [], []); - - expect(YAML.parse(result.yaml).network_policies).toEqual({ restrictive_baseline: {} }); - expect(result.omissions.map((entry) => entry.key)).toEqual([ - "mcp_bridge_", - "mcp_bridge_legacy_invalid_name", - ]); - }); - - it("deadline composition omits malformed and duplicate manifest entries without delaying lockdown", () => { - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - mcp_bridge_: {}, - mcp_bridge_alpha: {}, - restrictive_baseline: {}, - }, - }); - - const result = composeDeadlineManagedMcpPolicies( - snapshot, - [], - ["mcp_bridge_", "restrictive_baseline", "mcp_bridge_alpha", "mcp_bridge_alpha"], - ); - - expect(YAML.parse(result.yaml).network_policies).toEqual({ restrictive_baseline: {} }); - expect(result.omissions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "mcp_bridge_", - reason: expect.stringMatching(/ownership key.*invalid/), - }), - expect.objectContaining({ - key: "restrictive_baseline", - reason: expect.stringMatching(/ownership key.*invalid/), - }), - expect.objectContaining({ - key: "mcp_bridge_alpha", - reason: expect.stringMatching(/more than once/), - }), - ]), - ); - }); - - it("deadline composition restores the restrictive baseline when a saved key is absent", () => { - const snapshot = YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - }, - }); - - const result = composeDeadlineManagedMcpPolicies(snapshot, [], ["mcp_bridge_alpha"]); - const restored = YAML.parse(result.yaml); - - expect(restored.network_policies).toEqual({ - restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, - }); - expect(result.omissions).toEqual([ - expect.objectContaining({ reason: expect.stringMatching(/already absent/) }), - ]); + it("rejects a malformed live document instead of inventing MCP state", () => { + expect(() => + composeLiveMcpPolicies("version: 1\nnetwork_policies: {}\n", "network_policies: ["), + ).toThrow(/Live OpenShell policy is not valid YAML/); }); }); diff --git a/src/lib/shields/mcp-policy-transition.ts b/src/lib/shields/mcp-policy-transition.ts index 0d1b402fd68..36444fbf777 100644 --- a/src/lib/shields/mcp-policy-transition.ts +++ b/src/lib/shields/mcp-policy-transition.ts @@ -3,14 +3,7 @@ import YAML from "yaml"; -import type { - ExactManagedMcpPolicy, - ManagedMcpPolicyOmission, -} from "../actions/sandbox/mcp-bridge-policy"; -import { diagnosticPreview } from "../name-validation"; - -const CANONICAL_MANAGED_MCP_POLICY_KEY_RE = /^mcp_bridge_[a-z][a-z0-9_]{0,63}$/; -const RESERVED_MANAGED_MCP_POLICY_KEY_RE = /^mcp_bridge_/; +const MCP_POLICY_KEY_PREFIX = "mcp_bridge_"; function parsePolicyDocument(source: string, label: string): Record { let parsed: unknown; @@ -38,146 +31,20 @@ function readNetworkPolicies( } /** - * Reconcile generated MCP entries into a complete target policy. - * - * Snapshot-time keys are removed first so an MCP server deleted during the - * shields-down window cannot be restored. The current exact entries are then overlaid, - * retaining additions and replacing stale pins. Every non-MCP target entry - * remains authoritative; unrelated live entries are never copied. + * Preserve the live MCP policy namespace while composing a temporary Shields + * policy. OpenShell is the authority for both the keys and their content; MCP + * registry state is deliberately not consulted or used as an ownership claim. */ -export function composeManagedMcpPolicies( - targetPolicyYaml: string, - currentPolicies: readonly ExactManagedMcpPolicy[], - snapshotManagedPolicyKeys: readonly string[] = [], -): string { +export function composeLiveMcpPolicies(targetPolicyYaml: string, livePolicyYaml: string): string { const target = parsePolicyDocument(targetPolicyYaml, "Target Shields policy"); const targetPolicies = readNetworkPolicies(target, "Target Shields policy"); + const live = parsePolicyDocument(livePolicyYaml, "Live OpenShell policy"); + const livePolicies = readNetworkPolicies(live, "Live OpenShell policy"); - const snapshotKeys = new Set(); - for (const key of snapshotManagedPolicyKeys) { - if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(key) || snapshotKeys.has(key)) { - throw new Error("Saved Shields MCP policy ownership is invalid"); - } - if (!Object.hasOwn(targetPolicies, key)) { - throw new Error(`Saved Shields MCP policy '${key}' is absent from its policy snapshot`); - } - snapshotKeys.add(key); - delete targetPolicies[key]; - } - const unclassifiedKey = Object.keys(targetPolicies).find((key) => - RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(key), - ); - if (unclassifiedKey) { - throw new Error( - `Reserved MCP policy key ${diagnosticPreview(unclassifiedKey)} is absent from the saved ownership manifest`, - ); - } - - const currentKeys = new Set(); - for (const policy of currentPolicies) { - if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(policy.key) || currentKeys.has(policy.key)) { - throw new Error(`Managed MCP policy key '${policy.key}' has ambiguous ownership`); - } - currentKeys.add(policy.key); - targetPolicies[policy.key] = policy.networkPolicy; + for (const [key, policy] of Object.entries(livePolicies)) { + if (key.startsWith(MCP_POLICY_KEY_PREFIX)) targetPolicies[key] = structuredClone(policy); } target.network_policies = targetPolicies; return YAML.stringify(target); } - -export interface DeadlineManagedMcpPolicyComposition { - yaml: string; - omissions: ManagedMcpPolicyOmission[]; -} - -/** - * Security-authoritative deadline composition. - * - * Every reserved key is removed from the snapshot, including keys missing from - * an incomplete manifest. Only independently proven current entries are then - * overlaid. - */ -export function composeDeadlineManagedMcpPolicies( - targetPolicyYaml: string, - currentPolicies: readonly ExactManagedMcpPolicy[], - snapshotManagedPolicyKeys: readonly string[], -): DeadlineManagedMcpPolicyComposition { - const target = parsePolicyDocument(targetPolicyYaml, "Target Shields policy"); - const targetPolicies = readNetworkPolicies(target, "Target Shields policy"); - - const snapshotKeys = new Set(); - const omissions: ManagedMcpPolicyOmission[] = []; - for (const key of snapshotManagedPolicyKeys) { - if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(key)) { - if (RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(key)) { - delete targetPolicies[key]; - } - omissions.push({ - key, - reason: `Saved Shields MCP policy ownership key '${key}' is invalid`, - }); - continue; - } - if (snapshotKeys.has(key)) { - omissions.push({ - key, - reason: `Saved Shields MCP policy '${key}' appeared more than once in its ownership manifest`, - }); - continue; - } - if (!Object.hasOwn(targetPolicies, key)) { - omissions.push({ - reason: `Saved Shields MCP policy '${key}' was already absent from its policy snapshot`, - }); - } - snapshotKeys.add(key); - delete targetPolicies[key]; - } - for (const key of Object.keys(targetPolicies)) { - if (!RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(key)) continue; - delete targetPolicies[key]; - omissions.push({ - key, - reason: `Reserved MCP policy key '${key}' was absent from the saved ownership manifest`, - }); - } - - const currentKeys = new Set(); - for (const policy of currentPolicies) { - if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(policy.key) || currentKeys.has(policy.key)) { - throw new Error(`Managed MCP policy key '${policy.key}' has ambiguous ownership`); - } - currentKeys.add(policy.key); - targetPolicies[policy.key] = policy.networkPolicy; - } - - target.network_policies = targetPolicies; - return { yaml: YAML.stringify(target), omissions }; -} - -export function isManagedMcpPolicyKey(value: unknown): value is string { - return typeof value === "string" && RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(value); -} - -/** - * Refuse to guess managed ownership for a Shields snapshot captured before the - * ownership manifest existed. Current claims prove reconciliation is needed; - * a managed-shaped snapshot key may be a removed bridge or an operator entry. - * Either case requires explicit recovery instead of a destructive raw apply. - */ -export function assertLegacyMcpPolicyRestoreSafe( - snapshotPolicyYaml: string, - hasCurrentManagedClaims: boolean, -): void { - const snapshot = parsePolicyDocument(snapshotPolicyYaml, "Legacy Shields policy snapshot"); - const snapshotPolicies = readNetworkPolicies(snapshot, "Legacy Shields policy snapshot"); - if ( - hasCurrentManagedClaims || - Object.keys(snapshotPolicies).some((key) => isManagedMcpPolicyKey(key)) - ) { - throw new Error( - "Legacy Shields state has no managed MCP ownership manifest; refusing policy restore", - ); - } -} diff --git a/src/lib/shields/mutable-config-repair.test.ts b/src/lib/shields/mutable-config-repair.test.ts index 36c0f0077dc..723f47b2d4b 100644 --- a/src/lib/shields/mutable-config-repair.test.ts +++ b/src/lib/shields/mutable-config-repair.test.ts @@ -204,7 +204,6 @@ describe("locked Shields policy recovery status", () => { expect(() => harness.shieldsStatus(sandboxName, false, { - inspectPolicyRecovery: () => ({ status: "external", handoff: "policy handoff" }), resolveConfig: () => target, verifyLockState: () => ({ ok: true, issues: [] }), verifyStateLockPlan: () => [], diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index d41ba56aa06..0236fedb820 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -55,6 +56,20 @@ function openClawTarget() { }; } +function boundPolicySnapshot(snapshotPath: string, content: string) { + const metadata = fs.statSync(snapshotPath); + return { + schemaVersion: 1 as const, + path: snapshotPath, + sha256: createHash("sha256").update(content).digest("hex"), + size: Buffer.byteLength(content), + mode: 0o600, + uid: metadata.uid, + gid: metadata.gid, + nlink: 1 as const, + }; +} + const retryAgentCases: ReadonlyArray< readonly [label: string, sandboxName: string, target: AgentConfigTarget] > = [ @@ -1003,29 +1018,64 @@ describe("OpenClaw shields flow rollback and recovery", () => { }); it("reports staged driver-neutral recovery when snapshot restoration fails (#6126)", () => { - const harness = createHarness({ run: () => ({ status: 1 }) }); + const harness = createHarness(); + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "recovery-hint coverage", + policy: "permissive", + throwOnError: true, + }); + harness.runSpy.mockImplementation(() => ({ status: 1 }) as never); + + expect(() => harness.shieldsUp("openclaw", { throwOnError: true })).toThrow( + "policy restore exited with status 1", + ); + + const output = expectStagedDriverNeutralRecovery(harness.errorSpy, "openclaw"); + expect(output).toContain("Config remains unlocked — manual intervention required"); + }); + + it.each([ + ["changed bytes", (snapshotPath: string) => fs.appendFileSync(snapshotPath, "# changed\n")], + [ + "replacement symlink", + (snapshotPath: string) => { + const replacement = `${snapshotPath}.replacement`; + fs.writeFileSync(replacement, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); + fs.unlinkSync(snapshotPath); + fs.symlinkSync(replacement, snapshotPath); + }, + ], + ["changed mode", (snapshotPath: string) => fs.chmodSync(snapshotPath, 0o644)], + ])("rejects a restrictive snapshot with %s before any policy write", (_label, mutate) => { + const harness = createHarness(); const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-failed-restore.yaml"); + const snapshotPath = path.join(stateDir, "policy-snapshot-tamper.yaml"); + const snapshotContent = "version: 1\nnetwork_policies: {}\n"; fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync(snapshotPath, snapshotContent, { mode: 0o600 }); fs.writeFileSync( path.join(stateDir, "shields-openclaw.json"), JSON.stringify({ shieldsDown: true, shieldsDownAt: new Date().toISOString(), shieldsDownTimeout: 300, - shieldsDownReason: "recovery-hint coverage", + shieldsDownReason: "snapshot binding coverage", shieldsDownPolicy: "permissive", shieldsPolicySnapshotPath: snapshotPath, + shieldsPolicySnapshot: boundPolicySnapshot(snapshotPath, snapshotContent), }), ); + mutate(snapshotPath); + harness.runSpy.mockClear(); expect(() => harness.shieldsUp("openclaw", { throwOnError: true })).toThrow( - "policy restore exited with status 1", + /Restrictive policy snapshot/u, ); - - const output = expectStagedDriverNeutralRecovery(harness.errorSpy, "openclaw"); - expect(output).toContain("Config remains unlocked — manual intervention required"); + expect(harness.runSpy).not.toHaveBeenCalled(); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf8")).shieldsDown, + ).toBe(true); }); it("reports staged driver-neutral recovery when the initial config lock fails (#6126)", () => { @@ -1175,46 +1225,23 @@ describe("OpenClaw shields flow rollback and recovery", () => { }); it("retains the bounded auto-restore owner when manual shields-up fails", () => { - const harness = createHarness(); - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-relock-failure.yaml"); - const markerPath = path.join(stateDir, "shields-timer-openclaw.json"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n"); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: new Date().toISOString(), - shieldsDownTimeout: 1800, - shieldsDownReason: "rebuild", - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, - }), - ); - fs.writeFileSync( - markerPath, - JSON.stringify({ - pid: 4242, - sandboxName: "openclaw", - snapshotPath, - restoreAt: new Date(Date.now() + 60_000).toISOString(), - processToken: "timer-token", - allowLegacyHermesProtocol: false, - }), - ); + const harness = createHarness({ failOpenClawGuardActions: ["lock"] }); + harness.shieldsDown("openclaw", { + timeout: "30m", + reason: "rebuild", + policy: "permissive", + throwOnError: true, + }); + const before = readStateAndTimer("openclaw"); const killSpy = vi.spyOn(process, "kill").mockReturnValue(true); expect(() => harness.shieldsUp("openclaw", { throwOnError: true })).toThrow( - /Config not locked/, + /startup-not-ready/, ); - expect(fs.existsSync(markerPath)).toBe(true); + expect(fs.existsSync(before.timerPath)).toBe(true); expect(killSpy).not.toHaveBeenCalled(); - expect( - JSON.parse(fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8")) - .shieldsDown, - ).toBe(true); + expect(JSON.parse(fs.readFileSync(before.statePath, "utf-8")).shieldsDown).toBe(true); }); }); diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 118c7727d3d..d1c436a9f7c 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -7,20 +7,6 @@ import YAML from "yaml"; import { diagnosticPreview } from "../sandbox-name-contract"; -export { - type ExactManagedMcpPolicy, - hasManagedMcpPolicyClaims, - inspectExactManagedMcpPolicies, - inspectProvableManagedMcpPoliciesForDeadline, - inspectRecordedManagedMcpPolicies, - type ManagedMcpPolicyOmission, -} from "../actions/sandbox/mcp-bridge-policy"; - -import type { - ExactManagedMcpPolicy, - ManagedMcpPolicyOmission, -} from "../actions/sandbox/mcp-bridge-policy"; - function canonicalPolicyValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalPolicyValue); if (!value || typeof value !== "object") return value; @@ -52,12 +38,7 @@ export function describeCanonicalPolicyReference(policy: Record import { materializeMessagingPolicySandboxName } from "../messaging/channels/policy"; import { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; -export { assertLegacyMcpPolicyRestoreSafe, isManagedMcpPolicyKey } from "./mcp-policy-transition"; - -import { - composeDeadlineManagedMcpPolicies, - composeManagedMcpPolicies, -} from "./mcp-policy-transition"; +import { composeLiveMcpPolicies } from "./mcp-policy-transition"; const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime"; @@ -118,10 +99,6 @@ export interface PermissiveRuntimeDeps { // secureTempFile when omitted. Exposed so tests can drive the // write-failure fallback path without monkey-patching node:fs. writeTempPolicy?: (yaml: string) => string; - // Exact, live-matching generated MCP policies resolved by the Shields - // coordinator. These entries remain active while the static policy replaces - // the rest of the complete gateway policy. - managedMcpPolicies?: readonly ExactManagedMcpPolicy[]; // Hermes permissive messaging routes carry sandbox-scoped credential // bindings. Supplying the target name makes composition fail closed unless // every retained placeholder can be materialized before the policy is staged. @@ -135,7 +112,15 @@ export function buildRuntimePermissivePolicy( const live = deps.livePolicyYaml ? safeYamlObject(deps.livePolicyYaml) : null; const liveRw = readStringList(live, "read_write"); const liveRo = readStringList(live, "read_only"); - const managedMcpPolicies = deps.managedMcpPolicies ?? []; + const liveNetworkPolicies = + live?.network_policies && + typeof live.network_policies === "object" && + !Array.isArray(live.network_policies) + ? (live.network_policies as Record) + : {}; + const hasLiveMcpPolicies = Object.keys(liveNetworkPolicies).some((key) => + key.startsWith("mcp_bridge_"), + ); const discordProviderName = deps.sandboxName ? `${deps.sandboxName}-discord-bridge` : null; const slackProviderNames = deps.sandboxName ? [`${deps.sandboxName}-slack-app`, `${deps.sandboxName}-slack-bridge`] @@ -148,13 +133,13 @@ export function buildRuntimePermissivePolicy( const preserveCredentialBinding = preserveDiscordBinding || preserveSlackBinding; // No live startup-sealed or filesystem state to carry forward — keep the - // static path so the caller's apply path is unchanged unless exact managed - // MCP entries must survive the complete-policy replacement. + // static path so the caller's apply path is unchanged unless live MCP + // entries must survive the complete-policy replacement. if ( liveRw.length === 0 && liveRo.length === 0 && live?.landlock === undefined && - managedMcpPolicies.length === 0 && + !hasLiveMcpPolicies && deps.sandboxName === undefined ) { return basePermissivePath; @@ -164,8 +149,8 @@ export function buildRuntimePermissivePolicy( try { baseYaml = deps.readBasePolicy(); } catch (error) { - if (managedMcpPolicies.length > 0) { - throw new Error("Cannot read the Shields-down policy while managed MCP policies are active", { + if (hasLiveMcpPolicies) { + throw new Error("Cannot read the Shields-down policy while live MCP policies are active", { cause: error, }); } @@ -178,8 +163,8 @@ export function buildRuntimePermissivePolicy( } let base = safeYamlObject(baseYaml); if (!base) { - if (managedMcpPolicies.length > 0) { - throw new Error("Cannot parse the Shields-down policy while managed MCP policies are active"); + if (hasLiveMcpPolicies) { + throw new Error("Cannot parse the Shields-down policy while live MCP policies are active"); } if (deps.sandboxName !== undefined) { throw new Error("Cannot parse the Shields-down policy with credential provider bindings"); @@ -240,16 +225,17 @@ export function buildRuntimePermissivePolicy( base.landlock = live.landlock; } - const yaml = composeManagedMcpPolicies(YAML.stringify(base), managedMcpPolicies); + const yaml = live + ? composeLiveMcpPolicies(YAML.stringify(base), deps.livePolicyYaml) + : YAML.stringify(base); if (deps.writeTempPolicy) { try { return deps.writeTempPolicy(yaml); } catch (error) { - if (managedMcpPolicies.length > 0) { - throw new Error( - "Cannot stage the Shields-down policy while managed MCP policies are active", - { cause: error }, - ); + if (hasLiveMcpPolicies) { + throw new Error("Cannot stage the Shields-down policy while live MCP policies are active", { + cause: error, + }); } if (deps.sandboxName !== undefined) { throw new Error("Cannot stage the Shields-down credential provider binding", { @@ -269,11 +255,10 @@ export function buildRuntimePermissivePolicy( // writeFileSync failed. Clean it up so we do not leak a 0700 dir // on /tmp every time the write path errors. if (tmpPath) cleanupTempDir(tmpPath, TEMP_FILE_PREFIX); - if (managedMcpPolicies.length > 0) { - throw new Error( - "Cannot stage the Shields-down policy while managed MCP policies are active", - { cause: error }, - ); + if (hasLiveMcpPolicies) { + throw new Error("Cannot stage the Shields-down policy while live MCP policies are active", { + cause: error, + }); } if (deps.sandboxName !== undefined) { throw new Error("Cannot stage the Shields-down credential provider binding", { @@ -284,44 +269,34 @@ export function buildRuntimePermissivePolicy( } } -export interface ManagedMcpRuntimePolicyDeps { - managedMcpPolicies: readonly ExactManagedMcpPolicy[]; +export interface LiveMcpRuntimePolicyDeps { + livePolicyYaml: string; readBasePolicy: () => string; - snapshotManagedPolicyKeys?: readonly string[]; writeTempPolicy?: (yaml: string) => string; } /** - * Reconcile current generated MCP policies into a custom Shields-down policy - * or a saved restrictive snapshot. Unlike the legacy filesystem-only fallback, - * this path must fail closed: returning the unmodified base could silently - * discard a managed entry or restore one that was removed during the - * shields-down window. + * Preserve current live MCP policy entries in a custom Shields-down policy. + * OpenShell's live document is the only source used for their keys and content. */ -export function buildRuntimeManagedMcpPolicy( +export function buildRuntimePolicyWithLiveMcpEntries( _basePolicyPath: string, - deps: ManagedMcpRuntimePolicyDeps, + deps: LiveMcpRuntimePolicyDeps, ): string { - const snapshotManagedPolicyKeys = deps.snapshotManagedPolicyKeys ?? []; - let baseYaml: string; try { baseYaml = deps.readBasePolicy(); } catch (error) { - throw new Error("Cannot read the Shields policy for managed MCP reconciliation", { + throw new Error("Cannot read the Shields policy while preserving live MCP entries", { cause: error, }); } - const yaml = composeManagedMcpPolicies( - baseYaml, - deps.managedMcpPolicies, - snapshotManagedPolicyKeys, - ); + const yaml = composeLiveMcpPolicies(baseYaml, deps.livePolicyYaml); if (deps.writeTempPolicy) { try { return deps.writeTempPolicy(yaml); } catch (error) { - throw new Error("Cannot stage the Shields policy for managed MCP reconciliation", { + throw new Error("Cannot stage the Shields policy with live MCP entries", { cause: error, }); } @@ -334,52 +309,7 @@ export function buildRuntimeManagedMcpPolicy( return tmpPath; } catch (error) { if (tmpPath) cleanupTempDir(tmpPath, TEMP_FILE_PREFIX); - throw new Error("Cannot stage the Shields policy for managed MCP reconciliation", { - cause: error, - }); - } -} - -export interface DeadlineManagedMcpRuntimePolicy { - path: string; - omissions: ManagedMcpPolicyOmission[]; -} - -export function buildDeadlineRuntimeManagedMcpPolicy( - basePolicyPath: string, - deps: ManagedMcpRuntimePolicyDeps, -): DeadlineManagedMcpRuntimePolicy { - const baseYaml = deps.readBasePolicy(); - const snapshotManagedPolicyKeys = deps.snapshotManagedPolicyKeys ?? []; - const composition = composeDeadlineManagedMcpPolicies( - baseYaml, - deps.managedMcpPolicies, - snapshotManagedPolicyKeys, - ); - // With no saved or current managed MCP entries, composition records every - // reserved snapshot key as an omission. No omissions means the snapshot is - // valid without modification, so restoration does not need temporary storage. - if ( - deps.managedMcpPolicies.length === 0 && - snapshotManagedPolicyKeys.length === 0 && - composition.omissions.length === 0 - ) { - return { path: basePolicyPath, omissions: composition.omissions }; - } - let runtimePath: string | null = null; - try { - runtimePath = deps.writeTempPolicy - ? deps.writeTempPolicy(composition.yaml) - : secureTempFile(TEMP_FILE_PREFIX, ".yaml"); - if (!deps.writeTempPolicy) { - fs.writeFileSync(runtimePath, composition.yaml, { mode: 0o600 }); - } - return { path: runtimePath, omissions: composition.omissions }; - } catch (error) { - if (runtimePath && !deps.writeTempPolicy) { - cleanupTempDir(runtimePath, TEMP_FILE_PREFIX); - } - throw new Error("Cannot stage the deadline Shields policy for managed MCP reconciliation", { + throw new Error("Cannot stage the Shields policy with live MCP entries", { cause: error, }); } diff --git a/src/lib/shields/policy-delta.test.ts b/src/lib/shields/policy-delta.test.ts new file mode 100644 index 00000000000..e7142387107 --- /dev/null +++ b/src/lib/shields/policy-delta.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import YAML from "yaml"; +import { describe, expect, it } from "vitest"; + +import { shieldsPolicyDeltaInternals } from "./index"; + +describe("Shields live policy delta restore", () => { + it("reverts only unchanged Shields values and preserves host-side edits", () => { + const before = YAML.stringify({ + version: 1, + network_policies: { + changed_by_shields: { mode: "restricted" }, + removed_by_shields: { mode: "allowed" }, + }, + }); + const forward = YAML.stringify({ + version: 1, + network_policies: { + changed_by_shields: { mode: "permissive" }, + added_by_shields: { mode: "temporary" }, + }, + }); + const live = YAML.stringify({ + version: 1, + network_policies: { + changed_by_shields: { mode: "host-edited" }, + added_by_shields: { mode: "temporary" }, + unrelated_host_entry: { mode: "allowed" }, + }, + }); + + const restored = YAML.parse( + shieldsPolicyDeltaInternals.restoreShieldsDelta(before, forward, live), + ); + expect(restored.network_policies).toEqual({ + changed_by_shields: { mode: "host-edited" }, + removed_by_shields: { mode: "allowed" }, + unrelated_host_entry: { mode: "allowed" }, + }); + }); +}); diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 3474819482a..762cd6089f7 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -7,10 +7,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; -import YAML from "yaml"; import { createShieldsFlowHarness, - externalPolicyAuthorityInspection, type ShieldsFlowHarnessOptions, } from "../../../test/helpers/shields-flow-harness"; @@ -32,37 +30,34 @@ function sandboxCommandFailure( } const TRANSITION_LOCK_MODULE = "./transition-lock.js"; -function mockManagedPolicyAuthority(sandboxName: string): void { +function mockLivePolicy(sandboxName: string): void { const registry = requireSource("../state/registry.js") as typeof import("../state/registry.js"); - const policyAuthority = requireSource( - "../adapters/openshell/policy-authority.js", - ) as typeof import("../adapters/openshell/policy-authority.js"); + const policyState = requireSource( + "../adapters/openshell/policy-state.js", + ) as typeof import("../adapters/openshell/policy-state.js"); const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: sandboxName, openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", }); vi.spyOn(registry, "updateSandbox").mockReturnValue(true); - vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue({ - authority: "nemoclaw-managed", + vi.spyOn(policyState, "inspectSandboxPolicy").mockReturnValue({ + policySource: "sandbox", effectivePolicy: { version: 1, network_policies: {} }, policyIdentity: { hash: "sha256:managed", activeVersion: 1 }, }); const receipt = { - authority: "nemoclaw-managed" as const, - authorityRecordedNow: false, gatewayName: "nemoclaw", inspection: { - authority: "nemoclaw-managed" as const, + policySource: "sandbox" as const, effectivePolicy: { version: 1, network_policies: {} }, policyIdentity: { hash: "sha256:managed", activeVersion: 1 }, }, }; - vi.spyOn(policy, "inspectPolicyMutationAuthority").mockReturnValue(receipt); - vi.spyOn(policy, "inspectPolicyRecoveryAuthority").mockReturnValue(receipt); - vi.spyOn(policy, "recheckPolicyMutationAuthority").mockReturnValue(receipt); - vi.spyOn(policy, "finalizePolicyMutationReceipt").mockImplementation(() => undefined); + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(receipt); + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(receipt); + vi.spyOn(policy, "recheckPolicyMutationContext").mockReturnValue(receipt); + vi.spyOn(policy, "verifyAppliedPolicyDocument").mockImplementation(() => undefined); } describe("shields policy transition", () => { @@ -71,18 +66,6 @@ describe("shields policy transition", () => { let runCaptureSpy: MockInstance; let shields: typeof import("./index.js"); - function writePolicySnapshot(sandboxName: string, fileName: string): string { - const stateDir = path.join(homeDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, fileName); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); - fs.writeFileSync( - path.join(stateDir, `shields-${sandboxName}.json`), - JSON.stringify({ shieldsDown: true, shieldsPolicySnapshotPath: snapshotPath }), - ); - return snapshotPath; - } - beforeEach(() => { homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-policy-transition-")); vi.stubEnv("HOME", homeDir); @@ -118,7 +101,7 @@ describe("shields policy transition", () => { (_sandboxName: unknown, cmd: unknown) => cmd as string[], ); vi.spyOn(dockerExec, "dockerExecFileSync").mockReturnValue(""); - mockManagedPolicyAuthority("openclaw"); + mockLivePolicy("openclaw"); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); @@ -132,63 +115,6 @@ describe("shields policy transition", () => { fs.rmSync(homeDir, { recursive: true, force: true }); }); - it("rechecks policy authority immediately before direct snapshot restore (#9833)", () => { - const sandboxName = "openclaw"; - const snapshotPath = writePolicySnapshot(sandboxName, "policy-snapshot-authority-race.yaml"); - const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); - vi.mocked(policy.recheckPolicyMutationAuthority).mockImplementation(() => { - throw new Error("OpenShell policy authority changed during snapshot restore"); - }); - - expect(() => shields.applyShieldsPolicySnapshot(sandboxName, snapshotPath)).toThrow( - /policy authority changed/, - ); - expect(runSpy).not.toHaveBeenCalled(); - }); - - it("refuses external authority before Shields snapshot recovery (#9833)", () => { - const sandboxName = "openclaw"; - const snapshotPath = writePolicySnapshot(sandboxName, "policy-snapshot-external.yaml"); - const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); - vi.mocked(policy.inspectPolicyMutationAuthority).mockReturnValue({ - authority: "externally-managed", - authorityRecordedNow: false, - gatewayName: "nemoclaw", - inspection: { - authority: "externally-managed", - effectivePolicy: { version: 1, network_policies: {} }, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }, - }); - - expect(() => shields.applyShieldsPolicySnapshot(sandboxName, snapshotPath)).toThrow( - /does not match.*canonical JSON SHA-256 [a-f0-9]{64}; network policy keys: "test"/su, - ); - expect(runSpy).not.toHaveBeenCalled(); - - const matchingExternalAuthority = { - authority: "externally-managed" as const, - authorityRecordedNow: false, - gatewayName: "nemoclaw", - inspection: { - authority: "externally-managed" as const, - effectivePolicy: { version: 1, network_policies: { test: {} } }, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }, - }; - vi.mocked(policy.inspectPolicyMutationAuthority).mockReturnValue(matchingExternalAuthority); - vi.mocked(policy.inspectPolicyRecoveryAuthority) - .mockReturnValueOnce(matchingExternalAuthority) - .mockReturnValue({ - ...matchingExternalAuthority, - authority: "nemoclaw-managed", - inspection: { ...matchingExternalAuthority.inspection, authority: "nemoclaw-managed" }, - }); - expect(() => shields.applyShieldsPolicySnapshot(sandboxName, snapshotPath)).toThrow( - /Policy authority changed.*canonical JSON SHA-256 [a-f0-9]{64}.*Stop without applying.*Restore the recorded externally managed authority.*NemoClaw will not change policy authority/su, - ); - }); - it("never relaxes policy or persists mutable state when the base-policy read fails", () => { expect(() => shields.shieldsDown("openclaw", { throwOnError: true })).toThrow( "Cannot capture current policy", @@ -242,23 +168,6 @@ describe("shields down policy rejection", () => { }); } - it("stops Shields down before mutation when policy is externally managed (#9833)", () => { - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - policyAuthorityInspection: externalPolicyAuthorityInspection, - sandboxEntry: { - name: "openclaw", - openshellDriver: "docker", - policyAuthority: "externally-managed", - }, - }); - - expect(() => harness.shieldsDown("openclaw", { throwOnError: true })).toThrow( - "externally managed", - ); - expect(harness.runSpy).not.toHaveBeenCalled(); - expect(harness.dockerSpawnCalls).toEqual([]); - }); - it("pins Shields policy inspection, reads, and writes to the recorded gateway (#9833)", () => { const gatewayName = "nemoclaw-18080"; const harness = createShieldsFlowHarness(requireSource, tmpDir, { @@ -268,19 +177,18 @@ describe("shields down policy rejection", () => { gatewayName, gatewayPort: 18080, openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", }, }); harness.shieldsDown("openclaw", { throwOnError: true }); - expect(harness.policyAuthoritySpy).toHaveBeenCalledWith("openclaw", "lower Shields"); + expect(harness.policyStateSpy).toHaveBeenCalledWith("openclaw", "lower Shields"); const policyCommands = [...harness.runCaptureSpy.mock.calls, ...harness.runSpy.mock.calls] .map(([command]) => command) .filter((command) => Array.isArray(command) && command.includes("policy")); expect(policyCommands.length).toBeGreaterThan(0); expect(policyCommands.every((command) => command.includes(gatewayName))).toBe(true); - expect(harness.policyReceiptFinalizeSpy).toHaveBeenCalledWith( + expect(harness.policyVerificationSpy).toHaveBeenCalledWith( "openclaw", expect.stringContaining("network_policies"), expect.objectContaining({ gatewayName }), @@ -454,7 +362,6 @@ describe("shields config lock without a shipped config hash", () => { const CONFIG_PATH = `${CONFIG_DIR}/config.toml`; const HASH_PATH = `${CONFIG_DIR}/.config-hash`; const LOCK_COMMAND_KEY = [CONFIG_DIR, CONFIG_PATH].join("\0"); - const TIMER_PROCESS_KEY = ["number", "4242", "number", "0"].join("\0"); type SandboxEntry = { mode: string; owner: string }; type SandboxCommandHandler = (args: string[], command: string[]) => string; @@ -558,26 +465,6 @@ describe("shields config lock without a shipped config hash", () => { }; } - function reportTimerProcessMissing(): never { - const error = new Error("timer is gone") as NodeJS.ErrnoException; - error.code = "ESRCH"; - throw error; - } - - function reportTimerProcessRunning(): true { - return true; - } - - const timerProcessHandlers = new Map true>([ - [TIMER_PROCESS_KEY, reportTimerProcessMissing], - ]); - - function reportMissingTimerProcess(pid: number, signal?: string | number): true { - const key = [typeof pid, String(pid), typeof signal, String(signal)].join("\0"); - const behavior = timerProcessHandlers.get(key) ?? reportTimerProcessRunning; - return behavior(); - } - function makePathImmutable(pathname: string): void { immutablePaths.add(pathname); } @@ -738,7 +625,7 @@ describe("shields config lock without a shipped config hash", () => { vi.spyOn(stateDirLock, "preflightStateDirLock").mockReturnValue([]); applyStateDirLockModeSpy = vi.spyOn(stateDirLock, "applyStateDirLockMode").mockReturnValue([]); restoreStateDirLockPostureSpy = vi.spyOn(stateDirLock, "restoreStateDirLockPosture"); - mockManagedPolicyAuthority("dcode-safety"); + mockLivePolicy("dcode-safety"); errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); @@ -896,75 +783,6 @@ describe("shields config lock without a shipped config hash", () => { expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("CRITICAL")); }); - it.each([ - [ - "is unavailable", - () => { - throw new Error("registry unavailable"); - }, - ], - [ - "falls back to a changed OpenClaw target", - () => ({ - agentName: "openclaw", - configDir: "/sandbox/.openclaw", - configFile: "openclaw.json", - configPath: "/sandbox/.openclaw/openclaw.json", - format: "json", - }), - ], - ])( - "pins expired inline recovery to Deep Agents when the registry %s (#7995)", - (_scenario, resolveTarget) => { - const sandboxName = "dcode-safety"; - const stateDir = path.join(homeDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-inline-recovery.yaml"); - const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); - fs.writeFileSync( - path.join(stateDir, `shields-${sandboxName}.json`), - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: new Date(Date.now() - 120_000).toISOString(), - shieldsDownTimeout: 60, - shieldsDownReason: "identity coverage", - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - markerPath, - JSON.stringify({ - pid: 4242, - sandboxName, - snapshotPath, - restoreAt: new Date(Date.now() - 30_000).toISOString(), - processToken: "7".repeat(32), - agentName: "langchain-deepagents-code", - configPath: CONFIG_PATH, - configDir: CONFIG_DIR, - }), - { mode: 0o600 }, - ); - vi.spyOn(process, "kill").mockImplementation(reportMissingTimerProcess); - resolveAgentConfigSpy.mockImplementation(resolveTarget); - - const posture = shields.getShieldsPosture(sandboxName, true); - const state = JSON.parse( - fs.readFileSync(path.join(stateDir, `shields-${sandboxName}.json`), "utf-8"), - ); - - expect(posture.mode).toBe("locked"); - expect(lockCalls).toHaveLength(2); - expect(lockCalls.every((command) => command[4] === CONFIG_DIR)).toBe(true); - expect(lockCalls.every((command) => command[5] === CONFIG_PATH)).toBe(true); - expect(Object.keys(state.fileHashes)).toEqual([CONFIG_PATH, HASH_PATH]); - expect(fs.existsSync(markerPath)).toBe(false); - }, - ); - it("restores the managed sandbox parent when the config is unlocked", () => { entries.set(CONFIG_DIR, { mode: "755", owner: "root:root" }); entries.set(CONFIG_PATH, { mode: "444", owner: "root:root" }); @@ -986,235 +804,6 @@ describe("shields config lock without a shipped config hash", () => { }); }); -describe("managed MCP policy deadline restoration (#7952)", () => { - let homeDir: string; - - function createRestoreHarness() { - delete require.cache[requireSource.resolve(SHIELDS_MODULE)]; - delete require.cache[requireSource.resolve("./permissive-runtime.js")]; - delete require.cache[requireSource.resolve("../actions/sandbox/mcp-bridge-policy.js")]; - - const runner = requireSource("../runner.js") as typeof import("../runner.js"); - const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); - const registry = requireSource("../state/registry.js") as typeof import("../state/registry.js"); - const policyAuthority = requireSource( - "../adapters/openshell/policy-authority.js", - ) as typeof import("../adapters/openshell/policy-authority.js"); - const policySetBodies: string[] = []; - - vi.spyOn(runner, "runCapture").mockReturnValue( - "version: 1\nnetwork_policies:\n live_baseline: {}\n", - ); - vi.spyOn(runner, "run").mockReturnValue({ status: 0 } as never); - vi.spyOn(policy, "buildPolicyGetCommand").mockReturnValue(["openshell", "policy", "get"]); - vi.spyOn(policy, "buildPolicySetCommand").mockImplementation((file: unknown) => { - policySetBodies.push(fs.readFileSync(String(file), "utf-8")); - return ["openshell", "policy", "set"]; - }); - vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "openclaw", - openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", - }); - vi.spyOn(registry, "updateSandbox").mockReturnValue(true); - vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue({ - authority: "nemoclaw-managed", - effectivePolicy: { version: 1, network_policies: {} }, - policyIdentity: { hash: "sha256:managed", activeVersion: 1 }, - }); - const authorityReceipt = { - authority: "nemoclaw-managed" as const, - authorityRecordedNow: false, - gatewayName: "nemoclaw", - inspection: { - authority: "nemoclaw-managed" as const, - effectivePolicy: { version: 1, network_policies: {} }, - policyIdentity: { hash: "sha256:managed", activeVersion: 1 }, - }, - }; - vi.spyOn(policy, "inspectPolicyMutationAuthority").mockReturnValue(authorityReceipt); - vi.spyOn(policy, "recheckPolicyMutationAuthority").mockReturnValue(authorityReceipt); - vi.spyOn(policy, "finalizePolicyMutationReceipt").mockImplementation(() => undefined); - - const shields = requireSource(SHIELDS_MODULE) as typeof import("./index.js"); - return { applyShieldsPolicySnapshot: shields.applyShieldsPolicySnapshot, policySetBodies }; - } - - function writeCurrentProcessTimerMarker(snapshotPath: string, processToken: string): void { - fs.writeFileSync( - path.join(homeDir, ".nemoclaw", "state", "shields-timer-openclaw.json"), - JSON.stringify({ - pid: process.pid, - sandboxName: "openclaw", - snapshotPath, - restoreAt: new Date(Date.now() + 60_000).toISOString(), - processToken, - }), - { mode: 0o600 }, - ); - } - - beforeEach(() => { - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-mcp-deadline-flow-")); - vi.stubEnv("HOME", homeDir); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - fs.rmSync(homeDir, { recursive: true, force: true }); - delete require.cache[requireSource.resolve(SHIELDS_MODULE)]; - delete require.cache[requireSource.resolve("./permissive-runtime.js")]; - delete require.cache[requireSource.resolve("../actions/sandbox/mcp-bridge-policy.js")]; - }); - - it("restores lockdown with malformed and duplicate ownership", () => { - const stateDir = path.join(homeDir, ".nemoclaw", "state"); - const processToken = "a".repeat(32); - const snapshotPath = path.join(stateDir, "policy-snapshot-malformed-deadline.yaml"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - snapshotPath, - YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: {}, - mcp_bridge_: {}, - mcp_bridge_alpha: {}, - }, - }), - ); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: ["mcp_bridge_", "mcp_bridge_alpha", "mcp_bridge_alpha"], - }), - ); - writeCurrentProcessTimerMarker(snapshotPath, processToken); - const harness = createRestoreHarness(); - - const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - }); - - expect(result.status).toBe(0); - expect(result.managedMcpOmissions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "mcp_bridge_", - reason: expect.stringMatching(/ownership key.*invalid/), - }), - expect.objectContaining({ - key: "mcp_bridge_alpha", - reason: expect.stringMatching(/more than once/), - }), - ]), - ); - expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies).toEqual({ - restrictive_baseline: {}, - }); - }); - - it("restores lockdown when transition and persisted ownership differ", () => { - const stateDir = path.join(homeDir, ".nemoclaw", "state"); - const processToken = "b".repeat(32); - const snapshotPath = path.join(stateDir, "policy-snapshot-mismatched-deadline.yaml"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - snapshotPath, - YAML.stringify({ - version: 1, - network_policies: { - restrictive_baseline: {}, - mcp_bridge_alpha: {}, - mcp_bridge_beta: {}, - }, - }), - ); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: ["mcp_bridge_alpha"], - }), - ); - fs.writeFileSync( - path.join(stateDir, `shields-transition-openclaw-${processToken}.json`), - JSON.stringify({ - version: 1, - phase: "active", - ownerPid: process.pid, - ownerStartIdentity: "test-owner", - processToken, - sandboxName: "openclaw", - snapshotPath, - managedMcpPolicyKeys: ["mcp_bridge_beta"], - }), - { mode: 0o600 }, - ); - writeCurrentProcessTimerMarker(snapshotPath, processToken); - const harness = createRestoreHarness(); - - const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - }); - - expect(result.status).toBe(0); - expect(result.managedMcpOmissions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - reason: expect.stringMatching(/did not match persisted policy ownership/), - }), - ]), - ); - expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies).toEqual({ - restrictive_baseline: {}, - }); - }); - - it("restores lockdown from a legacy snapshot without ownership metadata", () => { - const stateDir = path.join(homeDir, ".nemoclaw", "state"); - const processToken = "c".repeat(32); - const snapshotPath = path.join(stateDir, "policy-snapshot-legacy-deadline.yaml"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - snapshotPath, - YAML.stringify({ - version: 1, - network_policies: { restrictive_baseline: {}, mcp_bridge_alpha: {} }, - }), - ); - fs.writeFileSync( - path.join(stateDir, "shields-openclaw.json"), - JSON.stringify({ shieldsDown: true, shieldsPolicySnapshotPath: snapshotPath }), - ); - writeCurrentProcessTimerMarker(snapshotPath, processToken); - const harness = createRestoreHarness(); - - const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - }); - - expect(result.status).toBe(0); - expect(result.managedMcpOmissions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ reason: expect.stringMatching(/no managed MCP ownership/) }), - expect.objectContaining({ key: "mcp_bridge_alpha" }), - ]), - ); - expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies).toEqual({ - restrictive_baseline: {}, - }); - }); -}); - function removeWithInjectedStateRestoreFailure( originalRmSync: typeof fs.rmSync, statePath: string, @@ -1223,9 +812,8 @@ function removeWithInjectedStateRestoreFailure( switch (String(target)) { case statePath: throw new Error("injected state restoration failure"); - default: - return originalRmSync(target, options); } + return originalRmSync(target, options); }) as typeof fs.rmSync; } diff --git a/src/lib/shields/state-dir-lock.ts b/src/lib/shields/state-dir-lock.ts index b17302d4b20..97bb25b8206 100644 --- a/src/lib/shields/state-dir-lock.ts +++ b/src/lib/shields/state-dir-lock.ts @@ -52,7 +52,11 @@ type GuardSummary = { }; function resultFailure(label: string, result: PrivilegedExecResult): string { - const details = [result.error, result.stderr.trim(), result.stdout.trim()] + const details = [ + result.error, + String(result.stderr ?? "").trim(), + String(result.stdout ?? "").trim(), + ] .filter((value): value is string => Boolean(value)) .join("; "); const termination = @@ -128,10 +132,10 @@ function inspectRuntimePlan( }; } const read = privileged.run(["cat", CONTAINER_STATE_LOCK_PLAN]); - if (!successful(read) || read.stderr.trim()) { + if (!successful(read) || String(read.stderr ?? "").trim()) { return { kind: "error", issue: resultFailure("installed state lock plan read failed", read) }; } - const parsed = parseInstalledPlan(read.stdout); + const parsed = parseInstalledPlan(String(read.stdout ?? "")); if (typeof parsed === "string") return { kind: "error", issue: parsed }; if (!plansMatch(parsed, expected)) { return { @@ -156,8 +160,10 @@ function parseGuardOutput(action: GuardAction, result: PrivilegedExecResult): st const issues: GuardIssue[] = []; const summaries: GuardSummary[] = []; const contractIssues: string[] = []; + const stdout = String(result.stdout ?? ""); + const stderr = String(result.stderr ?? ""); - for (const line of result.stdout.split("\n")) { + for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; let value: unknown; @@ -220,8 +226,8 @@ function parseGuardOutput(action: GuardAction, result: PrivilegedExecResult): st } else if (result.status !== 0 && issues.length === 0) { contractIssues.push(resultFailure("state-dir guard failed without a diagnostic", result)); } - if (result.stderr.trim()) { - contractIssues.push(`state-dir guard wrote unexpected stderr: ${result.stderr.trim()}`); + if (stderr.trim()) { + contractIssues.push(`state-dir guard wrote unexpected stderr: ${stderr.trim()}`); } return [ diff --git a/src/lib/shields/status-state-lock-plan.test.ts b/src/lib/shields/status-state-lock-plan.test.ts index 4dd060b8d1e..193e703580e 100644 --- a/src/lib/shields/status-state-lock-plan.test.ts +++ b/src/lib/shields/status-state-lock-plan.test.ts @@ -47,43 +47,6 @@ async function loadShieldsModule() { } describe("Shields status state lock plan drift", () => { - it("reports unavailable policy authority during temporary unlock (#9833)", async () => { - const sandboxName = "openclaw"; - const stateDir = path.join(tmpDir, ".nemoclaw", "state"); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync( - path.join(stateDir, `shields-${sandboxName}.json`), - JSON.stringify({ shieldsDown: true, shieldsDownAt: new Date().toISOString() }), - { mode: 0o600 }, - ); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { - throw new Error(`exit ${String(code)}`); - }); - - const { shieldsStatus } = await loadShieldsModule(); - expect(() => - shieldsStatus(sandboxName, false, { - inspectPolicyRecovery: () => ({ - status: "unavailable", - detail: "OpenShell sandbox policy authority inspection failed: the query timed out.", - }), - }), - ).toThrow("exit 2"); - - const output = logSpy.mock.calls.flat().join("\n"); - const errors = errorSpy.mock.calls.flat().join("\n"); - expect(exitSpy).toHaveBeenCalledWith(2); - expect(output).not.toContain("DOWN (temporarily unlocked)"); - expect(output).not.toContain("Auto-lockdown in:"); - expect(errors).toContain("Shields: DOWN (RECOVERY REQUIRED — policy authority unavailable)"); - expect(errors).toContain("OpenShell sandbox policy authority inspection failed"); - expect(errors).toContain( - "Recovery: restore policy authority inspection for sandbox 'openclaw'", - ); - }); - it("reports a mismatched installed state lock plan as drift", async () => { const sandboxName = "openclaw"; writeSealedLockedState(sandboxName); diff --git a/src/lib/shields/timer-recovery-budget.test.ts b/src/lib/shields/timer-recovery-budget.test.ts index ca724d5540b..bf714babb3a 100644 --- a/src/lib/shields/timer-recovery-budget.test.ts +++ b/src/lib/shields/timer-recovery-budget.test.ts @@ -6,7 +6,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ManagedMcpPolicyOmission } from "../actions/sandbox/mcp-bridge-policy"; import { beginCommittedMcpLifecycleContainmentSync, getMcpLifecycleLockPath, @@ -14,9 +13,7 @@ import { } from "../state/mcp-lifecycle-lock"; const shieldsIndexMock = vi.hoisted(() => ({ - applyShieldsPolicySnapshot: vi.fn( - (): { status: number; managedMcpOmissions?: ManagedMcpPolicyOmission[] } => ({ status: 0 }), - ), + applyShieldsPolicySnapshot: vi.fn((): { status: number } => ({ status: 0 })), completeAutoRestoreTransition: vi.fn(() => true), lockAgentConfig: vi.fn(), prepareAutoRestoreTransitionTakeover: vi.fn(), diff --git a/src/lib/shields/timer.test.ts b/src/lib/shields/timer.test.ts index 80db4419816..73099c02c1e 100644 --- a/src/lib/shields/timer.test.ts +++ b/src/lib/shields/timer.test.ts @@ -6,7 +6,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ManagedMcpPolicyOmission } from "../actions/sandbox/mcp-bridge-policy"; import { beginCommittedMcpLifecycleContainmentSync, getMcpLifecycleLockPath, @@ -14,12 +13,7 @@ import { } from "../state/mcp-lifecycle-lock"; const shieldsIndexMock = vi.hoisted(() => ({ - applyShieldsPolicySnapshot: vi.fn( - (): { - status: number; - managedMcpOmissions?: ManagedMcpPolicyOmission[]; - } => ({ status: 0 }), - ), + applyShieldsPolicySnapshot: vi.fn((): { status: number } => ({ status: 0 })), completeAutoRestoreTransition: vi.fn(() => true), lockAgentConfig: vi.fn() as unknown, prepareAutoRestoreTransitionTakeover: vi.fn(), @@ -474,12 +468,10 @@ describe("shields timer authorization", () => { expect(fs.existsSync(deadlinePath)).toBe(true); return { status: 17, - managedMcpOmissions: [{ server: "beta", reason: "incomplete add" }], }; }); shieldsIndexMock.applyShieldsPolicySnapshot.mockReturnValueOnce({ status: 0, - managedMcpOmissions: [], }); const args = timer.parseTimerArgs([ sandboxName, @@ -726,7 +718,6 @@ describe("shields timer authorization", () => { }); return { status: 0, - managedMcpOmissions: [{ server: "beta", reason: "incomplete add" }], }; }); @@ -762,7 +753,6 @@ describe("shields timer authorization", () => { ).toContainEqual( expect.objectContaining({ action: "shields_auto_restore", - warning: "Auto-restore omitted 1 unproven managed MCP policy entries", }), ); }); diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index e596e0fda73..999f14ab832 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -304,7 +304,6 @@ async function runRestoreTimerWithBudget( let retryScheduled = false; let terminalContainment = false; let restoreCompleted = false; - let managedMcpWarning: string | undefined; const scheduleRetry = (): boolean => { if (!markerMatchesCurrentTimer(args)) return false; retryScheduled = true; @@ -401,11 +400,6 @@ async function runRestoreTimerWithBudget( deadlineAuthoritative: true, }); const status = typeof result.status === "number" ? result.status : 1; - managedMcpWarning = result.managedMcpOmissions?.length - ? `Auto-restore omitted ${String( - result.managedMcpOmissions.length, - )} unproven managed MCP policy entries` - : undefined; if (status !== 0) { appendAudit({ @@ -530,7 +524,6 @@ async function runRestoreTimerWithBudget( restored_by: "auto_timer", policy_snapshot: args.snapshotPath, scheduled_restore_at: args.restoreAtIso, - ...(managedMcpWarning ? { warning: managedMcpWarning } : {}), }); restoreCompleted = true; exitCode = 0; diff --git a/src/lib/state/launch-readiness-lease.test.ts b/src/lib/state/launch-readiness-lease.test.ts index 35a43a2179b..6261afb11db 100644 --- a/src/lib/state/launch-readiness-lease.test.ts +++ b/src/lib/state/launch-readiness-lease.test.ts @@ -35,7 +35,6 @@ function identity(gatewayName = GATEWAY_NAME): LaunchReadinessIdentity { return { registry: DIGEST, agent: DIGEST, - livePolicy: DIGEST, liveInference: DIGEST, gatewayName, lifecycleGeneration: "generation-1", @@ -53,7 +52,6 @@ function openClawIdentity(): LaunchReadinessIdentity { openclawVersion: "2026.7.1", deviceIdentitySha256: DIGEST, pairingStateSha256: DIGEST, - policySha256: DIGEST, requiredRoles: ["operator"], requiredScopes: ["operator.pairing", "operator.read", "operator.write"], }, @@ -281,7 +279,7 @@ describe("launch readiness lease storage", () => { expect(readLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()).kind).toBe("malformed"); const next = fenceLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()); - expect(next).toMatchObject({ schemaVersion: 2, epochId: EPOCH_B }); + expect(next).toMatchObject({ schemaVersion: 3, epochId: EPOCH_B }); expect(next.preservedLeaseStartedWallMs).toBe(first.leaseStartedWallMs); expect(next.preservedLeaseExpiresWallMs).toBe(first.leaseExpiresWallMs); expect( diff --git a/src/lib/state/launch-readiness-lease.ts b/src/lib/state/launch-readiness-lease.ts index 48cf67671cf..eab922b43c0 100644 --- a/src/lib/state/launch-readiness-lease.ts +++ b/src/lib/state/launch-readiness-lease.ts @@ -12,7 +12,7 @@ import { nemoclawStateRoot } from "./state-root"; export const LAUNCH_READINESS_LEASE_MS = 24 * 60 * 60 * 1_000; // This version covers lease and fence records. Nested session qualifications // and the separate runtime authority keep their independent schema versions. -export const LAUNCH_READINESS_SCHEMA_VERSION = 2; +export const LAUNCH_READINESS_SCHEMA_VERSION = 3; export const LAUNCH_READINESS_MAX_BYTES = 16 * 1_024; const RECEIPT_DIRECTORY = "launch-readiness"; @@ -24,7 +24,6 @@ const BOOT_ID_RE = /^[A-Za-z0-9._:-]{1,160}$/; export interface LaunchReadinessIdentity { registry: string; agent: string; - livePolicy: string; liveInference: string; gatewayName: string; lifecycleGeneration: string; @@ -38,7 +37,6 @@ export interface LaunchReadinessOpenClawSessionQualification { openclawVersion: string; deviceIdentitySha256: string; pairingStateSha256: string; - policySha256: string; requiredRoles: ["operator"]; requiredScopes: ["operator.pairing", "operator.read", "operator.write"]; } @@ -46,7 +44,7 @@ export interface LaunchReadinessOpenClawSessionQualification { export type LaunchReadinessSessionQualification = LaunchReadinessOpenClawSessionQualification; export interface LaunchReadinessLease { - schemaVersion: 2; + schemaVersion: 3; kind: "lease"; epochId: string; sandboxName: string; @@ -67,7 +65,7 @@ export interface LaunchReadinessLease { } export interface LaunchReadinessFence { - schemaVersion: 2; + schemaVersion: 3; kind: "fence"; epochId: string; sandboxName: string; @@ -222,7 +220,6 @@ function isSessionQualification(value: unknown): value is LaunchReadinessSession "openclawVersion", "deviceIdentitySha256", "pairingStateSha256", - "policySha256", "requiredRoles", "requiredScopes", ]) @@ -239,8 +236,6 @@ function isSessionQualification(value: unknown): value is LaunchReadinessSession SHA256_RE.test(value.deviceIdentitySha256) && typeof value.pairingStateSha256 === "string" && SHA256_RE.test(value.pairingStateSha256) && - typeof value.policySha256 === "string" && - SHA256_RE.test(value.policySha256) && isExactStringArray(value.requiredRoles, ["operator"]) && isExactStringArray(value.requiredScopes, [ "operator.pairing", @@ -256,7 +251,6 @@ function isIdentity(value: unknown): value is LaunchReadinessIdentity { !hasExactKeys(value, [ "registry", "agent", - "livePolicy", "liveInference", "gatewayName", "lifecycleGeneration", @@ -279,8 +273,6 @@ function isIdentity(value: unknown): value is LaunchReadinessIdentity { SHA256_RE.test(value.registry) && typeof value.agent === "string" && SHA256_RE.test(value.agent) && - typeof value.livePolicy === "string" && - SHA256_RE.test(value.livePolicy) && typeof value.liveInference === "string" && SHA256_RE.test(value.liveInference) && (value.session === null || isSessionQualification(value.session)) diff --git a/src/lib/state/onboard-session-cross-process-lock.test.ts b/src/lib/state/onboard-session-cross-process-lock.test.ts index aa37b509ea0..c0583bbabc7 100644 --- a/src/lib/state/onboard-session-cross-process-lock.test.ts +++ b/src/lib/state/onboard-session-cross-process-lock.test.ts @@ -190,7 +190,9 @@ describe("cross-process onboard lock", () => { })(); }); try { - expect(() => session.clearSession()).toThrow(/state directory changed|session state changed/u); + expect(() => session.clearSession()).toThrow( + /state directory changed|session state changed/u, + ); } finally { unlinkSpy.mockRestore(); } @@ -343,9 +345,7 @@ describe("cross-process onboard lock", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-" + role, - verifiedEffectivePolicyIdentity: null, - createAttemptNonce: "c".repeat(62), - policyCreationReceipt: null, + createAttemptNonce: role.repeat(62), resources: { sharedInferenceProviders: [], sandboxScopedProviders: [], @@ -392,17 +392,6 @@ describe("cross-process onboard lock", () => { const fingerprint = "a".repeat(64); const createAttemptNonce = "b".repeat(62); const lifecycleGeneration = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - const policyCreationReceipt = { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: "nemoclaw", - gatewayPort: 8080, - sandboxName: "alpha", - lifecycleGeneration, - sandboxIdentityFingerprint: fingerprint, - policyHash: "sha256:effective", - policyVersion: 4, - }; const writer = spawnSync( process.execPath, [ @@ -435,9 +424,7 @@ describe("cross-process onboard lock", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration, - verifiedEffectivePolicyIdentity: { hash: "sha256:effective", activeVersion: 4 }, createAttemptNonce, - policyCreationReceipt, }), ], { env: { ...process.env, HOME: tempHome }, encoding: "utf8" }, @@ -454,9 +441,7 @@ describe("cross-process onboard lock", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration, - verifiedEffectivePolicyIdentity: { hash: "sha256:effective", activeVersion: 4 }, createAttemptNonce, - policyCreationReceipt, }), ]); }); diff --git a/src/lib/state/onboard-session-normalization.test.ts b/src/lib/state/onboard-session-normalization.test.ts index 84ef75802a5..5a73df0d8af 100644 --- a/src/lib/state/onboard-session-normalization.test.ts +++ b/src/lib/state/onboard-session-normalization.test.ts @@ -3,42 +3,25 @@ import { describe, expect, it } from "vitest"; -import { - createSession, - filterSafeUpdates, - normalizeSession, - summarizeForDebug, -} from "./onboard-session"; +import { createSession, normalizeSession, summarizeForDebug } from "./onboard-session"; type LegacySession = Omit, "machine"> & { machine?: unknown; }; -const VERIFIED_RECOVERY_RECEIPT = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, +const VERIFIED_RECOVERY = { + reason: "retained_after_sandbox_creation_failure" as const, + sandboxName: "retained-sb", + sandboxIdentityFingerprint: "a".repeat(64), gatewayName: "nemoclaw", gatewayPort: 8080, - sandboxName: "retained-sb", lifecycleGeneration: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - sandboxIdentityFingerprint: "a".repeat(64), - policyHash: "sha256:effective", - policyVersion: 4, -}; - -const VERIFIED_RECOVERY = { - reason: "retained_after_sandbox_creation_failure" as const, - sandboxName: VERIFIED_RECOVERY_RECEIPT.sandboxName, - sandboxIdentityFingerprint: VERIFIED_RECOVERY_RECEIPT.sandboxIdentityFingerprint, - gatewayName: VERIFIED_RECOVERY_RECEIPT.gatewayName, - gatewayPort: VERIFIED_RECOVERY_RECEIPT.gatewayPort, - lifecycleGeneration: VERIFIED_RECOVERY_RECEIPT.lifecycleGeneration, verifiedEffectivePolicyIdentity: { - hash: VERIFIED_RECOVERY_RECEIPT.policyHash, - activeVersion: VERIFIED_RECOVERY_RECEIPT.policyVersion, + hash: "sha256:legacy", + activeVersion: 4, }, createAttemptNonce: "c".repeat(62), - policyCreationReceipt: VERIFIED_RECOVERY_RECEIPT, + policyCreationReceipt: { schemaVersion: 1, policyHash: "sha256:legacy" }, recordedAt: "2026-08-27T00:00:00.000Z", }; @@ -57,9 +40,7 @@ describe("onboard session normalization", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, createAttemptNonce: "c".repeat(62), - policyCreationReceipt: null, recordedAt: "2026-08-27T00:00:00.000Z", }; const normalized = normalizeSession({ @@ -96,37 +77,16 @@ describe("onboard session normalization", () => { ).toThrow(/saved recovery authority is incomplete/u); }); - it("preserves a policy receipt bound to the saved recovery authority (#9833)", () => { - expect( - normalizeSession({ - ...createSession({ sandboxName: VERIFIED_RECOVERY.sandboxName }), - resumable: false, - status: "recovery_required", - cancellationRecovery: VERIFIED_RECOVERY, - })?.cancellationRecovery?.policyCreationReceipt, - ).toEqual(VERIFIED_RECOVERY_RECEIPT); - }); - - it.each([ - ["gateway name", { gatewayName: "replacement-gateway" }], - ["gateway port", { gatewayPort: 8081 }], - ["sandbox name", { sandboxName: "replacement-sandbox" }], - ["lifecycle generation", { lifecycleGeneration: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }], - ["identity fingerprint", { sandboxIdentityFingerprint: "b".repeat(64) }], - ["policy hash", { policyHash: "sha256:replacement" }], - ["policy version", { policyVersion: 5 }], - ])("fails closed when the recovery receipt has a mismatched %s (#9833)", (_field, mismatch) => { - expect(() => - normalizeSession({ - ...createSession({ sandboxName: VERIFIED_RECOVERY.sandboxName }), - resumable: false, - status: "recovery_required", - cancellationRecovery: { - ...VERIFIED_RECOVERY, - policyCreationReceipt: { ...VERIFIED_RECOVERY_RECEIPT, ...mismatch }, - }, - }), - ).toThrow(/saved recovery authority is incomplete/u); + it("strips every legacy policy field from saved recovery authority (#9833)", () => { + const recovery = normalizeSession({ + ...createSession({ sandboxName: VERIFIED_RECOVERY.sandboxName }), + resumable: false, + status: "recovery_required", + cancellationRecovery: VERIFIED_RECOVERY, + })?.cancellationRecovery; + expect(recovery).not.toHaveProperty("policyCreationReceipt"); + expect(recovery).not.toHaveProperty("verifiedEffectivePolicyIdentity"); + expect(recovery).toMatchObject({ createAttemptNonce: "c".repeat(62) }); }); it("keeps APF create intent and defaults legacy sessions to false (#9833)", () => { @@ -152,43 +112,6 @@ describe("onboard session normalization", () => { ).toThrow(/saved APF selection is invalid/u); }); - it("keeps recognized, absent, and legacy null policy authority values (#9833)", () => { - const external = createSession({ policyAuthority: "externally-managed" }); - expect(normalizeSession(external)?.policyAuthority).toBe("externally-managed"); - - const absent = { ...external } as Partial; - delete absent.policyAuthority; - expect( - normalizeSession(absent as Parameters[0])?.policyAuthority, - ).toBeNull(); - expect(normalizeSession({ ...external, policyAuthority: null })?.policyAuthority).toBeNull(); - }); - - it("refuses an invalid saved policy authority (#9833)", () => { - const external = createSession({ policyAuthority: "externally-managed" }); - const malformed = { ...external, policyAuthority: "unspecified" }; - expect(() => normalizeSession(malformed as Parameters[0])).toThrow( - /saved policy authority is invalid/u, - ); - }); - - it("clears NemoClaw preset attribution for external policy authority (#9833)", () => { - const external = createSession({ - policyAuthority: "externally-managed", - policyPresets: ["npm"], - }); - expect(external.policyPresets).toBeNull(); - - const legacy = { - ...createSession({ policyAuthority: "nemoclaw-managed", policyPresets: ["npm"] }), - policyAuthority: "externally-managed" as const, - }; - expect(normalizeSession(legacy)?.policyPresets).toBeNull(); - expect( - filterSafeUpdates({ policyAuthority: "externally-managed", policyPresets: ["npm"] }), - ).toMatchObject({ policyAuthority: "externally-managed", policyPresets: null }); - }); - it("normalizes old sessions without machine snapshots", () => { const legacy = createSession({ sessionId: "legacy-session", diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 2414e1b5a68..a6704cd4f0a 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -428,7 +428,6 @@ describe("onboard session", () => { preferredInferenceApi: "openai-completions", compatibleEndpointReasoning: "true", nimContainer: "nim-123", - policyPresets: ["pypi", "npm"], apiKey: "nvapi-secret", metadata: { gatewayName: "nemoclaw", @@ -446,7 +445,6 @@ describe("onboard session", () => { expect(loaded.preferredInferenceApi).toBe("openai-completions"); expect(loaded.compatibleEndpointReasoning).toBe("true"); expect(loaded.nimContainer).toBe("nim-123"); - expect(loaded.policyPresets).toEqual(["pypi", "npm"]); expect(requireDebugSummary(session.summarizeForDebug()).compatibleEndpointReasoning).toBe( "true", ); @@ -750,7 +748,7 @@ describe("onboard session", () => { session.saveSession(created); const raw = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf-8")); - expect(raw.messagingPlan.networkPolicy).toEqual({ presets: [], entries: [] }); + expect(raw.messagingPlan.networkPolicy).toBeUndefined(); expect(raw.messagingPlan.agentRender).toBeUndefined(); expect(raw.messagingPlan.buildSteps).toBeUndefined(); expect(raw.messagingPlan.runtimeSetup).toBeUndefined(); @@ -1437,14 +1435,6 @@ describe("onboard session", () => { expect(created.provider).toBeNull(); }); - it("filters non-string array entries in createSession overrides", () => { - const created = session.createSession({ - policyPresets: ["pypi", 7, null, "npm"] as unknown as string[], - }); - - expect(created.policyPresets).toEqual(["pypi", "npm"]); - }); - it("summarizes the session for debug output", () => { session.saveSession(session.createSession({ sandboxName: "my-assistant" })); session.markStepStarted("preflight"); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 01ba28aace3..52a06ca85b2 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -11,7 +11,6 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import type { SandboxPolicyAuthority } from "../adapters/openshell/policy-authority"; import { isErrnoException } from "../core/errno"; import { isObjectRecord, type JsonObject, type JsonValue } from "../core/json-types"; import { GATEWAY_PORT } from "../core/ports"; @@ -63,13 +62,11 @@ import { import { nextMachineStateAfterCompletedStep } from "./onboard-step-state"; import { listRetainedSandboxRecoveryRecords as readRetainedSandboxRecoveryRecords, - parseNemoClawPolicyCreationReceipt, recordRetainedSandboxRecovery as writeRetainedSandboxRecovery, retainedSandboxRecoveryFile, type RecordRetainedSandboxRecoveryInput, type RetainedSandboxRecoveryRecord, type RetainedSandboxRecoveryReason, - type RetainedSandboxVerifiedEffectivePolicyIdentity, } from "./onboard-session/retained-sandbox-recovery"; import type { SandboxHostMount } from "./registry/types"; import { hasUnsafeHostMountTerminalText } from "./registry/host-mount"; @@ -87,7 +84,6 @@ export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); export const RETAINED_SANDBOX_RECOVERY_FILE = retainedSandboxRecoveryFile(SESSION_DIR); const SAFE_VLLM_INSTALL_MODEL = /^[A-Za-z0-9._:/-]+$/; -export class InvalidPersistedPolicyAuthorityError extends Error {} export class InvalidPersistedApfInterceptorIntentError extends Error {} export class InvalidPersistedCancellationRecoveryError extends Error {} @@ -131,9 +127,7 @@ export interface SessionCancellationRecovery { readonly gatewayName: string; readonly gatewayPort: number; readonly lifecycleGeneration: string; - readonly verifiedEffectivePolicyIdentity: RetainedSandboxVerifiedEffectivePolicyIdentity | null; readonly createAttemptNonce: string; - readonly policyCreationReceipt: RetainedSandboxRecoveryRecord["policyCreationReceipt"]; readonly recordedAt: string; } @@ -142,10 +136,6 @@ function sameCancellationRecovery( right: SessionCancellationRecovery | null, ): boolean { if (left === null || right === null) return left === right; - const leftPolicy = left.verifiedEffectivePolicyIdentity; - const rightPolicy = right.verifiedEffectivePolicyIdentity; - const leftReceipt = left.policyCreationReceipt; - const rightReceipt = right.policyCreationReceipt; return ( left.reason === right.reason && left.sandboxName === right.sandboxName && @@ -154,22 +144,7 @@ function sameCancellationRecovery( left.gatewayPort === right.gatewayPort && left.lifecycleGeneration === right.lifecycleGeneration && left.createAttemptNonce === right.createAttemptNonce && - left.recordedAt === right.recordedAt && - (leftPolicy === null || rightPolicy === null - ? leftPolicy === rightPolicy - : leftPolicy.hash === rightPolicy.hash && - leftPolicy.activeVersion === rightPolicy.activeVersion) && - (leftReceipt === null || rightReceipt === null - ? leftReceipt === rightReceipt - : leftReceipt.schemaVersion === rightReceipt.schemaVersion && - leftReceipt.origin === rightReceipt.origin && - leftReceipt.gatewayName === rightReceipt.gatewayName && - leftReceipt.gatewayPort === rightReceipt.gatewayPort && - leftReceipt.sandboxName === rightReceipt.sandboxName && - leftReceipt.lifecycleGeneration === rightReceipt.lifecycleGeneration && - leftReceipt.sandboxIdentityFingerprint === rightReceipt.sandboxIdentityFingerprint && - leftReceipt.policyHash === rightReceipt.policyHash && - leftReceipt.policyVersion === rightReceipt.policyVersion) + left.recordedAt === right.recordedAt ); } @@ -309,9 +284,6 @@ export interface Session { /** Operator-selected APF create mode; this is not observed policy provenance. */ apfInterceptorRequested: boolean; hermesToolGateways: string[] | null; - policyPresets: string[] | null; - /** Policy authority selected from OpenShell metadata before policy-dependent effects. */ - policyAuthority: SandboxPolicyAuthority | null; messagingPlan: SandboxMessagingPlan | null; /** Non-secret names of credential providers registered before sandbox setup completed. */ stagedCredentialProviders: string[]; @@ -388,8 +360,6 @@ export interface SessionUpdates { toolDisclosure?: ToolDisclosure; observabilityEnabled?: boolean; hermesToolGateways?: string[] | null; - policyPresets?: string[] | null; - policyAuthority?: SandboxPolicyAuthority | null; messagingPlan?: SandboxMessagingPlan | null; migratedLegacyValueHashes?: Record; gpuPassthrough?: boolean; @@ -426,8 +396,6 @@ export interface DebugSessionSummary { observabilityRequestedExplicitly: boolean; apfInterceptorRequested: boolean; hermesToolGateways: string[] | null; - policyPresets: string[] | null; - policyAuthority: SandboxPolicyAuthority | null; gpuPassthrough: boolean; lastStepStarted: string | null; lastCompletedStep: string | null; @@ -572,10 +540,6 @@ function readHermesAuthMethod(value: SessionJsonValue | undefined): HermesAuthMe return value === "oauth" || value === "api_key" ? value : null; } -function readPolicyAuthority(value: unknown): SandboxPolicyAuthority | null { - return value === "nemoclaw-managed" || value === "externally-managed" ? value : null; -} - function readPositiveInteger(value: SessionJsonValue | undefined): number | null { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; } @@ -843,23 +807,6 @@ function parseSessionCancellationRecovery( const gatewayPort = value.gatewayPort; const lifecycleGeneration = readString(value.lifecycleGeneration); const createAttemptNonce = readString(value.createAttemptNonce); - const verifiedEffectivePolicyIdentity = (() => { - if (value.verifiedEffectivePolicyIdentity === null) return null; - if (!isObject(value.verifiedEffectivePolicyIdentity)) return undefined; - const hash = readString(value.verifiedEffectivePolicyIdentity.hash); - const activeVersion = value.verifiedEffectivePolicyIdentity.activeVersion; - return hash && Number.isSafeInteger(activeVersion) && Number(activeVersion) > 0 - ? { hash, activeVersion: Number(activeVersion) } - : undefined; - })(); - let policyCreationReceipt: RetainedSandboxRecoveryRecord["policyCreationReceipt"] = null; - if (value.policyCreationReceipt !== null) { - try { - policyCreationReceipt = parseNemoClawPolicyCreationReceipt(value.policyCreationReceipt); - } catch { - return null; - } - } if ( !sandboxName || sandboxName.length > NAME_MAX_LENGTH || @@ -871,17 +818,8 @@ function parseSessionCancellationRecovery( Number(gatewayPort) < 1024 || Number(gatewayPort) > 65_535 || !lifecycleGeneration || - verifiedEffectivePolicyIdentity === undefined || !createAttemptNonce || - !/^[0-9a-f]{62}$/u.test(createAttemptNonce) || - (policyCreationReceipt !== null && - (policyCreationReceipt.gatewayName !== gatewayName || - policyCreationReceipt.gatewayPort !== Number(gatewayPort) || - policyCreationReceipt.sandboxName !== sandboxName || - policyCreationReceipt.lifecycleGeneration !== lifecycleGeneration || - policyCreationReceipt.sandboxIdentityFingerprint !== fingerprint || - policyCreationReceipt.policyHash !== verifiedEffectivePolicyIdentity?.hash || - policyCreationReceipt.policyVersion !== verifiedEffectivePolicyIdentity?.activeVersion)) + !/^[0-9a-f]{62}$/u.test(createAttemptNonce) ) { return null; } @@ -892,9 +830,7 @@ function parseSessionCancellationRecovery( gatewayName, gatewayPort: Number(gatewayPort), lifecycleGeneration, - verifiedEffectivePolicyIdentity, createAttemptNonce, - policyCreationReceipt, recordedAt, }; } @@ -984,7 +920,6 @@ export function createSession(overrides: Partial = {}): Session { ...defaultSteps(), ...(overrides.steps ?? {}), }; - const policyAuthority = readPolicyAuthority(overrides.policyAuthority); const session: Session = { version: SESSION_VERSION, sessionId, @@ -1034,9 +969,6 @@ export function createSession(overrides: Partial = {}): Session { observabilityRequestedExplicitly: overrides.observabilityRequestedExplicitly === true, apfInterceptorRequested: overrides.apfInterceptorRequested === true, hermesToolGateways: readStringArray(overrides.hermesToolGateways), - policyPresets: - policyAuthority === "externally-managed" ? null : readStringArray(overrides.policyPresets), - policyAuthority, messagingPlan: parseSandboxMessagingPlan(overrides.messagingPlan), stagedCredentialProviders: readStringArray(overrides.stagedCredentialProviders) ?? [], migratedLegacyValueHashes: overrides.migratedLegacyValueHashes @@ -1073,12 +1005,6 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): "Refusing to load the onboarding session: the saved APF selection is invalid.", ); } - const policyAuthority = readPolicyAuthority(data.policyAuthority); - if (hasOwn(data, "policyAuthority") && data.policyAuthority !== null && !policyAuthority) { - throw new InvalidPersistedPolicyAuthorityError( - "Refusing to load the onboarding session: the saved policy authority is invalid.", - ); - } const servingProfileProvenance = parseServingProfileProvenance(data.servingProfileProvenance); if ( hasOwn(data, "servingProfileProvenance") && @@ -1166,8 +1092,6 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): observabilityRequestedExplicitly: data.observabilityRequestedExplicitly === true, apfInterceptorRequested: data.apfInterceptorRequested === true, hermesToolGateways: readStringArray(data.hermesToolGateways), - policyPresets: readStringArray(data.policyPresets), - policyAuthority, messagingPlan: parseSandboxMessagingPlan(data.messagingPlan), stagedCredentialProviders: readStringArray(data.stagedCredentialProviders) ?? [], migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes), @@ -1289,11 +1213,7 @@ export function loadSession(): Session | null { if (lockOwned) assertOnboardLockOwned(); return normalized; } catch (error) { - if ( - error instanceof InvalidPersistedPolicyAuthorityError || - error instanceof InvalidPersistedApfInterceptorIntentError || - error instanceof InvalidPersistedCancellationRecoveryError - ) { + if (error instanceof InvalidPersistedApfInterceptorIntentError) { throw error; } if (lockOwned) throw error; @@ -1932,20 +1852,6 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { (value) => typeof value === "string", ); } - if (updates.policyPresets === null) { - safe.policyPresets = null; - } else if (Array.isArray(updates.policyPresets)) { - safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); - } - if (updates.policyAuthority === null) { - safe.policyAuthority = null; - } else { - const policyAuthority = readPolicyAuthority(updates.policyAuthority); - if (policyAuthority) { - safe.policyAuthority = policyAuthority; - if (policyAuthority === "externally-managed") safe.policyPresets = null; - } - } if (updates.messagingPlan === null) { safe.messagingPlan = null; } else { @@ -1998,9 +1904,7 @@ export interface RetainedSandboxRecoveryContext { readonly gatewayName: string; readonly gatewayPort: number; readonly lifecycleGeneration: string; - readonly verifiedEffectivePolicyIdentity: RetainedSandboxVerifiedEffectivePolicyIdentity | null; readonly createAttemptNonce: string; - readonly policyCreationReceipt: RetainedSandboxRecoveryRecord["policyCreationReceipt"]; } function retainedSandboxResourceEvidence(session: Session) { @@ -2031,9 +1935,7 @@ function persistIndependentRetainedSandboxRecovery( gatewayName: context.gatewayName, gatewayPort: context.gatewayPort, lifecycleGeneration: context.lifecycleGeneration, - verifiedEffectivePolicyIdentity: context.verifiedEffectivePolicyIdentity, createAttemptNonce: context.createAttemptNonce, - policyCreationReceipt: context.policyCreationReceipt, resources: retainedSandboxResourceEvidence(session), reason, }); @@ -2061,9 +1963,7 @@ export function listRetainedSandboxRecoveryRecords(): readonly RetainedSandboxRe gatewayName: recovery.gatewayName, gatewayPort: recovery.gatewayPort, lifecycleGeneration: recovery.lifecycleGeneration, - verifiedEffectivePolicyIdentity: recovery.verifiedEffectivePolicyIdentity, createAttemptNonce: recovery.createAttemptNonce, - policyCreationReceipt: recovery.policyCreationReceipt, resources: retainedSandboxResourceEvidence(current), reason: recovery.reason, recordedAt: recovery.recordedAt, @@ -2569,8 +2469,6 @@ export function summarizeForDebug( observabilityRequestedExplicitly: session.observabilityRequestedExplicitly, apfInterceptorRequested: session.apfInterceptorRequested, hermesToolGateways: session.hermesToolGateways, - policyPresets: session.policyPresets, - policyAuthority: session.policyAuthority, gpuPassthrough: session.gpuPassthrough, lastStepStarted: session.lastStepStarted, lastCompletedStep: session.lastCompletedStep, diff --git a/src/lib/state/onboard-session/retained-sandbox-recovery.ts b/src/lib/state/onboard-session/retained-sandbox-recovery.ts index fced883d05b..851eb7f1b01 100644 --- a/src/lib/state/onboard-session/retained-sandbox-recovery.ts +++ b/src/lib/state/onboard-session/retained-sandbox-recovery.ts @@ -6,15 +6,10 @@ import fs from "node:fs"; import path from "node:path"; import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; -import { - parseNemoClawPolicyCreationReceipt, - type NemoClawPolicyCreationReceipt, -} from "../../policy/merge"; - -export { parseNemoClawPolicyCreationReceipt } from "../../policy/merge"; const SCHEMA_VERSION = 1; const FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/u; +const CREATE_ATTEMPT_NONCE_PATTERN = /^[0-9a-f]{62}$/u; const SAFE_EVIDENCE_PATTERN = /^[A-Za-z0-9._:@/-]{1,256}$/u; const NAME_MAX_LENGTH = 63; const NAME_VALID_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u; @@ -33,11 +28,6 @@ export interface RetainedSandboxResourceEvidence { readonly credentialEnvironmentVariables: readonly string[]; } -export interface RetainedSandboxVerifiedEffectivePolicyIdentity { - readonly hash: string; - readonly activeVersion: number; -} - export interface RetainedSandboxRecoveryRecord { readonly schemaVersion: typeof SCHEMA_VERSION; readonly recordId: string; @@ -47,9 +37,7 @@ export interface RetainedSandboxRecoveryRecord { readonly gatewayName: string; readonly gatewayPort: number; readonly lifecycleGeneration: string | null; - readonly verifiedEffectivePolicyIdentity: RetainedSandboxVerifiedEffectivePolicyIdentity | null; readonly createAttemptNonce: string; - readonly policyCreationReceipt: NemoClawPolicyCreationReceipt | null; readonly resources: RetainedSandboxResourceEvidence; readonly reason: RetainedSandboxRecoveryReason; readonly recordedAt: string; @@ -73,9 +61,7 @@ export interface RecordRetainedSandboxRecoveryInput { readonly gatewayName: string; readonly gatewayPort: number; readonly lifecycleGeneration: string | null; - readonly verifiedEffectivePolicyIdentity: RetainedSandboxVerifiedEffectivePolicyIdentity | null; readonly createAttemptNonce: string; - readonly policyCreationReceipt: NemoClawPolicyCreationReceipt | null; readonly resources: RetainedSandboxResourceEvidence; readonly reason: RetainedSandboxRecoveryReason; readonly recordedAt?: string; @@ -349,37 +335,11 @@ function parseEvidence(value: unknown): RetainedSandboxResourceEvidence | null { : null; } -function parseVerifiedEffectivePolicyIdentity( - value: unknown, -): RetainedSandboxVerifiedEffectivePolicyIdentity | null | undefined { - if (value === null || value === undefined) return null; - if ( - !isObjectRecord(value) || - !validSafeEvidence(value.hash) || - !Number.isSafeInteger(value.activeVersion) || - Number(value.activeVersion) < 1 - ) { - return undefined; - } - return { hash: value.hash, activeVersion: Number(value.activeVersion) }; -} - function parseRecord(value: unknown): RetainedSandboxRecoveryRecord | null { if (!isObjectRecord(value)) return null; const resources = parseEvidence(value.resources); const fingerprint = value.sandboxIdentityFingerprint; - const verifiedEffectivePolicyIdentity = parseVerifiedEffectivePolicyIdentity( - value.verifiedEffectivePolicyIdentity, - ); const reason = value.reason; - let policyCreationReceipt: NemoClawPolicyCreationReceipt | null = null; - if (value.policyCreationReceipt !== null) { - try { - policyCreationReceipt = parseNemoClawPolicyCreationReceipt(value.policyCreationReceipt); - } catch { - return null; - } - } if ( value.schemaVersion !== SCHEMA_VERSION || typeof value.recordId !== "string" || @@ -391,17 +351,8 @@ function parseRecord(value: unknown): RetainedSandboxRecoveryRecord | null { !validSafeEvidence(value.gatewayName) || !validGatewayPort(value.gatewayPort) || (value.lifecycleGeneration !== null && !validSafeEvidence(value.lifecycleGeneration)) || - verifiedEffectivePolicyIdentity === undefined || typeof value.createAttemptNonce !== "string" || - !/^[0-9a-f]{62}$/u.test(value.createAttemptNonce) || - (policyCreationReceipt !== null && - (policyCreationReceipt.gatewayName !== value.gatewayName || - policyCreationReceipt.gatewayPort !== value.gatewayPort || - policyCreationReceipt.sandboxName !== value.sandboxName || - policyCreationReceipt.lifecycleGeneration !== value.lifecycleGeneration || - policyCreationReceipt.sandboxIdentityFingerprint !== fingerprint || - policyCreationReceipt.policyHash !== verifiedEffectivePolicyIdentity?.hash || - policyCreationReceipt.policyVersion !== verifiedEffectivePolicyIdentity?.activeVersion)) || + !CREATE_ATTEMPT_NONCE_PATTERN.test(value.createAttemptNonce) || !resources || !["cancelled_after_sandbox_creation", "retained_after_sandbox_creation_failure"].includes( String(reason), @@ -419,9 +370,7 @@ function parseRecord(value: unknown): RetainedSandboxRecoveryRecord | null { gatewayName: value.gatewayName, gatewayPort: value.gatewayPort, lifecycleGeneration: value.lifecycleGeneration, - verifiedEffectivePolicyIdentity, createAttemptNonce: value.createAttemptNonce, - policyCreationReceipt, resources, reason: reason as RetainedSandboxRecoveryReason, recordedAt: value.recordedAt, @@ -452,9 +401,7 @@ function recoveryRecordId(input: RecordRetainedSandboxRecoveryInput): string { input.sandboxName, input.sandboxIdentityFingerprint, input.lifecycleGeneration, - input.verifiedEffectivePolicyIdentity, input.createAttemptNonce, - input.policyCreationReceipt, ]), ) .digest("hex"); @@ -468,31 +415,11 @@ function assertRecordInput(input: RecordRetainedSandboxRecoveryInput): void { !validSafeEvidence(input.gatewayName) || !validGatewayPort(input.gatewayPort) || (input.lifecycleGeneration !== null && !validSafeEvidence(input.lifecycleGeneration)) || - parseVerifiedEffectivePolicyIdentity(input.verifiedEffectivePolicyIdentity) === undefined || - !/^[0-9a-f]{62}$/u.test(input.createAttemptNonce) || + !CREATE_ATTEMPT_NONCE_PATTERN.test(input.createAttemptNonce) || !parseEvidence(input.resources) ) { throw new Error("Cannot persist invalid retained sandbox recovery evidence."); } - if (input.policyCreationReceipt !== null) { - let receipt: NemoClawPolicyCreationReceipt; - try { - receipt = parseNemoClawPolicyCreationReceipt(input.policyCreationReceipt); - } catch { - throw new Error("Cannot persist invalid retained sandbox recovery evidence."); - } - if ( - receipt.gatewayName !== input.gatewayName || - receipt.gatewayPort !== input.gatewayPort || - receipt.sandboxName !== input.sandboxName || - receipt.lifecycleGeneration !== input.lifecycleGeneration || - receipt.sandboxIdentityFingerprint !== input.sandboxIdentityFingerprint || - receipt.policyHash !== input.verifiedEffectivePolicyIdentity?.hash || - receipt.policyVersion !== input.verifiedEffectivePolicyIdentity?.activeVersion - ) { - throw new Error("Cannot persist mismatched retained sandbox recovery evidence."); - } - } } export function listRetainedSandboxRecoveryRecords( @@ -515,13 +442,7 @@ export function recordRetainedSandboxRecovery( gatewayName: input.gatewayName, gatewayPort: input.gatewayPort, lifecycleGeneration: input.lifecycleGeneration, - verifiedEffectivePolicyIdentity: input.verifiedEffectivePolicyIdentity - ? { ...input.verifiedEffectivePolicyIdentity } - : null, createAttemptNonce: input.createAttemptNonce, - policyCreationReceipt: input.policyCreationReceipt - ? parseNemoClawPolicyCreationReceipt(input.policyCreationReceipt) - : null, resources: parseEvidence(input.resources)!, reason: input.reason, recordedAt: input.recordedAt ?? new Date().toISOString(), diff --git a/src/lib/state/registry-mcp.ts b/src/lib/state/registry-mcp.ts index d98d76ce976..db7f11de1c1 100644 --- a/src/lib/state/registry-mcp.ts +++ b/src/lib/state/registry-mcp.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { isObjectRecord } from "../core/json-types"; +import { isIP } from "node:net"; import { isBlockedMcpUrlTargetHost, MCP_SERVER_URL_MAX_LENGTH } from "../security/mcp-url-target"; import { canonicalizeTrustedPrivateEndpointPins, @@ -16,12 +17,7 @@ export interface McpBridgeEntry { env: string[]; /** Exact URL host explicitly admitted for routed private access. */ trustedPrivateHost?: string; - /** - * Immutable validated private address pins recorded when the bridge was - * added. After strict registry normalization, this durable host state is the - * operator-approved replay authority; lifecycle commands never widen it - * from ambient DNS. - */ + /** Validated endpoint pins recorded as MCP domain state for new bridges. */ allowedIps?: string[]; providerName?: string; /** Immutable OpenShell ObjectMeta.id captured after provider creation. */ @@ -175,8 +171,31 @@ function normalizeMcpBridgeEntry(server: string, value: unknown): McpBridgeEntry return null; } allowedIps = [...canonicalPins]; - } else if (rawAllowedIps !== undefined) { - return null; + } else { + // Legacy public bridge rows predate durable public pins. Preserve them so + // explicit restart/rebuild can resolve and write current pins; new bridge + // registrations always persist a non-empty canonical list. + if (rawAllowedIps === undefined) { + allowedIps = undefined; + } else { + if ( + !Array.isArray(rawAllowedIps) || + rawAllowedIps.length === 0 || + rawAllowedIps.some( + (address) => + typeof address !== "string" || + address !== address.toLowerCase() || + address.includes("%") || + isIP(address) === 0 || + isBlockedMcpUrlTargetHost(address), + ) + ) { + return null; + } + const canonical = [...new Set(rawAllowedIps as string[])].sort(); + if (canonical.length !== rawAllowedIps.length) return null; + allowedIps = canonical; + } } const rawEnv = value.env; const env = @@ -209,7 +228,8 @@ function normalizeMcpBridgeEntry(server: string, value: unknown): McpBridgeEntry ...(adapter ? { adapter } : {}), url, env, - ...(trustedPrivateHost ? { trustedPrivateHost, allowedIps } : {}), + ...(trustedPrivateHost ? { trustedPrivateHost } : {}), + ...(allowedIps ? { allowedIps } : {}), ...(providerName ? { providerName } : {}), ...(providerId ? { providerId } : {}), policyName, diff --git a/src/lib/state/registry-normalization.test.ts b/src/lib/state/registry-normalization.test.ts index 7d5b813d5e8..af03bb89136 100644 --- a/src/lib/state/registry-normalization.test.ts +++ b/src/lib/state/registry-normalization.test.ts @@ -1,30 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - normalizeBaselineExclusions, - normalizeBaselineExclusionTransition, - normalizeCustomPolicyEntries, - normalizeSandboxPolicyAuthority, -} from "./registry-normalization"; - const originalHome = process.env.HOME; const temporaryHomes: string[] = []; -async function loadRegistryWith( - sandboxes: Record, - defaultSandbox: unknown = null, -) { - return loadRegistryDocument({ defaultSandbox, sandboxes }); -} - async function loadRegistryDocument(document: unknown) { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-registry-normalization-")); temporaryHomes.push(home); @@ -33,18 +18,22 @@ async function loadRegistryDocument(document: unknown) { fs.writeFileSync(path.join(configDir, "sandboxes.json"), JSON.stringify(document), { mode: 0o600, }); - process.env.HOME = home; vi.resetModules(); - return import("./registry"); + return { home, registry: await import("./registry") }; +} + +async function loadRegistryWith( + sandboxes: Record, + defaultSandbox: unknown = null, +) { + return (await loadRegistryDocument({ defaultSandbox, sandboxes })).registry; } afterEach(() => { process.env.HOME = originalHome; vi.resetModules(); - for (const home of temporaryHomes.splice(0)) { - fs.rmSync(home, { recursive: true, force: true }); - } + for (const home of temporaryHomes.splice(0)) fs.rmSync(home, { recursive: true, force: true }); }); describe("sandbox registry normalization", () => { @@ -68,66 +57,8 @@ describe("sandbox registry normalization", () => { estimatedModelDownloadBytes: null, } as const; - const createPolicyAttribution = () => { - const exclusion = { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-08-20T00:00:00.000Z", - }; - return { - policies: ["weather"], - customPolicies: [{ name: "private-api", content: "network_policies: {}" }], - baselineExclusions: [exclusion], - baselineExclusionTransition: { - id: "123e4567-e89b-42d3-a456-426614174983", - operation: "exclude" as const, - exclusion, - targetLiveDigest: null, - startedAt: "2026-08-20T00:00:01.000Z", - }, - policyPresetsFinalized: true, - }; - }; - - const createManagedPolicyEntry = (name: string, policyVersion = 1) => { - const gatewayName = "nemoclaw"; - const gatewayPort = 8080; - const lifecycleGeneration = "123e4567-e89b-42d3-a456-426614174983"; - const lifecycleLiveIdentityFingerprint = "d".repeat(64); - return { - name, - gatewayName, - gatewayPort, - lifecycleGeneration, - lifecycleLiveIdentityFingerprint, - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt: { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName, - gatewayPort, - sandboxName: name, - lifecycleGeneration, - sandboxIdentityFingerprint: lifecycleLiveIdentityFingerprint, - policyHash: `sha256:policy-${String(policyVersion)}`, - policyVersion, - }, - }; - }; - - it.each([null, [], 42, "invalid"])( - "treats a non-object top-level registry document as empty: %j", - async (document) => { - const registry = await loadRegistryDocument(document); - - expect(registry.listSandboxes()).toEqual({ sandboxes: [], defaultSandbox: null }); - }, - ); - it("drops a malformed sandboxes container at the file boundary", async () => { - const registry = await loadRegistryDocument({ + const { registry } = await loadRegistryDocument({ defaultSandbox: 42, sandboxes: "not-an-object", }); @@ -324,337 +255,95 @@ describe("sandbox registry normalization", () => { expect(() => registry.getSandbox("profile")).toThrow("invalid serving profile provenance"); }); - function expectExternalAttributionCleared(entry: unknown, name: string): void { - expect(entry).toMatchObject({ - name, - policies: [], - policyAuthority: "externally-managed", - }); - expect(entry).not.toHaveProperty("customPolicies"); - expect(entry).not.toHaveProperty("baselineExclusions"); - expect(entry).not.toHaveProperty("baselineExclusionTransition"); - expect(entry).not.toHaveProperty("policyPresetsFinalized"); - expect(entry).not.toHaveProperty("policyTier"); - } + it.each([null, [], 42, "invalid"])( + "treats a non-object registry document as empty: %j", + async (document) => { + const { registry } = await loadRegistryDocument(document); + expect(registry.listSandboxes()).toEqual({ sandboxes: [], defaultSandbox: null }); + }, + ); - it("round-trips receipt-bound authority while removing a legacy managed claim (#9833)", async () => { - const registry = await loadRegistryWith({ - legacy: { name: "legacy" }, - legacyManaged: { - name: "legacyManaged", - policyAuthority: "nemoclaw-managed", - policies: ["weather"], - }, - managed: createManagedPolicyEntry("managed"), - external: { name: "external", policyAuthority: "externally-managed" }, + it("drops malformed sandbox rows", async () => { + const { registry } = await loadRegistryDocument({ + defaultSandbox: "alpha", + sandboxes: { alpha: { name: "different" }, beta: { name: "beta" } }, }); - registry.save(registry.load()); - const persisted = JSON.parse( - fs.readFileSync(path.join(process.env.HOME!, ".nemoclaw", "sandboxes.json"), "utf8"), - ) as { sandboxes: Record> }; - expect(registry.getSandbox("legacy")?.policyAuthority).toBeUndefined(); - expect(registry.getSandbox("legacyManaged")).toMatchObject({ policies: ["weather"] }); - expect(registry.getSandbox("legacyManaged")).not.toHaveProperty("policyAuthority"); - expect(registry.getSandbox("legacyManaged")).not.toHaveProperty("policyCreationReceipt"); - expect(registry.getSandbox("managed")?.policyAuthority).toBe("nemoclaw-managed"); - expect(registry.getSandbox("managed")?.policyCreationReceipt).toEqual( - createManagedPolicyEntry("managed").policyCreationReceipt, - ); - expect(registry.getSandbox("external")?.policyAuthority).toBe("externally-managed"); - expect(persisted.sandboxes.legacy).not.toHaveProperty("policyAuthority"); - expect(persisted.sandboxes.legacyManaged).not.toHaveProperty("policyAuthority"); - expect(persisted.sandboxes.legacyManaged).not.toHaveProperty("policyCreationReceipt"); - expect(persisted.sandboxes.managed?.policyAuthority).toBe("nemoclaw-managed"); - expect(persisted.sandboxes.external?.policyAuthority).toBe("externally-managed"); - }); - - it("round-trips only complete non-authorizing create checkpoints (#9833)", async () => { - const managed = createManagedPolicyEntry("managed-pending"); - const managedCheckpoint = { - schemaVersion: 1 as const, - state: "verified-create" as const, - policyAuthority: "nemoclaw-managed" as const, - observedPolicyAuthority: "owner-unknown" as const, - gatewayName: managed.gatewayName, - gatewayPort: managed.gatewayPort, - sandboxName: managed.name, - lifecycleGeneration: managed.lifecycleGeneration, - sandboxIdentityFingerprint: managed.lifecycleLiveIdentityFingerprint, - route: "none" as const, - policyHash: managed.policyCreationReceipt.policyHash, - policyVersion: managed.policyCreationReceipt.policyVersion, - policyCreationReceipt: managed.policyCreationReceipt, - }; - const externalCheckpoint = { - ...managedCheckpoint, - policyAuthority: "externally-managed" as const, - observedPolicyAuthority: "externally-managed" as const, - sandboxName: "external-pending", - policyHash: "sha256:external", - policyCreationReceipt: undefined, - }; - const registry = await loadRegistryWith({ - "managed-pending": { - name: "managed-pending", - pendingRouteReservation: true, - reservationSessionId: "managed-session", - gatewayName: managed.gatewayName, - gatewayPort: managed.gatewayPort, - lifecycleGeneration: managed.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: managed.lifecycleLiveIdentityFingerprint, - pendingPolicyVerification: managedCheckpoint, - }, - "external-pending": { - name: "external-pending", - pendingRouteReservation: true, - reservationSessionId: "external-session", - gatewayName: managed.gatewayName, - gatewayPort: managed.gatewayPort, - lifecycleGeneration: managed.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: managed.lifecycleLiveIdentityFingerprint, - pendingPolicyVerification: externalCheckpoint, - }, + expect(registry.listSandboxes()).toEqual({ + sandboxes: [expect.objectContaining({ name: "beta" })], + defaultSandbox: "alpha", }); - - expect(registry.getSandbox("managed-pending")?.pendingPolicyVerification).toEqual( - managedCheckpoint, - ); - expect(registry.getSandbox("external-pending")?.pendingPolicyVerification).toEqual( - externalCheckpoint, - ); - expect(registry.getDefault()).toBeNull(); }); - it.each([ - ["ownerless", {}], - ["authorizing", { reservationSessionId: "session", policyAuthority: "externally-managed" }], - ["wrong-lifecycle", { reservationSessionId: "session", lifecycleGeneration: "changed" }], - ])("rejects a %s persisted create checkpoint (#9833)", async (_label, overrides) => { - const managed = createManagedPolicyEntry("pending"); - const checkpoint = { - schemaVersion: 1 as const, - state: "verified-create" as const, - policyAuthority: "nemoclaw-managed" as const, - observedPolicyAuthority: "owner-unknown" as const, - gatewayName: managed.gatewayName, - gatewayPort: managed.gatewayPort, - sandboxName: managed.name, - lifecycleGeneration: managed.lifecycleGeneration, - sandboxIdentityFingerprint: managed.lifecycleLiveIdentityFingerprint, - route: "none" as const, - policyHash: managed.policyCreationReceipt.policyHash, - policyVersion: managed.policyCreationReceipt.policyVersion, - policyCreationReceipt: managed.policyCreationReceipt, + it("removes every legacy policy shadow field without replaying it", async () => { + const legacy = { + name: "alpha", + gatewayName: "nemoclaw", + customPolicies: [{ name: "corp", content: "network_policies: {}" }], + baselineExclusions: [{ key: "npm", digest: "a".repeat(64) }], + baselineExclusionTransition: { operation: "exclude" }, + policyCreationReceipt: { schemaVersion: 1 }, }; - const registry = await loadRegistryWith({ - pending: { - name: "pending", - pendingRouteReservation: true, - gatewayName: managed.gatewayName, - gatewayPort: managed.gatewayPort, - lifecycleGeneration: managed.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: managed.lifecycleLiveIdentityFingerprint, - pendingPolicyVerification: checkpoint, - ...overrides, - }, - }); - - expect(() => registry.getSandbox("pending")).toThrow(/pending policy verification/u); - }); - - it("clears NemoClaw policy attribution from externally managed rows (#9833)", async () => { - const attribution = createPolicyAttribution(); - const registry = await loadRegistryWith({ - legacy: { name: "legacy", ...attribution, policyTier: "strict" }, - managed: { - ...createManagedPolicyEntry("managed"), - ...attribution, - policyTier: "strict", - }, - external: { - name: "external", - ...attribution, - policyAuthority: "externally-managed", - policyTier: "strict", - }, - }); - - expect(registry.getSandbox("legacy")).toMatchObject(attribution); - expect(registry.getSandbox("managed")).toMatchObject(attribution); - expectExternalAttributionCleared(registry.getSandbox("external"), "external"); - }); - - it.each([null, "sandbox", {}])( - "fails closed on malformed persisted policy authority %j (#9833)", - async (policyAuthority) => { - const registry = await loadRegistryWith({ - alpha: { name: "alpha", policyAuthority }, - }); - - expect(() => registry.getSandbox("alpha")).toThrow(/invalid policy authority/i); - }, - ); - - it("does not backfill NemoClaw ownership and leaves external authority available (#9833)", async () => { - const registry = await loadRegistryWith({ - legacy: { name: "legacy", policyAuthority: "nemoclaw-managed" }, + const { home, registry } = await loadRegistryDocument({ + defaultSandbox: "alpha", + sandboxes: { alpha: legacy }, }); - expect(() => registry.updateSandbox("legacy", { policyAuthority: "global" as never })).toThrow( - /invalid policy authority/i, - ); - expect(() => registry.updateSandbox("legacy", { policyAuthority: "nemoclaw-managed" })).toThrow( - /outside completed sandbox registration/u, - ); - expect(registry.updateSandbox("legacy", { policyAuthority: "externally-managed" })).toBe(true); - expect(registry.getSandbox("legacy")?.policyAuthority).toBe("externally-managed"); - expect(() => registry.registerSandbox(createManagedPolicyEntry("legacy"))).toThrow( - /policy authority changed/u, + const sandbox = registry.getSandbox("alpha") as unknown as Record; + expect(Object.keys(sandbox)).not.toEqual( + expect.arrayContaining([ + "policies", + "customPolicies", + "baselineExclusions", + "baselineExclusionTransition", + "policyAuthority", + "policyCreationReceipt", + "policyPresetsFinalized", + "policyTier", + ]), ); - }); - - it("publishes and rotates only an exact completed policy creation receipt (#9833)", async () => { - const registry = await loadRegistryWith({}); - const managed = createManagedPolicyEntry("managed"); - const registered = registry.registerSandbox(managed); - const replacement = createManagedPolicyEntry("managed", 2).policyCreationReceipt; - expect(registered.policyCreationReceipt).toEqual(managed.policyCreationReceipt); - expect(() => registry.updateSandbox("managed", { policyCreationReceipt: replacement })).toThrow( - /outside the receipt rotation transaction/u, + registry.updateSandbox("alpha", { gatewayName: "nemoclaw" }); + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), ); - expect( - registry.compareAndSetSandboxPolicyCreationReceipt( - "managed", - { ...managed.policyCreationReceipt, policyVersion: 9 }, - replacement, - ), - ).toBe(false); - expect( - registry.compareAndSetSandboxPolicyCreationReceipt( - "managed", - managed.policyCreationReceipt, - replacement, - ), - ).toBe(true); - expect(registry.getSandbox("managed")?.policyCreationReceipt).toEqual(replacement); - expect(() => - registry.compareAndSetSandboxPolicyCreationReceipt("managed", replacement, { - ...replacement, - lifecycleGeneration: "223e4567-e89b-42d3-a456-426614174983", - }), - ).toThrow(/gateway or sandbox identity/u); + expect(persisted.sandboxes.alpha).toEqual(sandbox); }); - it("rejects partial, mismatched, and pending policy creation receipts (#9833)", async () => { - const managed = createManagedPolicyEntry("managed"); - const malformedRegistry = await loadRegistryWith({ - malformed: { - ...createManagedPolicyEntry("malformed"), - policyCreationReceipt: { schemaVersion: 1 }, - }, - }); - expect(() => malformedRegistry.getSandbox("malformed")).toThrow( - /invalid policy creation receipt/u, - ); - - const mismatchedRegistry = await loadRegistryWith({ - managed: { - ...managed, - gatewayPort: 9090, - }, - }); - expect(() => mismatchedRegistry.getSandbox("managed")).toThrow( - /does not match its gateway and sandbox identity/u, - ); - - const registry = await loadRegistryWith({}); - expect(() => registry.registerSandbox(managed, undefined, { pending: true })).toThrow( - /pending sandbox registration/u, - ); - expect(() => - registry.registerSandbox({ name: "managed", policyAuthority: "nemoclaw-managed" }), - ).toThrow(/without a complete policy creation receipt/u); - - registry.registerSandbox(managed); - registry.reserveSandboxInferenceRoute("managed", { - provider: "compatible-endpoint", - model: "model-a", - endpointUrl: "https://api.example.test/v1", - credentialEnv: "CUSTOM_API_KEY", - preferredInferenceApi: "openai-responses", - gatewayName: "nemoclaw", - }); - expect(registry.getSandbox("managed")).toMatchObject({ - pendingRouteReservation: true, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: managed.policyCreationReceipt, - }); - registry.restoreSandboxEntry(managed); - expect(registry.getSandbox("managed")).toEqual(managed); - - const replacementSelection = { - provider: "compatible-endpoint", - model: "model-b", - endpointUrl: "https://api.example.test/v1", - endpointSource: null, - credentialEnv: "CUSTOM_API_KEY", - preferredInferenceApi: "openai-responses", - compatibleEndpointReasoning: null, - compatibleEndpointReasoningEffort: null, - nimContainer: null, - gatewayName: "nemoclaw", - reservationSessionId: "session-owner", - } as const; - registry.reserveSandboxInferenceRoute("managed", replacementSelection); - const replacementLifecycle = { - ...createManagedPolicyEntry("managed", 2), - ...replacementSelection, - lifecycleGeneration: "223e4567-e89b-42d3-a456-426614174983", - lifecycleLiveIdentityFingerprint: "e".repeat(64), - }; - replacementLifecycle.policyCreationReceipt = { - ...replacementLifecycle.policyCreationReceipt, - lifecycleGeneration: replacementLifecycle.lifecycleGeneration, - sandboxIdentityFingerprint: replacementLifecycle.lifecycleLiveIdentityFingerprint, - }; - const createReservation = registry.qualifyPendingSandboxCreateReservation( - { - sandboxName: "managed", - gatewayName: "nemoclaw", - sessionId: "session-owner", - selection: replacementSelection, - }, - registry.getSandbox("managed"), - ); + it("retains only the bounded generic create checkpoint", async () => { const checkpoint = { schemaVersion: 1 as const, state: "verified-create" as const, - policyAuthority: "nemoclaw-managed" as const, - observedPolicyAuthority: "owner-unknown" as const, gatewayName: "nemoclaw", gatewayPort: 8080, - sandboxName: "managed", - lifecycleGeneration: replacementLifecycle.lifecycleGeneration, - sandboxIdentityFingerprint: replacementLifecycle.lifecycleLiveIdentityFingerprint, + sandboxName: "alpha", + lifecycleGeneration: "generation", + sandboxIdentityFingerprint: "a".repeat(64), route: "none" as const, - policyHash: replacementLifecycle.policyCreationReceipt.policyHash, - policyVersion: replacementLifecycle.policyCreationReceipt.policyVersion, - policyCreationReceipt: replacementLifecycle.policyCreationReceipt, + policyHash: "legacy", }; - registry.recordPendingSandboxPolicyVerification(createReservation, checkpoint); - expect( - registry.registerSandbox(replacementLifecycle, undefined, { - verifiedCreate: { reservation: createReservation, checkpoint }, - }), - ).toMatchObject({ - lifecycleGeneration: replacementLifecycle.lifecycleGeneration, - lifecycleLiveIdentityFingerprint: replacementLifecycle.lifecycleLiveIdentityFingerprint, - policyCreationReceipt: replacementLifecycle.policyCreationReceipt, + const { registry } = await loadRegistryDocument({ + defaultSandbox: null, + sandboxes: { + alpha: { + name: "alpha", + pendingRouteReservation: true, + pendingCreateIdentity: checkpoint, + }, + }, + }); + expect(registry.getSandbox("alpha")?.pendingCreateIdentity).toEqual({ + schemaVersion: 1, + state: "verified-create", + gatewayName: "nemoclaw", + gatewayPort: 8080, + sandboxName: "alpha", + lifecycleGeneration: "generation", + sandboxIdentityFingerprint: "a".repeat(64), + route: "none", }); }); - it("sets a gateway port only while the complete qualified row remains current (#10056)", async () => { + it("sets a gateway port only while the complete qualified row remains current", async () => { const registry = await loadRegistryWith({ alpha: { name: "alpha", @@ -675,413 +364,4 @@ describe("sandbox registry normalization", () => { expect(registry.compareAndSetSandboxGatewayPort("alpha", replacement, 8080)).toBe(true); expect(registry.getSandbox("alpha")).toEqual({ ...replacement, gatewayPort: 8080 }); }); - - it("canonicalizes external attribution across registry mutations and recovery (#9833)", async () => { - const registry = await loadRegistryWith({ - updated: { name: "updated", ...createPolicyAttribution(), policyTier: "strict" }, - }); - const externalEntry = (name: string) => ({ - name, - ...createPolicyAttribution(), - policyAuthority: "externally-managed" as const, - policyTier: "strict", - }); - - expectExternalAttributionCleared( - registry.registerSandbox(externalEntry("registered")), - "registered", - ); - expect(registry.updateSandbox("updated", { policyAuthority: "externally-managed" })).toBe(true); - expectExternalAttributionCleared(registry.getSandbox("updated"), "updated"); - - registry.restoreSandboxEntry(externalEntry("recovered")); - expectExternalAttributionCleared(registry.getSandbox("recovered"), "recovered"); - const receipt = registry.removeSandboxWithReceipt("recovered")!; - expect( - registry.restoreSandboxEntryIfMissing({ ...receipt, entry: externalEntry("recovered") }), - ).toBe(true); - expectExternalAttributionCleared(registry.getSandbox("recovered"), "recovered"); - }); - - it("preserves a replacement row when recovery has a different policy authority (#9833)", async () => { - const registry = await loadRegistryWith({ - alpha: { - name: "alpha", - model: "current", - policyAuthority: "externally-managed", - }, - }); - - expect(() => - registry.restoreSandboxEntry({ - name: "alpha", - model: "recovered", - policyAuthority: "nemoclaw-managed", - }), - ).toThrow(/policy authority changed during recovery/u); - expect(registry.getSandbox("alpha")).toMatchObject({ - model: "current", - policyAuthority: "externally-managed", - }); - }); -}); - -describe("sandbox policy authority normalization", () => { - it.each([ - [undefined, undefined], - ["nemoclaw-managed", "nemoclaw-managed"], - ["externally-managed", "externally-managed"], - ])("normalizes known policy authority %j (#9833)", (input, expected) => { - expect(normalizeSandboxPolicyAuthority(input)).toBe(expected); - }); - - it.each(["sandbox", null, {}])("rejects invalid policy authority %j (#9833)", (input) => { - expect(() => normalizeSandboxPolicyAuthority(input)).toThrow(/invalid policy authority/i); - }); -}); - -describe("custom policy pin receipt normalization (#8176)", () => { - const content = `network_policies: - private-api: - endpoints: - - host: api.corp.example - allowed_ips: [10.20.30.40] -`; - const receipt = { - version: 1 as const, - contentDigest: createHash("sha256").update(content).digest("hex"), - }; - - it("keeps exact generated-pin authority bound to custom policy content", () => { - expect( - normalizeCustomPolicyEntries([ - { - name: "private-api", - content, - sourcePath: "/tmp/private-api.yaml", - trustedPrivatePins: receipt, - }, - ]), - ).toEqual([ - { - name: "private-api", - content, - sourcePath: "/tmp/private-api.yaml", - trustedPrivatePins: receipt, - }, - ]); - }); - - it("fails closed when persisted pin authority does not match exact content", () => { - expect(() => - normalizeCustomPolicyEntries([ - { - name: "private-api", - content: `${content}\n# changed`, - trustedPrivatePins: receipt, - }, - ]), - ).toThrow(/invalid trusted-private pin authority.*before rebuilding/i); - expect(() => - normalizeCustomPolicyEntries([ - { - name: "private-api", - content, - trustedPrivatePins: { contentDigest: receipt.contentDigest }, - }, - ]), - ).toThrow(/invalid trusted-private pin authority.*before rebuilding/i); - }); -}); - -describe("baseline exclusion normalization (#7178)", () => { - const digest = "a".repeat(64); - const entry = { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest, - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }; - - it("keeps an exact versioned, agent-bound entry", () => { - expect( - normalizeBaselineExclusions([ - { - ...entry, - appliedAgentVersion: "1", - }, - ]), - ).toEqual([{ ...entry, appliedAgentVersion: "1" }]); - }); - - it("preserves an explicitly unknown applied agent version", () => { - expect(normalizeBaselineExclusions([{ ...entry, appliedAgentVersion: null }])).toEqual([ - { ...entry, appliedAgentVersion: null }, - ]); - }); - - it("fails closed when any persisted record is malformed", () => { - expect(() => normalizeBaselineExclusions([entry, { ...entry, key: "" }])).toThrow( - /invalid versioned baseline exclusion.*before rebuilding/i, - ); - expect(() => normalizeBaselineExclusions(["not-an-object"])).toThrow( - /malformed baseline exclusion.*before rebuilding/i, - ); - }); - - it("rejects an unversioned record so its baseline source is never guessed (#7194)", () => { - expect(() => normalizeBaselineExclusions([{ key: entry.key, digest }])).toThrow( - /invalid versioned baseline exclusion.*before rebuilding/i, - ); - }); - - it("collapses duplicate keys, last wins", () => { - expect( - normalizeBaselineExclusions([ - { ...entry, key: "dup", digest: "b".repeat(64) }, - { ...entry, key: "dup", digest: "c".repeat(64) }, - ]), - ).toEqual([{ ...entry, key: "dup", digest: "c".repeat(64) }]); - }); - - it("returns undefined only for a legacy registry without the field", () => { - expect(normalizeBaselineExclusions(undefined)).toBeUndefined(); - expect(normalizeBaselineExclusions([])).toBeUndefined(); - expect(() => normalizeBaselineExclusions("nope")).toThrow(/must be an array/i); - expect(() => normalizeBaselineExclusions([{ ...entry, key: "" }])).toThrow( - /invalid versioned baseline exclusion/i, - ); - }); -}); - -describe("baseline exclusion transition normalization (#7178)", () => { - const sourceDigest = "a".repeat(64); - const targetDigest = "b".repeat(64); - const restoreTransition = { - id: "123e4567-e89b-42d3-a456-426614174000", - operation: "restore" as const, - exclusion: { version: 1 as const, agent: "hermes", key: "nous_research", digest: sourceDigest }, - targetLiveDigest: targetDigest, - startedAt: "2026-07-19T00:00:00.000Z", - }; - - it("preserves an exact well-formed journal", () => { - expect(normalizeBaselineExclusionTransition(restoreTransition)).toEqual(restoreTransition); - expect(normalizeBaselineExclusionTransition(undefined)).toBeUndefined(); - }); - - it("fails closed for partial operations or invalid live targets", () => { - expect(() => - normalizeBaselineExclusionTransition({ ...restoreTransition, operation: "unknown" }), - ).toThrow(/incomplete baseline exclusion transition.*before rebuilding/i); - expect(() => - normalizeBaselineExclusionTransition({ ...restoreTransition, targetLiveDigest: null }), - ).toThrow(/invalid live target.*before rebuilding/i); - expect(() => - normalizeBaselineExclusionTransition({ - ...restoreTransition, - operation: "exclude", - targetLiveDigest: "must-be-absent", - }), - ).toThrow(/invalid live target.*before rebuilding/i); - }); - - it.each([ - ["non-UUID id", { id: "tx-1" }], - ["non-canonical timestamp", { startedAt: "yesterday" }], - [ - "unsafe key", - { exclusion: { version: 1, agent: "hermes", key: "bad key\nnext", digest: sourceDigest } }, - ], - [ - "non-SHA source digest", - { exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: "short" } }, - ], - ["non-SHA target digest", { targetLiveDigest: "short" }], - ])("rejects a journal with %s (#7178)", (_label, override) => { - expect(() => - normalizeBaselineExclusionTransition({ ...restoreTransition, ...override }), - ).toThrow( - /(?:baseline exclusion transition|invalid versioned baseline exclusion).*before rebuilding/i, - ); - }); -}); - -describe("baseline exclusion registry helpers (#7178)", () => { - it("round-trips add, get, and remove keyed by baseline entry", async () => { - const registry = await loadRegistryWith({}); - registry.registerSandbox({ name: "alpha", agent: "hermes" }); - - expect(registry.getBaselineExclusions("alpha")).toEqual([]); - - expect( - registry.addBaselineExclusion("alpha", { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "d".repeat(64), - appliedAgentVersion: null, - }), - ).toBe(true); - const stored = registry.getBaselineExclusions("alpha"); - expect(stored).toHaveLength(1); - expect(stored[0]).toMatchObject({ - key: "nous_research", - digest: "d".repeat(64), - appliedAgentVersion: null, - }); - expect(typeof stored[0].acknowledgedAt).toBe("string"); - - expect(registry.removeBaselineExclusion("alpha", "nous_research")).toBe(true); - expect(registry.getBaselineExclusions("alpha")).toEqual([]); - expect(registry.removeBaselineExclusion("alpha", "nous_research")).toBe(false); - }); - - it("keeps exclusions independent from a same-named custom preset", async () => { - const registry = await loadRegistryWith({}); - registry.registerSandbox({ name: "alpha", agent: "hermes" }); - - registry.addCustomPolicy("alpha", { name: "brave", content: "version: 1\n" }); - registry.addBaselineExclusion("alpha", { - version: 1, - agent: "hermes", - key: "brave", - digest: "d".repeat(64), - }); - - expect(registry.getCustomPolicies("alpha").map((p) => p.name)).toEqual(["brave"]); - expect(registry.getBaselineExclusions("alpha").map((e) => e.key)).toEqual(["brave"]); - - registry.removeBaselineExclusion("alpha", "brave"); - expect(registry.getCustomPolicies("alpha").map((p) => p.name)).toEqual(["brave"]); - expect(registry.getBaselineExclusions("alpha")).toEqual([]); - }); - - it("journals and atomically commits an exclude or restore transition", async () => { - const registry = await loadRegistryWith({}); - registry.registerSandbox({ name: "alpha", agent: "hermes" }); - const exclude = { - id: "123e4567-e89b-42d3-a456-426614174001", - operation: "exclude" as const, - exclusion: { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:01.000Z", - }; - - expect(registry.beginBaselineExclusionTransition("alpha", exclude)).toBe(true); - expect( - registry.beginBaselineExclusionTransition("alpha", { - ...exclude, - id: "123e4567-e89b-42d3-a456-426614174002", - }), - ).toBe(false); - expect(registry.getBaselineExclusionTransition("alpha")).toEqual(exclude); - expect(registry.getBaselineExclusions("alpha")).toEqual([]); - expect(registry.commitBaselineExclusionTransition("alpha", "wrong-id")).toBe(false); - expect(registry.commitBaselineExclusionTransition("alpha", exclude.id)).toBe(true); - expect(registry.getBaselineExclusionTransition("alpha")).toBeNull(); - expect(registry.getBaselineExclusions("alpha")).toEqual([exclude.exclusion]); - - const restore = { - id: "123e4567-e89b-42d3-a456-426614174003", - operation: "restore" as const, - exclusion: exclude.exclusion, - targetLiveDigest: "b".repeat(64), - startedAt: "2026-07-19T00:00:02.000Z", - }; - expect(registry.beginBaselineExclusionTransition("alpha", restore)).toBe(true); - expect(registry.commitBaselineExclusionTransition("alpha", restore.id)).toBe(true); - expect(registry.getBaselineExclusions("alpha")).toEqual([]); - expect(registry.getBaselineExclusionTransition("alpha")).toBeNull(); - }); - - it("clears only the exact journal without changing committed exclusions", async () => { - const registry = await loadRegistryWith({}); - registry.registerSandbox({ - name: "alpha", - baselineExclusions: [ - { version: 1, agent: "hermes", key: "nous_research", digest: "d".repeat(64) }, - ], - }); - const transition = { - id: "123e4567-e89b-42d3-a456-426614174004", - operation: "restore" as const, - exclusion: { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - }, - targetLiveDigest: "b".repeat(64), - startedAt: "2026-07-19T00:00:02.000Z", - }; - expect(registry.beginBaselineExclusionTransition("alpha", transition)).toBe(true); - expect( - registry.addBaselineExclusion("alpha", { - version: 1, - agent: "hermes", - key: "other", - digest: "e".repeat(64), - }), - ).toBe(false); - expect(registry.removeBaselineExclusion("alpha", "nous_research")).toBe(false); - expect(registry.clearBaselineExclusionTransition("alpha", "wrong-id")).toBe(false); - expect(registry.clearBaselineExclusionTransition("alpha", transition.id)).toBe(true); - expect(registry.getBaselineExclusions("alpha")).toEqual([ - expect.objectContaining({ key: "nous_research", digest: "d".repeat(64) }), - ]); - }); - - it("preserves a restore journal when the committed exclusion changed (#7178)", async () => { - const source = { - version: 1 as const, - agent: "hermes", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }; - const registry = await loadRegistryWith({}); - registry.registerSandbox({ name: "alpha", baselineExclusions: [source] }); - const transition = { - id: "123e4567-e89b-42d3-a456-426614174005", - operation: "restore" as const, - exclusion: source, - targetLiveDigest: "b".repeat(64), - startedAt: "2026-07-19T00:00:01.000Z", - }; - expect(registry.beginBaselineExclusionTransition("alpha", transition)).toBe(true); - - const document = registry.load(); - document.sandboxes.alpha.baselineExclusions = [{ ...source, digest: "c".repeat(64) }]; - registry.save(document); - - expect(registry.commitBaselineExclusionTransition("alpha", transition.id)).toBe(false); - expect(registry.getBaselineExclusionTransition("alpha")).toEqual(transition); - expect(registry.getBaselineExclusions("alpha")).toEqual([ - { ...source, digest: "c".repeat(64) }, - ]); - }); - - it("refuses to load mixed valid and malformed persisted exclusions", async () => { - const registry = await loadRegistryWith({ - alpha: { - name: "alpha", - baselineExclusions: [ - { version: 1, agent: "hermes", key: "good", digest: "d".repeat(64) }, - { version: 1, agent: "hermes", key: "", digest: "e".repeat(64) }, - ], - }, - }); - - expect(() => registry.listSandboxes()).toThrow( - /invalid versioned baseline exclusion.*before rebuilding/i, - ); - }); }); diff --git a/src/lib/state/registry-normalization.ts b/src/lib/state/registry-normalization.ts index f99e6b8a49c..b2485d28737 100644 --- a/src/lib/state/registry-normalization.ts +++ b/src/lib/state/registry-normalization.ts @@ -2,303 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 import { isObjectRecord } from "../core/json-types"; -import { - parseNemoClawPolicyCreationReceipt, - type NemoClawPolicyCreationReceipt, -} from "../policy/merge"; -import { normalizeTrustedPrivatePolicyPinReceipt } from "../policy/trusted-private-endpoints"; -import type { - BaselineExclusionEntry, - BaselineExclusionTransition, - CustomPolicyEntry, - RecordedSandboxPolicyAuthority, - SandboxEntry, -} from "./registry/types"; -import { normalizePendingSandboxPolicyVerification } from "./registry/pending-policy-verification"; - -export { normalizePendingSandboxPolicyVerification }; - -const BASELINE_TRANSITION_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const BASELINE_TRANSITION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; -const SHA256_DIGEST_PATTERN = /^[a-f0-9]{64}$/; -const RESERVATION_SESSION_CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u; - -/** Keep legacy absence unknown and reject every unrecognized authority value. */ -export function normalizeSandboxPolicyAuthority( - value: unknown, -): RecordedSandboxPolicyAuthority | undefined { - if (value === undefined) return undefined; - if (value === "nemoclaw-managed" || value === "externally-managed") return value; - throw new Error( - "Sandbox registry contains an invalid policy authority; repair the registry before continuing", - ); -} - -/** Clone one complete policy-creation receipt and reject every partial form. */ -export function cloneSandboxPolicyCreationReceipt( - value: unknown, -): NemoClawPolicyCreationReceipt | undefined { - if (value === undefined) return undefined; - try { - return parseNemoClawPolicyCreationReceipt(value); - } catch { - throw new Error( - "Sandbox registry contains an invalid policy creation receipt; repair the registry before continuing", - ); - } -} - -/** Remove policy attribution that an external authority owns and normalize managed state. */ +import type { SandboxEntry } from "./registry/types"; +import { normalizePendingSandboxCreateIdentity } from "./registry/pending-create-identity"; + +export { normalizePendingSandboxCreateIdentity }; + +const POLICY_SHADOW_FIELDS = [ + "baselineExclusions", + "baselineExclusionTransition", + "customPolicies", + "policies", + "policyAuthority", + "policyCreationReceipt", + "policyPresetsFinalized", + "policyTier", +] as const; + +/** Remove legacy policy shadow state without interpreting or replaying it. */ export function normalizeSandboxPolicyAttribution(entry: SandboxEntry): SandboxEntry { - const requestedPolicyAuthority = normalizeSandboxPolicyAuthority(entry.policyAuthority); - const parsedPolicyCreationReceipt = cloneSandboxPolicyCreationReceipt( - entry.policyCreationReceipt, - ); - if ( - parsedPolicyCreationReceipt && - (parsedPolicyCreationReceipt.sandboxName !== entry.name || - parsedPolicyCreationReceipt.gatewayName !== entry.gatewayName || - parsedPolicyCreationReceipt.gatewayPort !== entry.gatewayPort || - parsedPolicyCreationReceipt.lifecycleGeneration !== entry.lifecycleGeneration || - parsedPolicyCreationReceipt.sandboxIdentityFingerprint !== - entry.lifecycleLiveIdentityFingerprint) - ) { - throw new Error( - "Sandbox registry policy creation receipt does not match its gateway and sandbox identity", - ); - } - const hasManagedReceipt = - requestedPolicyAuthority === "nemoclaw-managed" && parsedPolicyCreationReceipt !== undefined; - const policyAuthority = - requestedPolicyAuthority === "nemoclaw-managed" && !hasManagedReceipt - ? undefined - : requestedPolicyAuthority; - const policyCreationReceipt = hasManagedReceipt ? parsedPolicyCreationReceipt : undefined; - const pendingPolicyVerification = normalizePendingSandboxPolicyVerification( - entry.pendingPolicyVerification, - ); - if ( - pendingPolicyVerification && - (entry.pendingRouteReservation !== true || - typeof entry.reservationSessionId !== "string" || - entry.reservationSessionId.length === 0 || - entry.reservationSessionId.length > 256 || - RESERVATION_SESSION_CONTROL_CHARACTER.test(entry.reservationSessionId) || - requestedPolicyAuthority !== undefined || - parsedPolicyCreationReceipt !== undefined || - pendingPolicyVerification.sandboxName !== entry.name || - pendingPolicyVerification.gatewayName !== entry.gatewayName || - pendingPolicyVerification.gatewayPort !== entry.gatewayPort || - pendingPolicyVerification.lifecycleGeneration !== entry.lifecycleGeneration || - pendingPolicyVerification.sandboxIdentityFingerprint !== - entry.lifecycleLiveIdentityFingerprint) - ) { - throw new Error( - "Sandbox registry pending policy verification does not match its route reservation", - ); - } - const { - policies: _policies, - customPolicies: _customPolicies, - baselineExclusions: _baselineExclusions, - baselineExclusionTransition: _baselineExclusionTransition, - policyPresetsFinalized: _policyPresetsFinalized, - policyTier: _policyTier, - policyAuthority: _policyAuthority, - policyCreationReceipt: _policyCreationReceipt, - pendingPolicyVerification: _pendingPolicyVerification, - ...rest - } = entry; - if (policyAuthority === "externally-managed") { - return { - ...rest, - policies: [], - policyAuthority, - ...(pendingPolicyVerification ? { pendingPolicyVerification } : {}), - }; - } - - const baselineExclusions = normalizeBaselineExclusions(entry.baselineExclusions); - const baselineExclusionTransition = normalizeBaselineExclusionTransition( - entry.baselineExclusionTransition, - ); - const customPolicies = normalizeCustomPolicyEntries(entry.customPolicies); - return { - ...rest, - ...(entry.policies !== undefined ? { policies: entry.policies } : {}), - ...(customPolicies ? { customPolicies } : {}), - ...(baselineExclusions ? { baselineExclusions } : {}), - ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), - ...(entry.policyPresetsFinalized !== undefined - ? { policyPresetsFinalized: entry.policyPresetsFinalized } - : {}), - ...(entry.policyTier !== undefined ? { policyTier: entry.policyTier } : {}), - ...(policyAuthority !== undefined ? { policyAuthority } : {}), - ...(policyCreationReceipt ? { policyCreationReceipt } : {}), - ...(pendingPolicyVerification ? { pendingPolicyVerification } : {}), - }; -} - -/** Normalize persisted custom policy content and its generated-pin authority. */ -export function normalizeCustomPolicyEntries(value: unknown): CustomPolicyEntry[] | undefined { - if (value === undefined) return undefined; - if (!Array.isArray(value)) { - throw new Error( - "Sandbox registry customPolicies must be an array; repair the registry before rebuilding", - ); - } - const entries: CustomPolicyEntry[] = []; - for (const item of value) { - if ( - !isObjectRecord(item) || - typeof item.name !== "string" || - item.name.trim().length === 0 || - typeof item.content !== "string" || - (item.pendingContent !== undefined && typeof item.pendingContent !== "string") || - (item.sourcePath !== undefined && typeof item.sourcePath !== "string") || - (item.appliedAt !== undefined && typeof item.appliedAt !== "string") - ) { - throw new Error( - "Sandbox registry contains a malformed custom policy; repair the registry before rebuilding", - ); - } - let trustedPrivatePins; - try { - trustedPrivatePins = normalizeTrustedPrivatePolicyPinReceipt( - item.content, - item.trustedPrivatePins, - ); - } catch { - throw new Error( - `Sandbox registry custom policy '${item.name}' has invalid trusted-private pin authority; repair the registry before rebuilding`, - ); - } - entries.push({ - name: item.name, - content: item.content, - ...(item.pendingContent !== undefined ? { pendingContent: item.pendingContent } : {}), - ...(item.sourcePath !== undefined ? { sourcePath: item.sourcePath } : {}), - ...(item.appliedAt !== undefined ? { appliedAt: item.appliedAt } : {}), - ...(trustedPrivatePins ? { trustedPrivatePins } : {}), - }); - } - return entries.length > 0 ? entries : undefined; -} - -function isCanonicalIsoTimestamp(value: string): boolean { - const parsed = new Date(value); - return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value; -} - -function normalizeBaselineExclusionEntry(item: unknown): BaselineExclusionEntry { - if (!isObjectRecord(item)) { - throw new Error( - "Sandbox registry contains a malformed baseline exclusion; repair the registry before rebuilding", - ); - } - const version = item.version; - const agent = typeof item.agent === "string" ? item.agent.trim() : ""; - const key = typeof item.key === "string" ? item.key.trim() : ""; - const digest = typeof item.digest === "string" ? item.digest.trim() : ""; - const acknowledgedAt = - typeof item.acknowledgedAt === "string" ? item.acknowledgedAt.trim() : item.acknowledgedAt; - if ( - version !== 1 || - !BASELINE_TRANSITION_KEY_PATTERN.test(agent) || - !BASELINE_TRANSITION_KEY_PATTERN.test(key) || - !SHA256_DIGEST_PATTERN.test(digest) || - (acknowledgedAt !== undefined && - (typeof acknowledgedAt !== "string" || !isCanonicalIsoTimestamp(acknowledgedAt))) - ) { - throw new Error( - "Sandbox registry contains an invalid versioned baseline exclusion; repair the registry before rebuilding", - ); - } - const entry: BaselineExclusionEntry = { version, agent, key, digest }; - if (typeof acknowledgedAt === "string") entry.acknowledgedAt = acknowledgedAt; - if (item.appliedAgentVersion === null) { - entry.appliedAgentVersion = null; - } else if (typeof item.appliedAgentVersion === "string") { - entry.appliedAgentVersion = item.appliedAgentVersion; - } else if (item.appliedAgentVersion !== undefined) { - throw new Error( - `Sandbox registry baseline exclusion '${key}' has an invalid agent version; repair the registry before rebuilding`, - ); - } - return entry; -} - -/** - * Coerce a persisted `baselineExclusions` value into well-formed entries. - * A legacy registry without the field yields `undefined`, while malformed - * exclusion state fails closed so rebuild cannot silently restore egress that - * the operator intended to remove. - */ -export function normalizeBaselineExclusions(value: unknown): BaselineExclusionEntry[] | undefined { - if (value === undefined) return undefined; - if (!Array.isArray(value)) { - throw new Error( - "Sandbox registry baselineExclusions must be an array; repair the registry before rebuilding", - ); - } - const byKey = new Map(); - for (const item of value) { - const entry = normalizeBaselineExclusionEntry(item); - const { key } = entry; - byKey.set(key, entry); - } - return byKey.size > 0 ? [...byKey.values()] : undefined; -} - -/** Normalize the crash-recovery journal, rejecting partial or forged states. */ -export function normalizeBaselineExclusionTransition( - value: unknown, -): BaselineExclusionTransition | undefined { - if (value === undefined) return undefined; - if (!isObjectRecord(value)) { - throw new Error( - "Sandbox registry contains a malformed baseline exclusion transition; repair the registry before rebuilding", - ); - } - const id = typeof value.id === "string" ? value.id.trim() : ""; - const operation = value.operation; - const startedAt = typeof value.startedAt === "string" ? value.startedAt.trim() : ""; - if ( - !BASELINE_TRANSITION_ID_PATTERN.test(id) || - (operation !== "exclude" && operation !== "restore") || - !isCanonicalIsoTimestamp(startedAt) - ) { - throw new Error( - "Sandbox registry contains an incomplete baseline exclusion transition; repair the registry before rebuilding", - ); - } - const exclusion = normalizeBaselineExclusionEntry(value.exclusion); - if ( - !BASELINE_TRANSITION_KEY_PATTERN.test(exclusion.key) || - !SHA256_DIGEST_PATTERN.test(exclusion.digest) || - (exclusion.acknowledgedAt !== undefined && !isCanonicalIsoTimestamp(exclusion.acknowledgedAt)) - ) { - throw new Error( - "Sandbox registry contains an invalid baseline exclusion transition source; repair the registry before rebuilding", - ); - } - const targetLiveDigest = - value.targetLiveDigest === null - ? null - : typeof value.targetLiveDigest === "string" - ? value.targetLiveDigest.trim() - : ""; - if ( - (operation === "exclude" && targetLiveDigest !== null) || - (operation === "restore" && - (targetLiveDigest === null || !SHA256_DIGEST_PATTERN.test(targetLiveDigest))) - ) { - throw new Error( - `Sandbox registry baseline exclusion transition '${exclusion.key}' has an invalid live target; repair the registry before rebuilding`, + const result = { ...entry } as SandboxEntry & Record; + for (const field of POLICY_SHADOW_FIELDS) delete result[field]; + if (result.pendingCreateIdentity !== undefined) { + result.pendingCreateIdentity = normalizePendingSandboxCreateIdentity( + result.pendingCreateIdentity, ); } - return { id, operation, exclusion, targetLiveDigest, startedAt }; + return result; } export function parseSandboxRegistryEntries(value: unknown): Array<[string, SandboxEntry]> { diff --git a/src/lib/state/registry-route-reservation.test.ts b/src/lib/state/registry-route-reservation.test.ts index 073abb51686..0c42d3532a6 100644 --- a/src/lib/state/registry-route-reservation.test.ts +++ b/src/lib/state/registry-route-reservation.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { serializedHostLocalInferenceReceipt } from "../../../test/helpers/host-local-inference-receipt"; import type { InferenceSelection } from "../inference/selection"; import type { SandboxInferenceRouteReservationDisposition } from "./registry/route-reservation"; -import type { PendingSandboxPolicyVerification, SandboxEntry } from "./registry/types"; +import type { PendingSandboxCreateIdentity, SandboxEntry } from "./registry/types"; function ownedReservation(disposition: SandboxInferenceRouteReservationDisposition) { expect(disposition.kind).toBe("owned"); return (disposition as Extract).reservation; @@ -42,64 +42,38 @@ const LIVE_IDENTITY_FINGERPRINT = "a".repeat(64); function managedCheckpoint( overrides: Partial< Pick< - PendingSandboxPolicyVerification, - | "gatewayPort" - | "lifecycleGeneration" - | "sandboxIdentityFingerprint" - | "route" - | "policyHash" - | "policyVersion" + PendingSandboxCreateIdentity, + "gatewayPort" | "lifecycleGeneration" | "sandboxIdentityFingerprint" | "route" > > = {}, -): PendingSandboxPolicyVerification { +): PendingSandboxCreateIdentity { const boundary = { gatewayPort: 8080, lifecycleGeneration: LIFECYCLE_GENERATION, sandboxIdentityFingerprint: LIVE_IDENTITY_FINGERPRINT, route: "none" as const, - policyHash: "sha256:policy-1", - policyVersion: 1, ...overrides, }; return { schemaVersion: 1, state: "verified-create", - policyAuthority: "nemoclaw-managed", - observedPolicyAuthority: "owner-unknown", gatewayName: EXACT_ROUTE_AUTHORITY.gatewayName, sandboxName: EXACT_ROUTE_AUTHORITY.sandboxName, ...boundary, - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: EXACT_ROUTE_AUTHORITY.gatewayName, - gatewayPort: boundary.gatewayPort, - sandboxName: EXACT_ROUTE_AUTHORITY.sandboxName, - lifecycleGeneration: boundary.lifecycleGeneration, - sandboxIdentityFingerprint: boundary.sandboxIdentityFingerprint, - policyHash: boundary.policyHash, - policyVersion: boundary.policyVersion, - }, }; } function externalCheckpoint( - overrides: Partial< - Pick - > = {}, -): PendingSandboxPolicyVerification { + overrides: Partial> = {}, +): PendingSandboxCreateIdentity { return { schemaVersion: 1, state: "verified-create", - policyAuthority: "externally-managed", - observedPolicyAuthority: "externally-managed", gatewayName: EXACT_ROUTE_AUTHORITY.gatewayName, gatewayPort: 8080, sandboxName: EXACT_ROUTE_AUTHORITY.sandboxName, lifecycleGeneration: LIFECYCLE_GENERATION, sandboxIdentityFingerprint: LIVE_IDENTITY_FINGERPRINT, route: "none", - policyHash: "sha256:external-1", - policyVersion: 1, ...overrides, }; } @@ -129,7 +103,6 @@ function createdSandboxRegistrationInput( reference: null, shared: false as const, }, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -171,7 +144,7 @@ function reserveQualifiedCreate(registry: typeof import("./registry")) { ); return { route, create }; } -function completedEntry(checkpoint: PendingSandboxPolicyVerification): SandboxEntry { +function completedEntry(checkpoint: PendingSandboxCreateIdentity): SandboxEntry { return { name: EXACT_ROUTE_AUTHORITY.sandboxName, ...EXACT_ROUTE_SELECTION, @@ -181,10 +154,6 @@ function completedEntry(checkpoint: PendingSandboxPolicyVerification): SandboxEn gatewayPort: checkpoint.gatewayPort, lifecycleGeneration: checkpoint.lifecycleGeneration, lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - policyAuthority: checkpoint.policyAuthority, - ...(checkpoint.policyAuthority === "nemoclaw-managed" - ? { policyCreationReceipt: checkpoint.policyCreationReceipt } - : {}), }; } describe("sandbox inference route reservation", () => { @@ -499,7 +468,6 @@ describe("sandbox inference route reservation", () => { reference: null, shared: false, }, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -924,7 +892,7 @@ describe("sandbox inference route reservation", () => { const registry = await import("./registry"); const { route, create } = reserveQualifiedCreate(registry); const checkpoint = managedCheckpoint(); - registry.recordPendingSandboxPolicyVerification(create, checkpoint); + registry.recordPendingSandboxCreateIdentity(create, checkpoint); const registered = registry.registerSandbox(completedEntry(checkpoint), route, { verifiedCreate: { reservation: create, checkpoint }, @@ -937,7 +905,7 @@ describe("sandbox inference route reservation", () => { agent: "hermes", }); expect(registered.pendingRouteReservation).toBeUndefined(); - expect(registered.pendingPolicyVerification).toBeUndefined(); + expect(registered.pendingCreateIdentity).toBeUndefined(); expect(registry.getSandbox("alpha")).toEqual(registered); } finally { await fs.rm(home, { recursive: true, force: true }); @@ -953,12 +921,12 @@ describe("sandbox inference route reservation", () => { const { create } = reserveQualifiedCreate(registry); const checkpoint = managedCheckpoint(); - const pending = registry.recordPendingSandboxPolicyVerification(create, checkpoint); + const pending = registry.recordPendingSandboxCreateIdentity(create, checkpoint); expect(pending).toMatchObject({ pendingRouteReservation: true, reservationSessionId: "session-owner", - pendingPolicyVerification: checkpoint, + pendingCreateIdentity: checkpoint, lifecycleGeneration: LIFECYCLE_GENERATION, lifecycleLiveIdentityFingerprint: LIVE_IDENTITY_FINGERPRINT, }); @@ -970,7 +938,7 @@ describe("sandbox inference route reservation", () => { ); expect(registry.finalizeSandboxRouteReservation("alpha", "session-owner")).toBe(false); expect(registry.finalizePendingSandboxRegistration("alpha")).toBe(false); - expect(registry.recordPendingSandboxPolicyVerification(create, checkpoint)).toEqual(pending); + expect(registry.recordPendingSandboxCreateIdentity(create, checkpoint)).toEqual(pending); expect( registry.reserveSandboxInferenceRoute("alpha", { ...EXACT_ROUTE_SELECTION, @@ -1002,37 +970,35 @@ describe("sandbox inference route reservation", () => { const replacement = managedCheckpoint({ route: "compatibility", sandboxIdentityFingerprint: "b".repeat(64), - policyHash: "sha256:policy-2", - policyVersion: 2, }); - const initialEntry = registry.recordPendingSandboxPolicyVerification(create, initial); + const initialEntry = registry.recordPendingSandboxCreateIdentity(create, initial); const admittedCheckpoint = ownedReservation( registry.classifySandboxInferenceRouteReservation(EXACT_ROUTE_AUTHORITY, initialEntry), ); - const rotated = registry.recordPendingSandboxPolicyVerification(create, replacement, { + const rotated = registry.recordPendingSandboxCreateIdentity(create, replacement, { expected: initial, }); - expect(rotated.pendingPolicyVerification).toEqual(replacement); + expect(rotated.pendingCreateIdentity).toEqual(replacement); expect(registry.isCurrentSandboxInferenceRouteReservation(route, rotated)).toBe(true); expect(registry.isCurrentSandboxInferenceRouteReservation(admittedCheckpoint, rotated)).toBe( false, ); expect(() => - registry.requireCurrentPendingSandboxPolicyVerification(create, initial), + registry.requireCurrentPendingSandboxCreateIdentity(create, initial), ).toThrow(/verified checkpoint changed/u); expect( - registry.recordPendingSandboxPolicyVerification(create, replacement, { + registry.recordPendingSandboxCreateIdentity(create, replacement, { expected: initial, }), ).toEqual(rotated); expect(() => - registry.recordPendingSandboxPolicyVerification(create, initial, { + registry.recordPendingSandboxCreateIdentity(create, initial, { expected: initial, }), ).toThrow(/without exact authority/u); expect(() => - registry.recordPendingSandboxPolicyVerification( + registry.recordPendingSandboxCreateIdentity( { ...create, authority: { ...create.authority, sessionId: "another-session" }, @@ -1055,9 +1021,9 @@ describe("sandbox inference route reservation", () => { const checkpoint = managedCheckpoint(); expect(() => registry.registerSandbox(completedEntry(checkpoint), route)).toThrow( - /verified policy checkpoint/u, + /pending create identity/u, ); - registry.recordPendingSandboxPolicyVerification(create, checkpoint); + registry.recordPendingSandboxCreateIdentity(create, checkpoint); registry.removeSandbox("alpha"); expect(() => registry.registerSandbox(completedEntry(checkpoint), route, { @@ -1077,7 +1043,7 @@ describe("sandbox inference route reservation", () => { const registry = await import("./registry"); const { route, create } = reserveQualifiedCreate(registry); const checkpoint = managedCheckpoint(); - registry.recordPendingSandboxPolicyVerification(create, checkpoint); + registry.recordPendingSandboxCreateIdentity(create, checkpoint); expect(() => registry.registerSandbox( @@ -1102,13 +1068,13 @@ describe("sandbox inference route reservation", () => { const registry = await import("./registry"); const { route, create } = reserveQualifiedCreate(registry); const checkpoint = externalCheckpoint(); - registry.recordPendingSandboxPolicyVerification(create, checkpoint); + registry.recordPendingSandboxCreateIdentity(create, checkpoint); expect(() => registry.registerSandbox(completedEntry(checkpoint), route, { verifiedCreate: { reservation: create, - checkpoint: externalCheckpoint({ policyHash: "sha256:changed" }), + checkpoint: externalCheckpoint({ route: "native" }), }, }), ).toThrow(/verified create checkpoint changed/u); @@ -1192,12 +1158,12 @@ describe("sandbox inference route reservation", () => { reservationSessionId: "another-session", }), ).toThrow(/belongs to another onboarding session/u); - registry.recordPendingSandboxPolicyVerification(create, managedCheckpoint()); + registry.recordPendingSandboxCreateIdentity(create, managedCheckpoint()); expect(registry.getSandbox("alpha")).toMatchObject({ pendingRouteReservation: true, reservationSessionId: EXACT_ROUTE_AUTHORITY.sessionId, model: EXACT_ROUTE_SELECTION.model, - pendingPolicyVerification: managedCheckpoint(), + pendingCreateIdentity: managedCheckpoint(), }); } finally { await fs.rm(home, { recursive: true, force: true }); @@ -1243,9 +1209,6 @@ describe("sandbox inference route reservation qualification (#9203)", () => { const disposition = classifySandboxInferenceRouteReservation(EXACT_ROUTE_AUTHORITY, { ...EXACT_QUALIFIED_ROUTE_RESERVATION, dashboardPort: 8080, - policies: ["github"], - policyPresetsFinalized: true, - policyTier: "personal", webSearchEnabled: false, webSearchProvider: null, }); @@ -1268,7 +1231,7 @@ describe("sandbox inference route reservation qualification (#9203)", () => { gatewayPort: checkpoint.gatewayPort, lifecycleGeneration: checkpoint.lifecycleGeneration, lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - pendingPolicyVerification: checkpoint, + pendingCreateIdentity: checkpoint, }; expect(classifySandboxInferenceRouteReservation(EXACT_ROUTE_AUTHORITY, pending).kind).toBe( @@ -1288,10 +1251,6 @@ describe("sandbox inference route reservation qualification (#9203)", () => { it.each([ ["invalid dashboard port", { dashboardPort: 0 }], - ["duplicate policies", { policies: ["github", "github"] }], - ["control character in a policy", { policies: ["github\u0000"] }], - ["control character in the policy tier", { policyTier: "personal\u0000" }], - ["non-boolean policy finalization", { policyPresetsFinalized: "yes" }], ["non-boolean web search state", { webSearchEnabled: "yes" }], ["unknown web search provider", { webSearchProvider: "unknown" }], ])("rejects %s in carried route metadata (#10056)", async (_case, updates) => { @@ -1322,7 +1281,6 @@ describe("sandbox inference route reservation qualification (#9203)", () => { await import("./registry/route-reservation"); const entry = { ...EXACT_QUALIFIED_ROUTE_RESERVATION, - policies: ["github"], ...updates, } as Parameters[1]; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 6b2af682664..833293251a8 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { isDeepStrictEqual } from "node:util"; -import { PolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; +import { PolicyObservationError } from "../adapters/openshell/policy-state"; import type { InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields, @@ -43,13 +43,8 @@ export { import { cloneSandboxWorkloadReceipt } from "./registry/workload"; import { normalizeSandboxMcpState } from "./registry-mcp"; import { - cloneSandboxPolicyCreationReceipt, - normalizeBaselineExclusions, - normalizeBaselineExclusionTransition, - normalizeCustomPolicyEntries, - normalizePendingSandboxPolicyVerification, + normalizePendingSandboxCreateIdentity, normalizeSandboxPolicyAttribution, - normalizeSandboxPolicyAuthority, retainedDefaultSandbox, } from "./registry-normalization"; import * as reversibleRemoval from "./registry-reversible-removal"; @@ -77,13 +72,7 @@ export { import { isDcodeAutoApprovalMode } from "../onboard/dcode-auto-approval"; import { cloneSandboxHostMounts, hasUnsafeHostMountTerminalText } from "./registry/host-mount"; -import type { - BaselineExclusionEntry, - BaselineExclusionTransition, - CustomPolicyEntry, - PendingSandboxPolicyVerification, - SandboxEntry, -} from "./registry/types"; +import type { PendingSandboxCreateIdentity, SandboxEntry } from "./registry/types"; import { cloneSandboxMessagingState, getConfiguredMessagingChannels as getRegistryConfiguredMessagingChannels, @@ -108,15 +97,11 @@ export { } from "./registry/lock"; export { load, REGISTRY_FILE, save } from "./registry/persistence"; export type { - BaselineExclusionEntry, - BaselineExclusionTransition, - BaselineExclusionTransitionOperation, - CustomPolicyEntry, SandboxEntry, SandboxGpuProofResult, SandboxGpuProofStatus, SandboxHostMount, - PendingSandboxPolicyVerification, + PendingSandboxCreateIdentity, SandboxRegistry, SandboxWorkloadReceipt, } from "./registry/types"; @@ -128,12 +113,7 @@ export { getMessagingPlanFromEntry, type SandboxMessagingState, } from "./registry-messaging"; -export { - cloneSandboxPolicyCreationReceipt, - hasUnsafeHostMountTerminalText, - normalizeCustomPolicyEntries, - normalizeSandboxPolicyAttribution, -}; +export { hasUnsafeHostMountTerminalText, normalizeSandboxPolicyAttribution }; export type SandboxRemovalReceipt = reversibleRemoval.RegistryRemovalReceipt; @@ -158,43 +138,41 @@ export function getDefault(): string | null { function pendingVerifiedCreateEntry( reservation: QualifiedPendingSandboxCreateReservation, - checkpoint: PendingSandboxPolicyVerification, + checkpoint: PendingSandboxCreateIdentity, ): SandboxEntry { return normalizeSandboxPolicyAttribution({ ...reservation.entry, gatewayPort: checkpoint.gatewayPort, lifecycleGeneration: checkpoint.lifecycleGeneration, lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - policyAuthority: undefined, - policyCreationReceipt: undefined, - pendingPolicyVerification: checkpoint, + pendingCreateIdentity: checkpoint, }); } -function assertPendingPolicyVerificationMatchesRegistration( +function assertPendingCreateIdentityMatchesRegistration( recordedEntry: SandboxEntry | undefined, requestedEntry: SandboxEntry, authority: | { readonly reservation: QualifiedPendingSandboxCreateReservation; - readonly checkpoint: PendingSandboxPolicyVerification; + readonly checkpoint: PendingSandboxCreateIdentity; } | undefined, ): void { - const checkpoint = normalizePendingSandboxPolicyVerification( - recordedEntry?.pendingPolicyVerification, + const checkpoint = normalizePendingSandboxCreateIdentity( + recordedEntry?.pendingCreateIdentity, ); - const expectedCheckpoint = normalizePendingSandboxPolicyVerification(authority?.checkpoint); + const expectedCheckpoint = normalizePendingSandboxCreateIdentity(authority?.checkpoint); if (!authority) { if (checkpoint) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( "Cannot publish a verified create checkpoint without exact transaction authority", ); } return; } if (!checkpoint || !expectedCheckpoint || !isDeepStrictEqual(checkpoint, expectedCheckpoint)) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( "Cannot publish a sandbox registration after its verified create checkpoint changed", ); } @@ -204,7 +182,7 @@ function assertPendingPolicyVerificationMatchesRegistration( !recordedEntry || !isDeepStrictEqual(recordedEntry, pendingVerifiedCreateEntry(reservation, expectedCheckpoint)) ) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( "Cannot publish a sandbox registration after its verified create transaction changed", ); } @@ -214,8 +192,6 @@ function assertPendingPolicyVerificationMatchesRegistration( "reservation session", recordedEntry?.reservationSessionId === reservation.authority.sessionId, ], - ["recorded policy authority", recordedEntry?.policyAuthority === undefined], - ["recorded policy receipt", recordedEntry?.policyCreationReceipt === undefined], [ "recorded lifecycle generation", recordedEntry?.lifecycleGeneration === checkpoint.lifecycleGeneration, @@ -235,7 +211,6 @@ function assertPendingPolicyVerificationMatchesRegistration( "requested lifecycle identity", checkpoint.sandboxIdentityFingerprint === requestedEntry.lifecycleLiveIdentityFingerprint, ], - ["policy authority", checkpoint.policyAuthority === requestedEntry.policyAuthority], ["reservation sandbox", reservation.authority.sandboxName === requestedEntry.name], ["reservation gateway", reservation.authority.gatewayName === requestedEntry.gatewayName], [ @@ -253,27 +228,22 @@ function assertPendingPolicyVerificationMatchesRegistration( ), ], ] as const; - const authorityMatches = - checkpoint.policyAuthority === "nemoclaw-managed" - ? isDeepStrictEqual(checkpoint.policyCreationReceipt, requestedEntry.policyCreationReceipt) - : requestedEntry.policyCreationReceipt === undefined; const mismatches: string[] = commonChecks.filter(([, matches]) => !matches).map(([name]) => name); - if (!authorityMatches) mismatches.push("policy receipt"); if (mismatches.length > 0) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Cannot publish a sandbox registration that differs from its verified create checkpoint (${mismatches.join(", ")})`, ); } } /** Persist the exact verified create boundary before any unrelated post-create effect. */ -export function recordPendingSandboxPolicyVerification( +export function recordPendingSandboxCreateIdentity( reservation: QualifiedPendingSandboxCreateReservation, - value: PendingSandboxPolicyVerification, - options: { readonly expected?: PendingSandboxPolicyVerification } = {}, + value: PendingSandboxCreateIdentity, + options: { readonly expected?: PendingSandboxCreateIdentity } = {}, ): SandboxEntry { - const checkpoint = normalizePendingSandboxPolicyVerification(value); - const expected = normalizePendingSandboxPolicyVerification(options.expected); + const checkpoint = normalizePendingSandboxCreateIdentity(value); + const expected = normalizePendingSandboxCreateIdentity(options.expected); const { authority } = reservation; const name = authority.sandboxName; if ( @@ -283,18 +253,18 @@ export function recordPendingSandboxPolicyVerification( checkpoint.gatewayName !== authority.gatewayName || !isCurrentPendingSandboxCreateReservation(reservation, reservation.entry) ) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( "Cannot record an incomplete verified sandbox create checkpoint", ); } return withLock(() => { const data = load(); const current = data.sandboxes[name]; - const recordedCheckpoint = normalizePendingSandboxPolicyVerification( - current?.pendingPolicyVerification, + const recordedCheckpoint = normalizePendingSandboxCreateIdentity( + current?.pendingCreateIdentity, ); if (!current) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Cannot record sandbox '${name}' policy verification after its route reservation changed`, ); } @@ -307,7 +277,7 @@ export function recordPendingSandboxPolicyVerification( recordedCheckpoint !== undefined || !isCurrentPendingSandboxCreateReservation(reservation, current) ) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Cannot record sandbox '${name}' policy verification after its route reservation changed`, ); } @@ -317,12 +287,11 @@ export function recordPendingSandboxPolicyVerification( !recordedCheckpoint || !isDeepStrictEqual(current, expectedEntry) || checkpoint.lifecycleGeneration !== expected.lifecycleGeneration || - checkpoint.policyAuthority !== expected.policyAuthority || checkpoint.gatewayName !== expected.gatewayName || checkpoint.gatewayPort !== expected.gatewayPort || checkpoint.sandboxName !== expected.sandboxName ) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Cannot replace sandbox '${name}' verified create checkpoint without exact authority`, ); } @@ -334,11 +303,11 @@ export function recordPendingSandboxPolicyVerification( } /** Re-read one durable verified create checkpoint before releasing an effect. */ -export function requireCurrentPendingSandboxPolicyVerification( +export function requireCurrentPendingSandboxCreateIdentity( reservation: QualifiedPendingSandboxCreateReservation, - expected: PendingSandboxPolicyVerification, + expected: PendingSandboxCreateIdentity, ): SandboxEntry { - const checkpoint = normalizePendingSandboxPolicyVerification(expected); + const checkpoint = normalizePendingSandboxCreateIdentity(expected); const { authority } = reservation; const name = authority.sandboxName; const current = load().sandboxes[name]; @@ -348,7 +317,7 @@ export function requireCurrentPendingSandboxPolicyVerification( !current || !isDeepStrictEqual(current, pendingVerifiedCreateEntry(reservation, checkpoint)) ) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Cannot continue sandbox '${name}' creation after its verified checkpoint changed`, ); } @@ -363,21 +332,21 @@ export function registerSandbox( reservationSessionId?: string; verifiedCreate?: { readonly reservation: QualifiedPendingSandboxCreateReservation; - readonly checkpoint: PendingSandboxPolicyVerification; + readonly checkpoint: PendingSandboxCreateIdentity; }; } = {}, ): SandboxEntry { return withLock(() => { const data = load(); const recordedEntry = data.sandboxes[entry.name]; - if (entry.pendingPolicyVerification !== undefined) { - throw new PolicyAuthorityRefusalError( + if (entry.pendingCreateIdentity !== undefined) { + throw new PolicyObservationError( "Cannot publish a caller-supplied pending policy verification", ); } if (routeReservation && options.pending !== true && !options.verifiedCreate) { - throw new PolicyAuthorityRefusalError( - "Cannot consume a create route reservation without its verified policy checkpoint", + throw new PolicyObservationError( + "Cannot consume a create route reservation without its pending create identity", ); } if ( @@ -385,7 +354,7 @@ export function registerSandbox( options.verifiedCreate && !isDeepStrictEqual(routeReservation, options.verifiedCreate.reservation) ) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( "Cannot publish a verified sandbox create with a different route reservation authority", ); } @@ -431,50 +400,20 @@ export function registerSandbox( options.pending !== true && !options.verifiedCreate ) { - throw new PolicyAuthorityRefusalError( - "Cannot publish a pending sandbox create without its verified policy checkpoint", + throw new PolicyObservationError( + "Cannot publish a pending sandbox create without its pending create identity", ); } const servingProfileProvenance = parseServingProfileProvenance(entry.servingProfileProvenance); if (entry.servingProfileProvenance !== undefined && !servingProfileProvenance) { throw new Error("Cannot register a sandbox with invalid serving profile provenance"); } - if ( - options.pending === true && - (entry.policyAuthority === "nemoclaw-managed" || entry.policyCreationReceipt !== undefined) - ) { - throw new PolicyAuthorityRefusalError( - "Cannot attach NemoClaw policy ownership to a pending sandbox registration", - ); - } - if (entry.policyAuthority === "nemoclaw-managed" && entry.policyCreationReceipt === undefined) { - throw new PolicyAuthorityRefusalError( - "Cannot register NemoClaw policy ownership without a complete policy creation receipt", - ); - } - if (entry.policyCreationReceipt !== undefined && entry.policyAuthority !== "nemoclaw-managed") { - throw new PolicyAuthorityRefusalError( - "Cannot register a policy creation receipt without NemoClaw policy ownership", - ); - } const normalizedPolicyEntry = normalizeSandboxPolicyAttribution(entry); - const requestedPolicyAuthority = normalizeSandboxPolicyAuthority( - normalizedPolicyEntry.policyAuthority, - ); - const requestedPolicyCreationReceipt = normalizedPolicyEntry.policyCreationReceipt; - if (requestedPolicyAuthority === "nemoclaw-managed") { - assertPolicyCreationReceiptMatchesSandboxEntry( - normalizedPolicyEntry, - requestedPolicyCreationReceipt, - ); - } - assertPendingPolicyVerificationMatchesRegistration( + assertPendingCreateIdentityMatchesRegistration( recordedEntry, normalizedPolicyEntry, options.verifiedCreate, ); - const recordedPolicyAuthority = normalizeSandboxPolicyAuthority(recordedEntry?.policyAuthority); - const recordedPolicyCreationReceipt = recordedEntry?.policyCreationReceipt; const reservedGenerationChanged = recordedEntry?.pendingRouteReservation === true && recordedEntry.lifecycleGeneration !== normalizedPolicyEntry.lifecycleGeneration; @@ -483,34 +422,10 @@ export function registerSandbox( recordedEntry.lifecycleLiveIdentityFingerprint !== normalizedPolicyEntry.lifecycleLiveIdentityFingerprint; if (reservedGenerationChanged !== reservedFingerprintChanged) { - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( "Cannot register a sandbox after only part of its reserved lifecycle identity changed", ); } - const replacesReservedSandboxLifecycle = - recordedEntry?.pendingRouteReservation === true && - requestedPolicyCreationReceipt !== undefined && - reservedGenerationChanged && - reservedFingerprintChanged; - if ( - recordedPolicyAuthority !== undefined && - requestedPolicyAuthority !== undefined && - recordedPolicyAuthority !== requestedPolicyAuthority - ) { - throw new PolicyAuthorityRefusalError( - "Cannot register a sandbox after its policy authority changed", - ); - } - if ( - recordedPolicyAuthority === "nemoclaw-managed" && - !isDeepStrictEqual(recordedPolicyCreationReceipt, requestedPolicyCreationReceipt) && - !replacesReservedSandboxLifecycle - ) { - throw new PolicyAuthorityRefusalError( - "Cannot register a sandbox after its policy creation receipt changed", - ); - } - const policyAuthority = requestedPolicyAuthority ?? recordedPolicyAuthority; if (retainedDefaultSandbox(data.defaultSandbox, data.sandboxes) === null) { data.defaultSandbox = null; } @@ -571,20 +486,6 @@ export function registerSandbox( : undefined, openshellDriver: entry.openshellDriver || null, openshellVersion: entry.openshellVersion || null, - ...(policyAuthority !== undefined ? { policyAuthority } : {}), - ...(policyAuthority === "nemoclaw-managed" && requestedPolicyCreationReceipt - ? { policyCreationReceipt: requestedPolicyCreationReceipt } - : {}), - ...(policyAuthority === "externally-managed" - ? { policies: [] } - : { - policies: entry.policies || [], - baselineExclusions: normalizeBaselineExclusions(entry.baselineExclusions), - baselineExclusionTransition: normalizeBaselineExclusionTransition( - entry.baselineExclusionTransition, - ), - policyTier: entry.policyTier || null, - }), webSearchEnabled: typeof entry.webSearchEnabled === "boolean" ? entry.webSearchEnabled : undefined, // Preserve absence on reconstructed legacy rows. Only a freshly built @@ -600,11 +501,6 @@ export function registerSandbox( (entry.webSearchProvider === "brave" || entry.webSearchProvider === "tavily") ? entry.webSearchProvider : null, - // policyPresetsFinalized is intentionally not set here: registration means - // the policy step has not completed for this entry. It is stamped only by - // the post-policy registry write (see policy-preset-persistence), so a - // snapshot clone (which spreads the source entry but resets `policies`) - // cannot inherit a stale finalized marker. See #4621. agent: entry.agent || null, agentVersion: entry.agentVersion || null, openclawImagePluginInstalls: Array.isArray(entry.openclawImagePluginInstalls) @@ -752,8 +648,8 @@ export function reserveSandboxInferenceRoute( normalizeInferenceSelection(route), )); if (!sameReservation) { - if (existing.pendingPolicyVerification) { - throw new PolicyAuthorityRefusalError( + if (existing.pendingCreateIdentity) { + throw new PolicyObservationError( `Cannot replace sandbox '${name}' while its verified create checkpoint is incomplete`, ); } @@ -761,7 +657,7 @@ export function reserveSandboxInferenceRoute( existing.reservationSessionId !== route.reservationSessionId ? "belongs to another onboarding session" : "cannot change before the owning create transaction completes"; - throw new PolicyAuthorityRefusalError( + throw new PolicyObservationError( `Cannot replace sandbox '${name}': its inference route reservation ${detail}`, ); } @@ -770,16 +666,6 @@ export function reserveSandboxInferenceRoute( const existingForReservation: SandboxEntry = existing ? { ...existing } : { name, pendingRouteReservation: true }; - if ( - existingForReservation.policyCreationReceipt !== undefined && - (route.gatewayName !== existingForReservation.gatewayName || - (route.gatewayPort !== undefined && - route.gatewayPort !== existingForReservation.gatewayPort)) - ) { - throw new PolicyAuthorityRefusalError( - "Cannot move a receipt-bound sandbox reservation to another gateway", - ); - } const next = normalizeSandboxPolicyAttribution({ ...existingForReservation, pendingRouteReservation: true, @@ -798,10 +684,8 @@ export function reserveSandboxInferenceRoute( ...(provenance ? { hostLocalInferenceProvenance: provenance } : {}), gatewayName: route.gatewayName, gatewayPort: - existingForReservation.policyCreationReceipt === undefined - ? (route.gatewayPort ?? - (existing?.gatewayName === route.gatewayName ? existing.gatewayPort : undefined)) - : existingForReservation.gatewayPort, + route.gatewayPort ?? + (existing?.gatewayName === route.gatewayName ? existing.gatewayPort : undefined), ...(route.openshellDriver === undefined ? {} : { openshellDriver: route.openshellDriver }), }); data.sandboxes[name] = next; @@ -841,61 +725,18 @@ function changesHostLocalInferenceLifecycleAuthority( ); } -function assertRecordedPolicyAuthorityUnchanged( - current: SandboxEntry, - updates: Partial, -): void { - if (!Object.prototype.hasOwnProperty.call(updates, "policyAuthority")) return; - const requested = normalizeSandboxPolicyAuthority(updates.policyAuthority); - if (current.policyAuthority === undefined || requested === current.policyAuthority) return; - throw new PolicyAuthorityRefusalError( - `Refusing to update sandbox '${current.name}' because its policy authority changed ` + - `from ${current.policyAuthority} to ${requested ?? "unrecorded"}.`, - ); -} - -function assertPolicyCreationReceiptMatchesSandboxEntry( - entry: SandboxEntry, - receipt: SandboxEntry["policyCreationReceipt"], -): asserts receipt is NonNullable { - if ( - !receipt || - receipt.sandboxName !== entry.name || - receipt.gatewayName !== entry.gatewayName || - receipt.gatewayPort !== entry.gatewayPort || - receipt.lifecycleGeneration !== entry.lifecycleGeneration || - receipt.sandboxIdentityFingerprint !== entry.lifecycleLiveIdentityFingerprint - ) { - throw new PolicyAuthorityRefusalError( - `Cannot record NemoClaw policy ownership for sandbox '${entry.name}' without an exact gateway and sandbox identity receipt`, - ); - } -} - -function assertPolicyCreationReceiptUnchanged( - current: SandboxEntry, - updates: Partial, -): void { - if (!Object.prototype.hasOwnProperty.call(updates, "policyCreationReceipt")) return; - const requested = cloneSandboxPolicyCreationReceipt(updates.policyCreationReceipt); - if (isDeepStrictEqual(requested, current.policyCreationReceipt)) return; - throw new PolicyAuthorityRefusalError( - `Refusing to update sandbox '${current.name}' because its policy creation receipt changed outside the receipt rotation transaction.`, - ); -} - export function updateSandbox(name: string, updates: Partial): boolean { return withLock(() => { const data = load(); const current = data.sandboxes[name]; if (!current) return false; - if (Object.prototype.hasOwnProperty.call(updates, "pendingPolicyVerification")) { - throw new PolicyAuthorityRefusalError( + if (Object.prototype.hasOwnProperty.call(updates, "pendingCreateIdentity")) { + throw new PolicyObservationError( `Refusing to change sandbox '${name}' verified create checkpoint outside its transaction.`, ); } - if (current.pendingPolicyVerification) { - throw new PolicyAuthorityRefusalError( + if (current.pendingCreateIdentity) { + throw new PolicyObservationError( `Refusing to update sandbox '${name}' while its verified create checkpoint is incomplete.`, ); } @@ -903,16 +744,6 @@ export function updateSandbox(name: string, updates: Partial): boo return false; } if (changesHostLocalInferenceLifecycleAuthority(current, updates)) return false; - if ( - updates.policyAuthority === "nemoclaw-managed" && - current.policyAuthority !== "nemoclaw-managed" - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to assign NemoClaw policy ownership to sandbox '${current.name}' outside completed sandbox registration.`, - ); - } - assertRecordedPolicyAuthorityUnchanged(current, updates); - assertPolicyCreationReceiptUnchanged(current, updates); data.sandboxes[name] = normalizeSandboxPolicyAttribution({ ...current, ...updates }); save(data); return true; @@ -956,7 +787,7 @@ export function removeSandboxRouteReservationIfCurrent(expected: SandboxEntry): const expectedSnapshot = structuredClone(expected); if ( expectedSnapshot.pendingRouteReservation !== true || - expectedSnapshot.pendingPolicyVerification !== undefined + expectedSnapshot.pendingCreateIdentity !== undefined ) { return false; } @@ -977,7 +808,7 @@ export function finalizeSandboxRouteReservation(name: string, sessionId: string) const current = data.sandboxes[name]; if (!current || !sessionId || current.reservationSessionId !== sessionId) return false; if (current.pendingRouteReservation !== true) return true; - if (current.pendingPolicyVerification) return false; + if (current.pendingCreateIdentity) return false; data.sandboxes[name] = { ...current, pendingRouteReservation: undefined, @@ -987,52 +818,6 @@ export function finalizeSandboxRouteReservation(name: string, sessionId: string) }); } -/** Replace only the policy identity in an exact, completed NemoClaw ownership receipt. */ -export function compareAndSetSandboxPolicyCreationReceipt( - name: string, - expected: NonNullable, - replacement: NonNullable, -): boolean { - const expectedReceipt = cloneSandboxPolicyCreationReceipt(expected); - const replacementReceipt = cloneSandboxPolicyCreationReceipt(replacement); - if (!expectedReceipt || !replacementReceipt) { - throw new PolicyAuthorityRefusalError( - "Cannot rotate an incomplete NemoClaw policy creation receipt", - ); - } - if ( - expectedReceipt.gatewayName !== replacementReceipt.gatewayName || - expectedReceipt.gatewayPort !== replacementReceipt.gatewayPort || - expectedReceipt.sandboxName !== replacementReceipt.sandboxName || - expectedReceipt.lifecycleGeneration !== replacementReceipt.lifecycleGeneration || - expectedReceipt.sandboxIdentityFingerprint !== replacementReceipt.sandboxIdentityFingerprint - ) { - throw new PolicyAuthorityRefusalError( - "Cannot rotate a policy creation receipt across a gateway or sandbox identity", - ); - } - - return withLock(() => { - const data = load(); - const current = data.sandboxes[name]; - if ( - !current || - current.pendingRouteReservation === true || - current.policyAuthority !== "nemoclaw-managed" || - !isDeepStrictEqual(current.policyCreationReceipt, expectedReceipt) - ) { - return false; - } - assertPolicyCreationReceiptMatchesSandboxEntry(current, replacementReceipt); - data.sandboxes[name] = { - ...current, - policyCreationReceipt: replacementReceipt, - }; - save(data); - return true; - }); -} - /** Atomically publish a pending registration and preserve its initial-default claim. */ export function finalizePendingSandboxRegistration(name: string): boolean { return withLock(() => { @@ -1042,7 +827,7 @@ export function finalizePendingSandboxRegistration(name: string): boolean { !current || current.pendingRouteReservation !== true || current.reservationSessionId !== undefined || - current.pendingPolicyVerification !== undefined + current.pendingCreateIdentity !== undefined ) { return false; } @@ -1081,21 +866,11 @@ export function restoreSandboxEntry( const data = load(); const normalizedEntry = normalizeSandboxPolicyAttribution(entry); const current = data.sandboxes[normalizedEntry.name]; - if (current?.pendingPolicyVerification && !isDeepStrictEqual(current, normalizedEntry)) { - throw new PolicyAuthorityRefusalError( + if (current?.pendingCreateIdentity && !isDeepStrictEqual(current, normalizedEntry)) { + throw new PolicyObservationError( `Refusing to restore sandbox '${normalizedEntry.name}' while its verified create checkpoint is incomplete.`, ); } - if ( - current && - (normalizeSandboxPolicyAuthority(current.policyAuthority) !== - normalizeSandboxPolicyAuthority(normalizedEntry.policyAuthority) || - !isDeepStrictEqual(current.policyCreationReceipt, normalizedEntry.policyCreationReceipt)) - ) { - throw new PolicyAuthorityRefusalError( - `Refusing to restore sandbox '${normalizedEntry.name}' because its policy authority changed during recovery.`, - ); - } save( reversibleRemoval.restoreSandboxEntryInRegistry( data, @@ -1143,146 +918,6 @@ export function clearAll(): void { withLock(() => save(reversibleRemoval.clearRegistry(load()))); } -/** Return the list of custom policy entries recorded for a sandbox (never null). */ -export function getCustomPolicies(name: string): CustomPolicyEntry[] { - const data = load(); - return data.sandboxes[name]?.customPolicies ?? []; -} - -/** Upsert a custom policy by name. Replaces any existing entry with the same name. */ -export function addCustomPolicy(name: string, entry: CustomPolicyEntry): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - if (!sandbox) return false; - const list = (sandbox.customPolicies ?? []).filter((p) => p.name !== entry.name); - list.push({ ...entry, appliedAt: entry.appliedAt ?? new Date().toISOString() }); - sandbox.customPolicies = list; - save(data); - return true; - }); -} - -/** Remove a custom policy by name. Returns true if an entry was removed. */ -export function removeCustomPolicyByName(name: string, presetName: string): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - if (!sandbox) return false; - const list = sandbox.customPolicies ?? []; - const next = list.filter((p) => p.name !== presetName); - if (next.length === list.length) return false; - sandbox.customPolicies = next.length > 0 ? next : undefined; - save(data); - return true; - }); -} - -/** Return the baseline exclusions recorded for a sandbox (never null). */ -export function getBaselineExclusions(name: string): BaselineExclusionEntry[] { - const data = load(); - return data.sandboxes[name]?.baselineExclusions ?? []; -} - -/** Upsert a baseline exclusion by key. Replaces any existing entry for the key. */ -export function addBaselineExclusion(name: string, entry: BaselineExclusionEntry): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - if (!sandbox || sandbox.baselineExclusionTransition) return false; - const list = (sandbox.baselineExclusions ?? []).filter((e) => e.key !== entry.key); - list.push({ ...entry, acknowledgedAt: entry.acknowledgedAt ?? new Date().toISOString() }); - sandbox.baselineExclusions = list; - save(data); - return true; - }); -} - -/** Remove a baseline exclusion by key. Returns true if an entry was removed. */ -export function removeBaselineExclusion(name: string, key: string): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - if (!sandbox || sandbox.baselineExclusionTransition) return false; - const list = sandbox.baselineExclusions ?? []; - const next = list.filter((e) => e.key !== key); - if (next.length === list.length) return false; - sandbox.baselineExclusions = next.length > 0 ? next : undefined; - save(data); - return true; - }); -} - -/** Return the one in-flight baseline policy transaction for a sandbox. */ -export function getBaselineExclusionTransition(name: string): BaselineExclusionTransition | null { - const data = load(); - return data.sandboxes[name]?.baselineExclusionTransition ?? null; -} - -/** - * Persist a new cross-system transaction before changing the live policy. - * Refuses to overwrite another pending transaction, even for the same key. - */ -export function beginBaselineExclusionTransition( - name: string, - transition: BaselineExclusionTransition, -): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - if (!sandbox || sandbox.baselineExclusionTransition) return false; - sandbox.baselineExclusionTransition = normalizeBaselineExclusionTransition(transition); - save(data); - return true; - }); -} - -/** - * Publish the durable intent represented by a completed live mutation and - * clear its journal in the same registry-file replacement. - */ -export function commitBaselineExclusionTransition(name: string, id: string): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - const transition = sandbox?.baselineExclusionTransition; - if (!sandbox || !transition || transition.id !== id) return false; - if (transition.operation === "exclude") { - const list = (sandbox.baselineExclusions ?? []).filter( - (entry) => entry.key !== transition.exclusion.key, - ); - list.push({ - ...transition.exclusion, - acknowledgedAt: transition.exclusion.acknowledgedAt ?? new Date().toISOString(), - }); - sandbox.baselineExclusions = list; - } else { - const list = sandbox.baselineExclusions ?? []; - const committed = list.find((entry) => entry.key === transition.exclusion.key); - // A restore may finalize only the exact durable exclusion it staged - // against. Preserve the journal if another writer changed the record. - if (!committed || !isDeepStrictEqual(committed, transition.exclusion)) return false; - const next = list.filter((entry) => entry.key !== transition.exclusion.key); - sandbox.baselineExclusions = next.length > 0 ? next : undefined; - } - sandbox.baselineExclusionTransition = undefined; - save(data); - return true; - }); -} - -/** Roll back only the exact pending transaction, preserving committed intent. */ -export function clearBaselineExclusionTransition(name: string, id: string): boolean { - return withLock(() => { - const data = load(); - const sandbox = data.sandboxes[name]; - if (!sandbox || sandbox.baselineExclusionTransition?.id !== id) return false; - sandbox.baselineExclusionTransition = undefined; - save(data); - return true; - }); -} - export function getDisabledChannels(name: string): string[] { return getRegistryDisabledChannels(name, { load }); } diff --git a/src/lib/state/registry/pending-create-identity.ts b/src/lib/state/registry/pending-create-identity.ts new file mode 100644 index 00000000000..df8ab3b8fb4 --- /dev/null +++ b/src/lib/state/registry/pending-create-identity.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PendingSandboxCreateIdentity } from "./types"; + +const SHA256_DIGEST_PATTERN = /^[a-f0-9]{64}$/; +const KEYS = new Set([ + "gatewayName", + "gatewayPort", + "lifecycleGeneration", + "createAttemptNonce", + "route", + "sandboxIdentityFingerprint", + "sandboxName", + "schemaVersion", + "state", +]); +const LEGACY_POLICY_KEYS = new Set([ + "observedPolicyAuthority", + "policyAuthority", + "policyCreationReceipt", + "policyHash", + "policyVersion", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Normalize the bounded identity checkpoint for one incomplete create. */ +export function normalizePendingSandboxCreateIdentity( + value: unknown, +): PendingSandboxCreateIdentity | undefined { + if (value === undefined) return undefined; + if ( + !isRecord(value) || + Object.keys(value).some((key) => !KEYS.has(key) && !LEGACY_POLICY_KEYS.has(key)) || + value.schemaVersion !== 1 || + value.state !== "verified-create" || + typeof value.gatewayName !== "string" || + value.gatewayName.length === 0 || + !Number.isSafeInteger(value.gatewayPort) || + Number(value.gatewayPort) < 1 || + Number(value.gatewayPort) > 65_535 || + typeof value.sandboxName !== "string" || + value.sandboxName.length === 0 || + typeof value.lifecycleGeneration !== "string" || + value.lifecycleGeneration.length === 0 || + typeof value.sandboxIdentityFingerprint !== "string" || + !SHA256_DIGEST_PATTERN.test(value.sandboxIdentityFingerprint) || + (value.createAttemptNonce !== undefined && + (typeof value.createAttemptNonce !== "string" || + !/^[0-9a-f]{62}$/u.test(value.createAttemptNonce))) || + (value.route !== "none" && value.route !== "native" && value.route !== "compatibility") + ) { + throw new Error( + "Sandbox registry contains an invalid pending sandbox create verification; repair the registry before continuing", + ); + } + return { + schemaVersion: 1, + state: "verified-create", + gatewayName: value.gatewayName, + gatewayPort: Number(value.gatewayPort), + sandboxName: value.sandboxName, + lifecycleGeneration: value.lifecycleGeneration, + sandboxIdentityFingerprint: value.sandboxIdentityFingerprint, + ...(value.createAttemptNonce ? { createAttemptNonce: value.createAttemptNonce } : {}), + route: value.route, + }; +} diff --git a/src/lib/state/registry/pending-policy-verification.ts b/src/lib/state/registry/pending-policy-verification.ts deleted file mode 100644 index 0c64644a4d4..00000000000 --- a/src/lib/state/registry/pending-policy-verification.ts +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { parseNemoClawPolicyCreationReceipt } from "../../policy/merge"; -import type { PendingSandboxPolicyVerification } from "./types"; - -const SHA256_DIGEST_PATTERN = /^[a-f0-9]{64}$/; -const PENDING_POLICY_VERIFICATION_KEYS = new Set([ - "schemaVersion", - "state", - "policyAuthority", - "observedPolicyAuthority", - "gatewayName", - "gatewayPort", - "sandboxName", - "lifecycleGeneration", - "sandboxIdentityFingerprint", - "createAttemptNonce", - "route", - "policyHash", - "policyVersion", - "policyCreationReceipt", -]); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Clone one exact post-create policy checkpoint and reject partial or forged state. */ -export function normalizePendingSandboxPolicyVerification( - value: unknown, -): PendingSandboxPolicyVerification | undefined { - if (value === undefined) return undefined; - if ( - !isRecord(value) || - Object.keys(value).some((key) => !PENDING_POLICY_VERIFICATION_KEYS.has(key)) || - value.schemaVersion !== 1 || - value.state !== "verified-create" || - (value.policyAuthority !== "nemoclaw-managed" && - value.policyAuthority !== "externally-managed") || - (value.observedPolicyAuthority !== "owner-unknown" && - value.observedPolicyAuthority !== "externally-managed") || - typeof value.gatewayName !== "string" || - value.gatewayName.length === 0 || - !Number.isSafeInteger(value.gatewayPort) || - Number(value.gatewayPort) < 1 || - Number(value.gatewayPort) > 65_535 || - typeof value.sandboxName !== "string" || - value.sandboxName.length === 0 || - typeof value.lifecycleGeneration !== "string" || - value.lifecycleGeneration.length === 0 || - typeof value.sandboxIdentityFingerprint !== "string" || - !SHA256_DIGEST_PATTERN.test(value.sandboxIdentityFingerprint) || - (value.createAttemptNonce !== undefined && - (typeof value.createAttemptNonce !== "string" || - !/^[0-9a-f]{62}$/u.test(value.createAttemptNonce))) || - (value.route !== "none" && value.route !== "native" && value.route !== "compatibility") || - typeof value.policyHash !== "string" || - !Number.isSafeInteger(value.policyVersion) || - Number(value.policyVersion) < 1 - ) { - throw new Error( - "Sandbox registry contains an invalid pending policy verification; repair the registry before continuing", - ); - } - let boundary; - try { - boundary = parseNemoClawPolicyCreationReceipt({ - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: value.gatewayName, - gatewayPort: value.gatewayPort, - sandboxName: value.sandboxName, - lifecycleGeneration: value.lifecycleGeneration, - sandboxIdentityFingerprint: value.sandboxIdentityFingerprint, - policyHash: value.policyHash, - policyVersion: value.policyVersion, - }); - } catch { - throw new Error( - "Sandbox registry contains an invalid pending policy verification identity; repair the registry before continuing", - ); - } - if (value.policyAuthority === "nemoclaw-managed") { - if (value.observedPolicyAuthority !== "owner-unknown") { - throw new Error( - "Sandbox registry contains an invalid managed pending policy verification; repair the registry before continuing", - ); - } - let receipt; - try { - receipt = parseNemoClawPolicyCreationReceipt(value.policyCreationReceipt); - } catch { - throw new Error( - "Sandbox registry contains an invalid policy creation receipt; repair the registry before continuing", - ); - } - if ( - receipt.gatewayName !== boundary.gatewayName || - receipt.gatewayPort !== boundary.gatewayPort || - receipt.sandboxName !== boundary.sandboxName || - receipt.lifecycleGeneration !== boundary.lifecycleGeneration || - receipt.sandboxIdentityFingerprint !== boundary.sandboxIdentityFingerprint || - receipt.policyHash !== boundary.policyHash || - receipt.policyVersion !== boundary.policyVersion - ) { - throw new Error( - "Sandbox registry pending managed policy verification does not match its creation receipt", - ); - } - return { - schemaVersion: 1, - state: "verified-create", - policyAuthority: "nemoclaw-managed", - observedPolicyAuthority: "owner-unknown", - gatewayName: boundary.gatewayName, - gatewayPort: boundary.gatewayPort, - sandboxName: boundary.sandboxName, - lifecycleGeneration: boundary.lifecycleGeneration, - sandboxIdentityFingerprint: boundary.sandboxIdentityFingerprint, - ...(value.createAttemptNonce ? { createAttemptNonce: value.createAttemptNonce } : {}), - route: value.route, - policyHash: boundary.policyHash, - policyVersion: boundary.policyVersion, - policyCreationReceipt: receipt, - }; - } - if (value.policyCreationReceipt !== undefined) { - throw new Error( - "Sandbox registry pending external policy verification cannot contain a creation receipt", - ); - } - return { - schemaVersion: 1, - state: "verified-create", - policyAuthority: "externally-managed", - observedPolicyAuthority: value.observedPolicyAuthority, - gatewayName: boundary.gatewayName, - gatewayPort: boundary.gatewayPort, - sandboxName: boundary.sandboxName, - lifecycleGeneration: boundary.lifecycleGeneration, - sandboxIdentityFingerprint: boundary.sandboxIdentityFingerprint, - ...(value.createAttemptNonce ? { createAttemptNonce: value.createAttemptNonce } : {}), - route: value.route, - policyHash: boundary.policyHash, - policyVersion: boundary.policyVersion, - }; -} diff --git a/src/lib/state/registry/route-reservation.ts b/src/lib/state/registry/route-reservation.ts index fe0e37e15ae..87c61cb3cf5 100644 --- a/src/lib/state/registry/route-reservation.ts +++ b/src/lib/state/registry/route-reservation.ts @@ -5,8 +5,8 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeInferenceSelection, type InferenceSelection } from "../../inference/selection"; import { isWebSearchProvider } from "../../inference/web-search/provider"; -import { normalizePendingSandboxPolicyVerification } from "./pending-policy-verification"; -import type { PendingSandboxPolicyVerification, SandboxEntry } from "./types"; +import { normalizePendingSandboxCreateIdentity } from "./pending-create-identity"; +import type { PendingSandboxCreateIdentity, SandboxEntry } from "./types"; const ROUTE_RESERVATION_KEYS = new Set([ "credentialEnv", @@ -23,11 +23,8 @@ const ROUTE_RESERVATION_KEYS = new Set([ "name", "openshellDriver", "pendingRouteReservation", - "pendingPolicyVerification", + "pendingCreateIdentity", "preferredInferenceApi", - "policies", - "policyPresetsFinalized", - "policyTier", "provider", "reservationSessionId", "webSearchEnabled", @@ -41,7 +38,7 @@ function verifiedCreateCheckpointClass( ): "absent" | "valid" | "malformed" | "sandbox-authority" { let checkpoint; try { - checkpoint = normalizePendingSandboxPolicyVerification(entry.pendingPolicyVerification); + checkpoint = normalizePendingSandboxCreateIdentity(entry.pendingCreateIdentity); } catch { return "malformed"; } @@ -59,14 +56,14 @@ function verifiedCreateCheckpointClass( function withVerifiedCreateCheckpoint( entry: SandboxEntry, - checkpoint: PendingSandboxPolicyVerification, + checkpoint: PendingSandboxCreateIdentity, ): SandboxEntry { return { ...entry, gatewayPort: checkpoint.gatewayPort, lifecycleGeneration: checkpoint.lifecycleGeneration, lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - pendingPolicyVerification: checkpoint, + pendingCreateIdentity: checkpoint, }; } @@ -80,31 +77,6 @@ function validCarriedRouteMetadata(entry: SandboxEntry): boolean { ) { return false; } - if ( - entry.policies !== undefined && - (!Array.isArray(entry.policies) || - entry.policies.some( - (value) => typeof value !== "string" || value.length === 0 || CONTROL_CHARACTER.test(value), - ) || - new Set(entry.policies).size !== entry.policies.length) - ) { - return false; - } - if ( - entry.policyPresetsFinalized !== undefined && - typeof entry.policyPresetsFinalized !== "boolean" - ) { - return false; - } - if ( - entry.policyTier !== undefined && - entry.policyTier !== null && - (typeof entry.policyTier !== "string" || - entry.policyTier.length === 0 || - CONTROL_CHARACTER.test(entry.policyTier)) - ) { - return false; - } if (entry.webSearchEnabled !== undefined && typeof entry.webSearchEnabled !== "boolean") { return false; } @@ -318,11 +290,11 @@ export function isCurrentSandboxInferenceRouteReservation( let checkpoint; let admittedCheckpoint; try { - checkpoint = normalizePendingSandboxPolicyVerification( - current.reservation.entry.pendingPolicyVerification, + checkpoint = normalizePendingSandboxCreateIdentity( + current.reservation.entry.pendingCreateIdentity, ); - admittedCheckpoint = normalizePendingSandboxPolicyVerification( - reservation.entry.pendingPolicyVerification, + admittedCheckpoint = normalizePendingSandboxCreateIdentity( + reservation.entry.pendingCreateIdentity, ); } catch { return false; diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index 688425c5035..7de4f982f37 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -1,22 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxPolicyAuthority } from "../../adapters/openshell/policy-authority"; import type { InferenceSelection } from "../../inference/selection"; import type { ServingProfileProvenance } from "../../inference/serving/types"; import type { WebSearchProvider } from "../../inference/web-search"; import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import type { NativeArtifactWorkloadReceiptV1 } from "../../onboard/workload/native-artifact"; -import type { NemoClawPolicyCreationReceipt } from "../../policy/merge"; -import type { TrustedPrivatePolicyPinReceipt } from "../../policy/trusted-private-endpoints"; import type { ToolDisclosure } from "../../tool-disclosure"; import type { OpenClawImagePluginInstall } from "../openclaw-plugin-restore"; import type { SandboxMcpState } from "../registry-mcp"; import type { SandboxMessagingState } from "../registry-messaging"; -export type RecordedSandboxPolicyAuthority = Exclude; - -interface PendingSandboxPolicyVerificationBoundary { +/** Bounded identity checkpoint for one incomplete sandbox create. */ +export interface PendingSandboxCreateIdentity { readonly schemaVersion: 1; readonly state: "verified-create"; readonly gatewayName: string; @@ -26,64 +22,6 @@ interface PendingSandboxPolicyVerificationBoundary { readonly sandboxIdentityFingerprint: string; readonly createAttemptNonce?: string; readonly route: "none" | "native" | "compatibility"; - readonly policyHash: string; - readonly policyVersion: number; -} - -/** Durable, incomplete create boundary recorded before post-create effects. */ -export type PendingSandboxPolicyVerification = PendingSandboxPolicyVerificationBoundary & - ( - | { - readonly policyAuthority: "nemoclaw-managed"; - readonly observedPolicyAuthority: "owner-unknown"; - readonly policyCreationReceipt: NemoClawPolicyCreationReceipt; - } - | { - readonly policyAuthority: "externally-managed"; - readonly observedPolicyAuthority: "externally-managed" | "owner-unknown"; - readonly policyCreationReceipt?: never; - } - ); -export interface CustomPolicyEntry { - name: string; - content: string; - /** Desired content reserved before a crash-safe generated-policy transition. */ - pendingContent?: string; - sourcePath?: string; - appliedAt?: string; - /** Content-bound authority for generated exact destination pins. */ - trustedPrivatePins?: TrustedPrivatePolicyPinReceipt; -} - -export interface BaselineExclusionEntry { - /** Persistence schema version for this reviewed exclusion intent. */ - version: 1; - /** Agent baseline that supplied the reviewed entry. */ - agent: string; - /** Exact baseline network policy key excluded, e.g. "nous_research". */ - key: string; - /** Digest of the reviewed baseline entry content the approval was bound to. */ - digest: string; - /** When the exclusion was acknowledged. */ - acknowledgedAt?: string; - /** Agent build/version recorded when the exclusion was last applied. */ - appliedAgentVersion?: string | null; -} - -export type BaselineExclusionTransitionOperation = "exclude" | "restore"; - -/** - * Durable journal for the one cross-system baseline mutation that is in flight. - * `baselineExclusions` remains the last committed operator intent until this - * transaction is published after the live OpenShell mutation succeeds. - */ -export interface BaselineExclusionTransition { - id: string; - operation: BaselineExclusionTransitionOperation; - exclusion: BaselineExclusionEntry; - /** Exact live-entry digest that completes the transition; null means absent. */ - targetLiveDigest: string | null; - startedAt: string; } // Outcome of the last live sandbox GPU proof run during onboarding/recovery. @@ -151,25 +89,8 @@ export interface SandboxEntry extends Partial { hostMounts?: SandboxHostMount[]; openshellDriver?: string | null; openshellVersion?: string | null; - /** Policy authority for a completed sandbox; absence means unknown. */ - policyAuthority?: RecordedSandboxPolicyAuthority; - /** Exact, secret-free proof that NemoClaw created this sandbox policy. */ - policyCreationReceipt?: NemoClawPolicyCreationReceipt; /** Verified create boundary retained until final registration publishes atomically. */ - pendingPolicyVerification?: PendingSandboxPolicyVerification; - policies?: string[]; - customPolicies?: CustomPolicyEntry[]; - /** Operator exclusions from the agent baseline policy, replayed on rebuild. */ - baselineExclusions?: BaselineExclusionEntry[]; - /** Crash-recoverable journal for an exclusion/restore live-policy mutation. */ - baselineExclusionTransition?: BaselineExclusionTransition; - policyTier?: string | null; - // True once the onboard policy step has fully completed and reconciled the - // effective preset selection (set by the post-policy registry write). Absent - // on a sandbox whose registration recorded only boot-time presets but whose - // policy step never finished — so re-onboard knows whether `policies` - // represents a final selection it can carry forward. See #4621. - policyPresetsFinalized?: boolean; + pendingCreateIdentity?: PendingSandboxCreateIdentity; webSearchEnabled?: boolean; /** Selected disclosure preference; model compatibility safeguards may downgrade runtime behavior. */ toolDisclosure?: ToolDisclosure; diff --git a/src/lib/state/retained-sandbox-recovery.test.ts b/src/lib/state/retained-sandbox-recovery.test.ts index 6fd70af1d27..3c28d2765c2 100644 --- a/src/lib/state/retained-sandbox-recovery.test.ts +++ b/src/lib/state/retained-sandbox-recovery.test.ts @@ -27,7 +27,6 @@ const evidence = { } as const; const recoveryAuthority = { createAttemptNonce: "c".repeat(62), - policyCreationReceipt: null, } as const; describe("retained sandbox recovery state", () => { @@ -40,7 +39,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "00000000-0000-4000-8000-000000000001", - verifiedEffectivePolicyIdentity: { hash: "sha256:policy-1", activeVersion: 1 }, ...recoveryAuthority, resources: evidence, reason: "cancelled_after_sandbox_creation", @@ -54,7 +52,6 @@ describe("retained sandbox recovery state", () => { sandboxName: "retained-sb", sandboxIdentityFingerprint: fingerprint, identityWasUnavailable: false, - verifiedEffectivePolicyIdentity: input.verifiedEffectivePolicyIdentity, resources: evidence, }); expect(fs.readFileSync(recovery.RETAINED_SANDBOX_RECOVERY_FILE, "utf8")).not.toContain( @@ -71,7 +68,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: null, - verifiedEffectivePolicyIdentity: null, ...recoveryAuthority, resources: { sharedInferenceProviders: [], @@ -96,7 +92,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw-18080", gatewayPort: 18080, lifecycleGeneration: "00000000-0000-4000-8000-000000000001", - verifiedEffectivePolicyIdentity: { hash: "sha256:policy-1", activeVersion: 1 }, ...recoveryAuthority, resources: evidence, reason: "cancelled_after_sandbox_creation", @@ -107,7 +102,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw-18080", gatewayPort: 18080, lifecycleGeneration: "00000000-0000-4000-8000-000000000002", - verifiedEffectivePolicyIdentity: { hash: "sha256:policy-2", activeVersion: 2 }, ...recoveryAuthority, resources: evidence, reason: "retained_after_sandbox_creation_failure", @@ -154,7 +148,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, ...recoveryAuthority, resources: evidence, reason: "retained_after_sandbox_creation_failure", @@ -190,7 +183,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, ...recoveryAuthority, resources: evidence, reason: "retained_after_sandbox_creation_failure", @@ -231,8 +223,7 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, - ...recoveryAuthority, + createAttemptNonce: recoveryAuthority.createAttemptNonce, }, ), ).toThrow(/state directory changed|lock ownership changed/u); @@ -255,7 +246,6 @@ describe("retained sandbox recovery state", () => { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "generation-1", - verifiedEffectivePolicyIdentity: null, ...recoveryAuthority, resources: evidence, reason: "cancelled_after_sandbox_creation", @@ -279,5 +269,4 @@ describe("retained sandbox recovery state", () => { ).toBeUndefined(); expect(recovery.listRetainedSandboxRecoveryRecords()).toEqual([recorded]); }); - }); diff --git a/src/lib/state/sandbox-manifest-publish.test.ts b/src/lib/state/sandbox-manifest-publish.test.ts index 4e085f0e5d7..6afde7f1aaa 100644 --- a/src/lib/state/sandbox-manifest-publish.test.ts +++ b/src/lib/state/sandbox-manifest-publish.test.ts @@ -6,7 +6,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { __test, type RebuildManifest } from "./sandbox.js"; +import { + __test, + clearRebuildPolicyHandoff, + readRebuildPolicyHandoff, + type RebuildManifest, + writeRebuildPolicyHandoff, +} from "./sandbox.js"; const tempDirs: string[] = []; @@ -24,8 +30,6 @@ function manifest(backupPath: string): RebuildManifest { dir: "/sandbox", backupPath, blueprintDigest: "digest", - policyPresets: [], - customPolicies: [], }; } @@ -80,3 +84,36 @@ describe("rebuild manifest publication", () => { ).toThrow("write failed"); }); }); + +describe("bounded rebuild policy handoff", () => { + it("binds exact content, rejects tampering, and retires manifest authority before cleanup", () => { + const backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-handoff-")); + tempDirs.push(backupPath); + const published = manifest(backupPath); + __test.writeManifest(backupPath, published); + + const policy = "version: 1\nnetwork_policies:\n host_preserved: {}\n"; + const withHandoff = writeRebuildPolicyHandoff(published, policy); + const handoffPath = path.join(backupPath, withHandoff.rebuildPolicyHandoff!.file); + expect(readRebuildPolicyHandoff(withHandoff)).toBe(policy); + const descriptor = fs.openSync(handoffPath, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW); + try { + expect(fs.fstatSync(descriptor).mode & 0o777).toBe(0o600); + fs.ftruncateSync(descriptor, 0); + fs.writeSync(descriptor, `${policy} raced: {}\n`, 0, "utf8"); + fs.fsyncSync(descriptor); + expect(readRebuildPolicyHandoff(withHandoff)).toBeNull(); + fs.ftruncateSync(descriptor, 0); + fs.writeSync(descriptor, policy, 0, "utf8"); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + + expect(clearRebuildPolicyHandoff(withHandoff)).toBe(true); + expect(fs.existsSync(handoffPath)).toBe(false); + expect( + JSON.parse(fs.readFileSync(path.join(backupPath, "rebuild-manifest.json"), "utf8")), + ).not.toHaveProperty("rebuildPolicyHandoff"); + }); +}); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index b5ab9b2ec07..8c71cd70fd2 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -83,7 +83,6 @@ import type { SandboxWorkloadReceipt, } from "./registry/types.js"; import { cloneSandboxWorkloadReceipt } from "./registry/workload.js"; -import type { CustomPolicyEntry } from "./registry.js"; import * as registry from "./registry.js"; import { isSshTransportFailure } from "./ssh-transport.js"; import { restoreStateFile } from "./state-file-restore.js"; @@ -130,16 +129,11 @@ export interface RebuildManifest { writableDir?: string; backupPath: string; blueprintDigest: string | null; - policyPresets?: string[]; - /** - * Custom policy presets applied via `--from-file`/`--from-dir`, captured with - * full content so they can be re-applied on restore without the source file. - * Like `policyPresets`, these live in the gateway policy engine and are - * otherwise lost on destroy/recreate. Always present on snapshots created since - * this field was added (possibly an empty array, so restore can reconcile a - * zero-custom snapshot); absent only on legacy manifests. - */ - customPolicies?: CustomPolicyEntry[]; + /** Bounded live-policy handoff retained only while a rebuild transaction is recoverable. */ + rebuildPolicyHandoff?: { + file: string; + sha256: string; + }; /** Allowlisted non-secret environment assignments captured for image recreation. */ preservedEnv?: PreservedEnvFile[]; /** @@ -317,16 +311,6 @@ function isInstanceBackup(value: unknown): value is InstanceBackup { ); } -function isCustomPolicyEntryArray(value: unknown): value is CustomPolicyEntry[] { - if (!Array.isArray(value)) return false; - if (value.length === 0) return true; - try { - return registry.normalizeCustomPolicyEntries(value) !== undefined; - } catch { - return false; - } -} - function cloneOpenClawImagePluginInstalls( installs: readonly OpenClawImagePluginInstall[], ): OpenClawImagePluginInstall[] { @@ -404,8 +388,13 @@ function isRebuildManifest(value: unknown): value is RebuildManifest { (value.blueprintDigest === undefined || value.blueprintDigest === null || typeof value.blueprintDigest === "string") && - (value.policyPresets === undefined || isStringArray(value.policyPresets)) && - (value.customPolicies === undefined || isCustomPolicyEntryArray(value.customPolicies)) && + (value.rebuildPolicyHandoff === undefined || + (isObjectRecord(value.rebuildPolicyHandoff) && + typeof value.rebuildPolicyHandoff.file === "string" && + typeof value.rebuildPolicyHandoff.sha256 === "string" && + /^[a-f0-9]{64}$/.test(value.rebuildPolicyHandoff.sha256) && + value.rebuildPolicyHandoff.file === + `rebuild-policy-handoff.${value.rebuildPolicyHandoff.sha256}.yaml`)) && (value.preservedEnv === undefined || (value.agentType === "hermes" && validatePreservedEnvFiles(value.preservedEnv, HERMES_PRESERVED_ENV_INVENTORY))) && @@ -1427,18 +1416,6 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = // a symlink swapped in between the first check and mkdirSync is caught here. rejectSymlinksOnPath(backupPath); - // Capture applied policy presets from the registry so they can be - // re-applied after rebuild. Presets live in the gateway policy engine, - // not on the sandbox filesystem, so they are lost on destroy/recreate. - const policyPresets: string[] = sb?.policies && sb.policies.length > 0 ? [...sb.policies] : []; - _log(`policyPresets from registry: [${policyPresets.join(",")}]`); - // Custom presets (--from-file/--from-dir) also live only in the gateway policy - // engine, so capture their full content for replay. Always record the field - // (even empty) so restore can tell a zero-custom snapshot (reconcile, remove - // any stale custom presets on the target) from a legacy snapshot (skip). - const customPolicies: CustomPolicyEntry[] = sb?.customPolicies ? [...sb.customPolicies] : []; - _log(`customPolicies from registry: [${customPolicies.map((c) => c.name).join(",")}]`); - const manifest: RebuildManifest = { version: MANIFEST_VERSION, sandboxName, @@ -1458,8 +1435,6 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = dir, backupPath, blueprintDigest: computeBlueprintDigest(), - policyPresets, - customPolicies, ...(agentName === "hermes" ? { preservedEnv: [] } : {}), ...snapshotAuthority, ...(providedName !== null ? { name: providedName } : {}), @@ -2556,6 +2531,103 @@ function writeManifest( export const __test = { writeManifest }; +function readBoundRebuildPolicyHandoff(filePath: string): string | null { + let descriptor: number | null = null; + try { + descriptor = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.size > 8n * 1024n * 1024n) return null; + const content = readFileSync(descriptor, "utf8"); + const after = fstatSync(descriptor, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + return null; + } + return content; + } catch { + return null; + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +/** Publish or replace the transaction-bound policy handoff beside its rebuild backup. */ +export function writeRebuildPolicyHandoff( + manifest: RebuildManifest, + policyDocument: string, +): RebuildManifest { + if (!policyDocument.trim()) throw new Error("Cannot persist an empty rebuild policy handoff"); + const sha256 = createHash("sha256").update(policyDocument).digest("hex"); + const file = `rebuild-policy-handoff.${sha256}.yaml`; + const filePath = path.join(manifest.backupPath, file); + let created = false; + let published = false; + try { + try { + writeFileSync(filePath, policyDocument, { encoding: "utf8", mode: 0o600, flag: "wx" }); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = readBoundRebuildPolicyHandoff(filePath); + if (existing !== policyDocument) { + throw new Error("Existing rebuild policy handoff does not match its content identity"); + } + } + const next = { + ...manifest, + rebuildPolicyHandoff: { file, sha256 }, + }; + writeManifest(manifest.backupPath, next); + const previousFile = manifest.rebuildPolicyHandoff?.file; + Object.assign(manifest, next); + published = true; + if (previousFile && previousFile !== file) { + rmSync(path.join(manifest.backupPath, previousFile), { force: true }); + } + return next; + } catch (error) { + // Roll back only a file that never became authoritative. Once the manifest + // is published, removing the new file would strand recovery on a dangling + // content identity if cleanup of the superseded handoff fails. + if (created && !published) rmSync(filePath, { force: true }); + throw error; + } +} + +/** Read a transaction-bound policy only when its exact published digest still matches. */ +export function readRebuildPolicyHandoff(manifest: RebuildManifest): string | null { + const handoff = manifest.rebuildPolicyHandoff; + if (!handoff) return null; + const content = readBoundRebuildPolicyHandoff(path.join(manifest.backupPath, handoff.file)); + if (content === null) return null; + return createHash("sha256").update(content).digest("hex") === handoff.sha256 ? content : null; +} + +/** Retire manifest authority before deleting the no-longer-needed handoff artifact. */ +export function clearRebuildPolicyHandoff(manifest: RebuildManifest): boolean { + const handoff = manifest.rebuildPolicyHandoff; + if (!handoff) return true; + const next = { ...manifest }; + delete next.rebuildPolicyHandoff; + try { + writeManifest(manifest.backupPath, next); + } catch { + return false; + } + delete manifest.rebuildPolicyHandoff; + try { + rmSync(path.join(manifest.backupPath, handoff.file), { force: true }); + return true; + } catch { + return false; + } +} + function readManifestPayload(backupPath: string): unknown | null { const manifestPath = path.join(backupPath, "rebuild-manifest.json"); if (!existsSync(manifestPath)) return null; diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index d5365fe01a7..a769fa90a43 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -27,12 +27,15 @@ import { resolveGatewayName } from "./onboard/gateway-binding"; import { classifyHermesPortableRegistry } from "./onboard/experimental/hermes-portable-onboarding"; import { inspectPortableAgentReceiptAuthorityForClassification } from "./onboard/experimental/hermes-portable-receipt"; import { defaultPortableDemoStateDir } from "./onboard/experimental/portable-runtime-receipt-readiness"; +import * as policy from "./policy"; import { summarizeForDebug } from "./state/onboard-session"; import * as registry from "./state/registry"; import { getHermesPortableHostAuthorityEntryCount } from "./state/portable-uninstall-retirement"; import { createSystemDeps, parseSshProcesses } from "./state/sandbox-session"; import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; +const INVENTORY_POLICY_PROBE_TIMEOUT_MS = 2_000; + function captureOpenshell( rootDir: string, args: string[], @@ -272,6 +275,13 @@ export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { return { listSandboxes: () => registry.listSandboxes(), + getPolicyPresets: (sandboxName) => { + try { + return policy.getAppliedPresets(sandboxName, INVENTORY_POLICY_PROBE_TIMEOUT_MS); + } catch { + return []; + } + }, getLiveInference: () => getLiveGatewayInference( (args, opts) => diff --git a/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts b/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts index f3ff7424759..bc12eb63148 100644 --- a/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts +++ b/test/agents/deepagents/deepagents-mcp-legacy-lifecycle.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ executeGatewaySupervisorAction: vi.fn(), executeSandboxCommand: vi.fn(), executeSandboxExecCommand: vi.fn(), + getSandboxPolicy: vi.fn(), getLiveSandboxPolicyEntryDigest: vi.fn(), getPresetContentGatewayState: vi.fn(), recoverNamedGatewayRuntime: vi.fn(), @@ -30,13 +31,18 @@ vi.mock("../../../src/lib/gateway-runtime-action", () => ({ recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, })); -vi.mock("../../../src/lib/policy", () => ({ +vi.mock("../../../src/lib/policy", async (importOriginal) => ({ + ...(await importOriginal()), applyPresetContent: mocks.applyPresetContent, getLiveSandboxPolicyEntryDigest: mocks.getLiveSandboxPolicyEntryDigest, getPresetContentGatewayState: mocks.getPresetContentGatewayState, removePreset: mocks.removePreset, })); +vi.mock("../../../src/lib/actions/sandbox/policy-get", () => ({ + getSandboxPolicy: mocks.getSandboxPolicy, +})); + vi.mock("../../../src/lib/actions/sandbox/process-recovery", () => ({ executeGatewaySupervisorAction: mocks.executeGatewaySupervisorAction, executeSandboxCommand: mocks.executeSandboxCommand, @@ -64,7 +70,6 @@ let providerResourceVersion = 1; let attached = true; let adapterRegistered = true; let adapterRemovalOutcome = ""; -let deepAgentsCapability = false; let policyApplyCalls = 0; let policyState = "match"; let adapterCalls: string[] = []; @@ -109,7 +114,6 @@ beforeEach(() => { attached = true; adapterRegistered = true; adapterRemovalOutcome = ""; - deepAgentsCapability = false; policyApplyCalls = 0; policyState = "match"; adapterCalls = []; @@ -120,8 +124,13 @@ beforeEach(() => { case command === "status --output json": return { status: 0, stdout: "ready", stderr: "" }; case args[0] === "provider" && args[1] === "profile": - return mockManagedEndpointlessProviderProfileRun(args) ?? - { status: 0, stdout: "Imported provider profile", stderr: "" }; + return ( + mockManagedEndpointlessProviderProfileRun(args) ?? { + status: 0, + stdout: "Imported provider profile", + stderr: "", + } + ); case args[0] === "provider" && args[1] === "get": return providerExists ? { @@ -179,6 +188,13 @@ beforeEach(() => { policyState = "absent"; return true; }); + mocks.getSandboxPolicy.mockReset().mockImplementation(() => ({ + raw: "", + yaml: + policyState === "absent" + ? "version: 1\nnetwork_policies: {}\n" + : "version: 1\nnetwork_policies:\n mcp_bridge_github: {}\n", + })); mocks.executeGatewaySupervisorAction.mockReset(); mocks.executeSandboxCommand @@ -187,9 +203,7 @@ beforeEach(() => { adapterCalls.push(command); switch (true) { case command === "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability": - return deepAgentsCapability - ? { status: 0, stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2\n", stderr: "" } - : { status: 2, stdout: "", stderr: "unknown option" }; + return { status: 2, stdout: "", stderr: "unknown option" }; case command.includes("servers.pop(payload['server'])"): { const outcome = adapterRemovalOutcome || (adapterRegistered ? "removed" : "absent"); adapterRegistered = outcome === "unowned" ? adapterRegistered : false; @@ -251,17 +265,6 @@ beforeEach(() => { gatewayName: "nemoclaw", mcp: { bridges: { github: entry } }, }); - registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml( - entry.server, - entry.url, - entry.adapter, - { addresses: ["8.8.8.8"] }, - entry.providerName, - ), - sourcePath: "generated:nemoclaw-mcp-bridge", - }); }); describe("legacy Deep Agents managed MCP lifecycle", () => { diff --git a/test/agents/deepagents/langchain-deepagents-code-fetch-proxy.test.ts b/test/agents/deepagents/langchain-deepagents-code-fetch-proxy.test.ts index f0f3448d728..892f56a7c57 100644 --- a/test/agents/deepagents/langchain-deepagents-code-fetch-proxy.test.ts +++ b/test/agents/deepagents/langchain-deepagents-code-fetch-proxy.test.ts @@ -129,9 +129,7 @@ print("root-owned-proxy-verification-ok") } }); - it.each( - ["example.com", "169.254.169.254", "127.0.0.1"], - )( + it.each(["example.com", "169.254.169.254", "127.0.0.1"])( "prepares read-only raw GitHub access without opening denied fetch targets [%s]", (deniedHost) => { const prepared = prepareInitialSandboxCreatePolicy( @@ -139,7 +137,6 @@ print("root-owned-proxy-verification-ok") [], { agentName: "langchain-deepagents-code", - policyTier: "balanced", additionalPresets: ["observability-otlp-local"], }, ); diff --git a/test/agents/hermes/hermes-home-channel-snapshot.test.ts b/test/agents/hermes/hermes-home-channel-snapshot.test.ts index a5c7e918f30..db76ec663ad 100644 --- a/test/agents/hermes/hermes-home-channel-snapshot.test.ts +++ b/test/agents/hermes/hermes-home-channel-snapshot.test.ts @@ -82,7 +82,6 @@ process.exit(result.status === null ? 1 : result.status); model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: "hermes", }, }, diff --git a/test/agents/hermes/hermes-kanban-snapshot.test.ts b/test/agents/hermes/hermes-kanban-snapshot.test.ts index 292a5c4cd6a..1b1c8662987 100644 --- a/test/agents/hermes/hermes-kanban-snapshot.test.ts +++ b/test/agents/hermes/hermes-kanban-snapshot.test.ts @@ -14,7 +14,8 @@ const ORIGINAL_HOME = process.env.HOME; const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-kanban-snapshot-")); process.env.HOME = TMP_HOME; const sandboxState = await import( - pathToFileURL(path.join(import.meta.dirname, "../../..", "src", "lib", "state", "sandbox.ts")).href + pathToFileURL(path.join(import.meta.dirname, "../../..", "src", "lib", "state", "sandbox.ts")) + .href ); afterAll(() => { @@ -38,7 +39,6 @@ function writeHermesRegistry(): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: "hermes", }, }, diff --git a/test/agents/hermes/hermes-mcp-shields-order.test.ts b/test/agents/hermes/hermes-mcp-shields-order.test.ts index 6576f7a0564..8d4601573df 100644 --- a/test/agents/hermes/hermes-mcp-shields-order.test.ts +++ b/test/agents/hermes/hermes-mcp-shields-order.test.ts @@ -62,6 +62,7 @@ const makeEntry = (server, addState) => ({ adapter: "hermes-config", url: "https://8.8.8.8/mcp", env: ["GITHUB_TOKEN"], + allowedIps: ["8.8.8.8"], providerName: "provider-" + server, providerId, policyName: "mcp-bridge-" + server, @@ -75,19 +76,6 @@ const register = (name, entry) => { gatewayName: "nemoclaw", ...(entry ? { mcp: { bridges: { [entry.server]: entry } } } : {}), }); - if (entry) { - registry.addCustomPolicy(name, { - name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml( - entry.server, - entry.url, - "hermes-config", - { addresses: ["8.8.8.8"] }, - entry.providerName, - ), - sourcePath: "generated:nemoclaw-mcp-bridge", - }); - } }; const messages = []; const capture = async (operation) => { @@ -138,8 +126,11 @@ const capture = async (operation) => { freshManifest?: unknown; }; expect(payload.messages).toHaveLength(4); - expect(payload.messages.every((message) => - message.includes("has shields up or an unreadable shields posture"))).toBe(true); + expect( + payload.messages.every((message) => + message.includes("has shields up or an unreadable shields posture"), + ), + ).toBe(true); expect(payload.mutations).toEqual([]); expect(payload.freshManifest).toBeUndefined(); }); diff --git a/test/agents/hermes/hermes-state-ledger-snapshot.test.ts b/test/agents/hermes/hermes-state-ledger-snapshot.test.ts index 29a926cb970..c0eac1ef97c 100644 --- a/test/agents/hermes/hermes-state-ledger-snapshot.test.ts +++ b/test/agents/hermes/hermes-state-ledger-snapshot.test.ts @@ -257,7 +257,6 @@ for (const name of ["SOUL.md", ".hermes_history"]) { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: "hermes", }, }, @@ -288,8 +287,11 @@ for (const name of ["SOUL.md", ".hermes_history"]) { const restore = sandboxState.restoreSandboxState("hermes", backupPath); expect(restore.success).toBe(true); expect(restore.restoredFiles).toEqual(backup.backedUpFiles); - expect(ledgers.every(([relativePath, content]) => - Object.is(readText(path.join(hermesHome, relativePath)), content))).toBe(true); + expect( + ledgers.every(([relativePath, content]) => + Object.is(readText(path.join(hermesHome, relativePath)), content), + ), + ).toBe(true); expect(readText(envPath)).toBe(replacementEnv); const loggedCommands = readText(sshLog); expect(loggedCommands).toContain("src_conn.backup(dst_conn)"); diff --git a/test/agents/openclaw/openclaw-config-restore.test.ts b/test/agents/openclaw/openclaw-config-restore.test.ts index d7bc88f154c..05609f27f3b 100644 --- a/test/agents/openclaw/openclaw-config-restore.test.ts +++ b/test/agents/openclaw/openclaw-config-restore.test.ts @@ -50,7 +50,6 @@ function writeOpenClawRegistry(sandboxName: string): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: null, }, }, diff --git a/test/agents/openclaw/openclaw-config-snapshot.test.ts b/test/agents/openclaw/openclaw-config-snapshot.test.ts index 7861bf5b392..b969b07c69f 100644 --- a/test/agents/openclaw/openclaw-config-snapshot.test.ts +++ b/test/agents/openclaw/openclaw-config-snapshot.test.ts @@ -112,7 +112,6 @@ function writeOpenClawRegistry(sandboxName: string): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: null, }, }, diff --git a/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts b/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts index 03e9451d7cb..43cf6672e4f 100644 --- a/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts +++ b/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -26,6 +27,10 @@ function readRepoFile(relativePath: string): string { return fs.readFileSync(path.join(root, relativePath), "utf8"); } +function qualificationReceiptDigest(contents: string): string { + return createHash("sha256").update(contents, "utf8").digest("hex"); +} + function currentSources(): PiArtifactSources { return { candidateAuthority: readRepoFile("src/lib/agent/candidate-authority.ts"), @@ -199,13 +204,11 @@ describe("Pi qualification receipts", () => { it("rejects an authority digest or cross-platform publication identity that drifts", () => { const sources = currentSources(); + const amd64Digest = qualificationReceiptDigest(sources.qualificationReceipts["linux/amd64"]); expect( verifyPiQualificationReceipts({ ...sources, - candidateAuthority: sources.candidateAuthority.replace( - "207930aaca3b1f233b32ddc0c5a3abe3db3123f34bb5b59a4233130befc16df5", - "f".repeat(64), - ), + candidateAuthority: sources.candidateAuthority.replace(amd64Digest, "f".repeat(64)), }), ).toContain( "src/lib/agent/candidate-authority.ts: accepted digests must match the exact Pi qualification receipts", @@ -216,7 +219,7 @@ describe("Pi qualification receipts", () => { qualificationReceipts: { ...sources.qualificationReceipts, "linux/arm64": sources.qualificationReceipts["linux/arm64"].replace( - '"revision": "d92acac1c40364702eaae92a169a2b06d1bfda4b"', + `"revision": "${JSON.parse(sources.qualificationReceipts["linux/arm64"]).source.revision as string}"`, `"revision": "${"e".repeat(40)}"`, ), }, @@ -226,15 +229,15 @@ describe("Pi qualification receipts", () => { it("rejects a stale commented Pi authority before the executed entry", () => { const sources = currentSources(); - const changedAuthority = sources.candidateAuthority.replace( - "207930aaca3b1f233b32ddc0c5a3abe3db3123f34bb5b59a4233130befc16df5", - "f".repeat(64), + const receiptDigests = Object.values(sources.qualificationReceipts).map( + qualificationReceiptDigest, ); + const changedAuthority = sources.candidateAuthority.replace(receiptDigests[0]!, "f".repeat(64)); const staleAuthority = changedAuthority.replace( "export const CANDIDATE_QUALIFICATION_RECEIPT_DIGESTS", `// pi: Object.freeze([ -// "207930aaca3b1f233b32ddc0c5a3abe3db3123f34bb5b59a4233130befc16df5", -// "1e49356ca9a910ea52fc7a0a70164aff8b056a5530e786c8ea0e54f79858e20e", +// "${receiptDigests[0]}", +// "${receiptDigests[1]}", // ]) export const CANDIDATE_QUALIFICATION_RECEIPT_DIGESTS`, ); diff --git a/test/automation/pull-requests/growth-guardrails.test.ts b/test/automation/pull-requests/growth-guardrails.test.ts index 6693a249565..6085ab4a37b 100644 --- a/test/automation/pull-requests/growth-guardrails.test.ts +++ b/test/automation/pull-requests/growth-guardrails.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { addedJavaScriptViolations, @@ -61,13 +61,30 @@ describe("codebase growth guardrails", () => { expect(violations, diagnostics.conditionals(violations)).toEqual([]); }); - it("does not add test loops directly, through one-use helpers, or through callback-forwarding helpers", async () => { - const violations = await loopGrowthViolations(diff); - expect(violations, diagnostics.loops(violations)).toEqual([]); - }); + it( + "does not add test loops directly, through one-use helpers, or through callback-forwarding helpers", + async () => { + const violations = await loopGrowthViolations(diff); + expect(violations, diagnostics.loops(violations)).toEqual([]); + }, + 60_000, + ); }); describe("codebase growth guardrail test support", () => { + it("caches repeated blob reads across guardrail checks", () => { + const read = vi.fn((file: string) => `${file} content`); + const cache = new Map(); + + expect(diffTestOnly.readFilesCached(["test/a.test.ts"], cache, read)).toEqual( + new Map([["test/a.test.ts", "test/a.test.ts content"]]), + ); + expect(diffTestOnly.readFilesCached(["test/a.test.ts"], cache, read)).toEqual( + new Map([["test/a.test.ts", "test/a.test.ts content"]]), + ); + expect(read).toHaveBeenCalledOnce(); + }); + it("rejects an added JavaScript file without rejecting an existing JavaScript rename", () => { expect( addedJavaScriptViolations([ diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index 9fae3fa4a31..5ce6a3fe972 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -89,21 +89,19 @@ function plan(...changedFiles: string[]) { } describe("deterministic PR risk plan", () => { - it.each([ - "inference-routing", - "managed-image-protected-runtime", - ])("classifies the controller-accepted %s job for the commit under review", (jobId) => { - expect(isPrE2eManualControllerJob(jobId)).toBe(true); - }); + it.each(["inference-routing", "managed-image-protected-runtime"])( + "classifies the controller-accepted %s job for the commit under review", + (jobId) => { + expect(isPrE2eManualControllerJob(jobId)).toBe(true); + }, + ); - it.each([ - "cloud-inference", - "security-posture", - "network-policy", - "jetson-nvmap-gpu", - ])("classifies %s as manual-only when the controller rejects the job", (jobId) => { - expect(isPrE2eManualControllerJob(jobId)).toBe(false); - }); + it.each(["cloud-inference", "security-posture", "network-policy", "jetson-nvmap-gpu"])( + "classifies %s as manual-only when the controller rejects the job", + (jobId) => { + expect(isPrE2eManualControllerJob(jobId)).toBe(false); + }, + ); it("emits a stable plan and digest for equivalent inputs", () => { const first = plan("src/lib/state/registry.ts", "src/lib/onboard.ts"); @@ -145,9 +143,7 @@ describe("deterministic PR risk plan", () => { it("combines gateway topology projections into one focused family (#10058)", () => { const result = plan(...GATEWAY_TOPOLOGY_FILES); - const topologyFamilies = result.families.filter( - (family) => family.id === "gateway-topology", - ); + const topologyFamilies = result.families.filter((family) => family.id === "gateway-topology"); expect(topologyFamilies).toEqual([ expect.objectContaining({ @@ -167,9 +163,7 @@ describe("deterministic PR risk plan", () => { ])("keeps gateway topology review scoped away from %s (#10058)", (changedFile) => { const result = plan(changedFile); - expect(result.families).not.toContainEqual( - expect.objectContaining({ id: "gateway-topology" }), - ); + expect(result.families).not.toContainEqual(expect.objectContaining({ id: "gateway-topology" })); }); it("keeps an unmapped live test behind the control-plane exception and cloud floor (#6446)", () => { @@ -272,39 +266,40 @@ describe("deterministic PR risk plan", () => { expect(riskPlanRequiredJobIds(result)).toEqual(expectedRequiredJobs); }); - it.each( - HERMES_CRON_RESTORE_FILES, - )("selects Hermes rebuild E2E for cron restore and drain changes in %s (#7806)", (changedFile) => { - const result = plan(changedFile); - const expectedRequiredJobs = changedFile.startsWith("agents/hermes/") - ? [...HERMES_SANDBOX_BOUNDARY_JOBS, "rebuild-hermes"] - : changedFile === "src/lib/actions/sandbox/rebuild-hermes-post-restore.ts" - ? [ - "managed-image-multiarch-startup", - "managed-image-protected-runtime", - "onboard-repair", - "onboard-resume", - "rebuild-hermes", - "rebuild-openclaw", - "state-backup-restore", - ] - : [ - "onboard-repair", - "onboard-resume", - "rebuild-hermes", - "rebuild-openclaw", - "state-backup-restore", - ]; - - expect(result.families).toContainEqual( - expect.objectContaining({ - id: "focused-e2e", - matchedFiles: [changedFile], - requiredJobs: ["rebuild-hermes"], - }), - ); - expect(riskPlanRequiredJobIds(result)).toEqual(expectedRequiredJobs); - }); + it.each(HERMES_CRON_RESTORE_FILES)( + "selects Hermes rebuild E2E for cron restore and drain changes in %s (#7806)", + (changedFile) => { + const result = plan(changedFile); + const expectedRequiredJobs = changedFile.startsWith("agents/hermes/") + ? [...HERMES_SANDBOX_BOUNDARY_JOBS, "rebuild-hermes"] + : changedFile === "src/lib/actions/sandbox/rebuild-hermes-post-restore.ts" + ? [ + "managed-image-multiarch-startup", + "managed-image-protected-runtime", + "onboard-repair", + "onboard-resume", + "rebuild-hermes", + "rebuild-openclaw", + "state-backup-restore", + ] + : [ + "onboard-repair", + "onboard-resume", + "rebuild-hermes", + "rebuild-openclaw", + "state-backup-restore", + ]; + + expect(result.families).toContainEqual( + expect.objectContaining({ + id: "focused-e2e", + matchedFiles: [changedFile], + requiredJobs: ["rebuild-hermes"], + }), + ); + expect(riskPlanRequiredJobIds(result)).toEqual(expectedRequiredJobs); + }, + ); it("does not select Hermes rebuild E2E for the generic recovery command (#7806)", () => { const result = plan("src/commands/sandbox/recover.ts"); @@ -313,29 +308,30 @@ describe("deterministic PR risk plan", () => { expect(riskPlanRequiredJobIds(result)).not.toContain("rebuild-hermes"); }); - it.each( - HERMES_MANAGED_POLICY_FILES, - )("selects every Hermes managed-policy live E2E job for %s (#8008)", (changedFile) => { - const result = plan(changedFile); - const isWrapper = changedFile === "agents/hermes/hermes-wrapper.py"; - const expectedFocusedJobs = isWrapper - ? HERMES_WRAPPER_FOCUSED_JOBS - : HERMES_MANAGED_POLICY_JOBS; - const expectedRequiredJobs = isWrapper - ? HERMES_WRAPPER_REQUIRED_JOBS - : changedFile === "src/lib/hermes-managed-route.ts" - ? HERMES_MANAGED_POLICY_JOBS - : HERMES_MANAGED_POLICY_REQUIRED_JOBS; - - const focusedFamily = result.families.find((family) => family.id === "focused-e2e"); - expect(focusedFamily).toEqual( - expect.objectContaining({ - matchedFiles: [changedFile], - requiredJobs: expectedFocusedJobs, - }), - ); - expect(riskPlanRequiredJobIds(result)).toEqual(expectedRequiredJobs); - }); + it.each(HERMES_MANAGED_POLICY_FILES)( + "selects every Hermes managed-policy live E2E job for %s (#8008)", + (changedFile) => { + const result = plan(changedFile); + const isWrapper = changedFile === "agents/hermes/hermes-wrapper.py"; + const expectedFocusedJobs = isWrapper + ? HERMES_WRAPPER_FOCUSED_JOBS + : HERMES_MANAGED_POLICY_JOBS; + const expectedRequiredJobs = isWrapper + ? HERMES_WRAPPER_REQUIRED_JOBS + : changedFile === "src/lib/hermes-managed-route.ts" + ? HERMES_MANAGED_POLICY_JOBS + : HERMES_MANAGED_POLICY_REQUIRED_JOBS; + + const focusedFamily = result.families.find((family) => family.id === "focused-e2e"); + expect(focusedFamily).toEqual( + expect.objectContaining({ + matchedFiles: [changedFile], + requiredJobs: expectedFocusedJobs, + }), + ); + expect(riskPlanRequiredJobIds(result)).toEqual(expectedRequiredJobs); + }, + ); it("does not select managed-policy E2E for an unrelated Hermes runtime file (#8008)", () => { const result = plan("agents/hermes/runtime-version.py"); @@ -695,24 +691,27 @@ describe("deterministic PR risk plan", () => { it.each([ "src/lib/onboard/machine/handlers/sandbox-resume.ts", "src/lib/onboard/machine/handlers/sandbox.ts", - ])("selects gateway upgrade and the Deep Agents Code target for journaled recreation changes in %s", (file) => { - const result = plan(file); - - expect(result.requiredJobs).toContainEqual( - expect.objectContaining({ - id: "openshell-gateway-upgrade", - families: ["focused-e2e"], - matchedFiles: [file], - }), - ); - expect(result.requiredTargets).toContainEqual( - expect.objectContaining({ - id: PR_E2E_TYPED_TARGET_IDS[0], - families: ["focused-e2e"], - matchedFiles: [file], - }), - ); - }); + ])( + "selects gateway upgrade and the Deep Agents Code target for journaled recreation changes in %s", + (file) => { + const result = plan(file); + + expect(result.requiredJobs).toContainEqual( + expect.objectContaining({ + id: "openshell-gateway-upgrade", + families: ["focused-e2e"], + matchedFiles: [file], + }), + ); + expect(result.requiredTargets).toContainEqual( + expect.objectContaining({ + id: PR_E2E_TYPED_TARGET_IDS[0], + families: ["focused-e2e"], + matchedFiles: [file], + }), + ); + }, + ); it("does not select the journaled recreation lanes for an adjacent sandbox handler", () => { const result = plan("src/lib/onboard/machine/handlers/sandbox-messaging.ts"); @@ -774,9 +773,7 @@ describe("deterministic PR risk plan", () => { const changedFile = "tools/e2e/onboard-timeout-contract.mts"; const result = plan(changedFile); - expect(riskPlanRequiredTargetIds(result)).toEqual([ - "ubuntu-repo-docker-post-reboot-recovery", - ]); + expect(riskPlanRequiredTargetIds(result)).toEqual(["ubuntu-repo-docker-post-reboot-recovery"]); expect(result.requiredTargets).toEqual([ expect.objectContaining({ id: "ubuntu-repo-docker-post-reboot-recovery", @@ -891,9 +888,9 @@ describe("deterministic PR risk plan", () => { jobs: ["inference-routing", "network-policy", "cloud-inference", "security-posture"], }, { - file: "src/lib/policy/managed-policy-binding.ts", - families: ["inference-policy", "credentials-security"], - jobs: ["inference-routing", "network-policy", "cloud-inference", "security-posture"], + file: "src/lib/shields/mcp-policy-transition.ts", + families: ["credentials-security"], + jobs: ["cloud-inference", "security-posture"], }, { file: "src/lib/shields/verify-lock.ts", diff --git a/test/channels/channels-add-bridge-lifecycle.test.ts b/test/channels/channels-add-bridge-lifecycle.test.ts index 534ec336765..afc62686aaa 100644 --- a/test/channels/channels-add-bridge-lifecycle.test.ts +++ b/test/channels/channels-add-bridge-lifecycle.test.ts @@ -120,7 +120,6 @@ beforeEach(() => { gatewayName: "nemoclaw", lifecycleGeneration: "generation-1", lifecycleLiveIdentityFingerprint: LIVE_IDENTITY_FINGERPRINT, - policies: [], } as SandboxEntry; vi.spyOn(registry, "getSandbox").mockImplementation(() => registryEntry); vi.spyOn(registry, "listSandboxes").mockImplementation(() => ({ @@ -155,7 +154,6 @@ beforeEach(() => { session = { sandboxName: "test-sb", - policyPresets: [], } as unknown as onboardSession.Session; vi.spyOn(onboardSession, "loadSession").mockReturnValue(session); vi.spyOn(onboardSession, "updateSession").mockImplementation((update) => { @@ -167,14 +165,12 @@ beforeEach(() => { // crosses the direct channel action, generic provider upsert, and OpenShell // refresh boundary. Individual failure tests override the spy below. providerSpy = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders"); - vi.spyOn( - policyChannelDependencies, - "revalidateChannelProviderPolicyAuthority", - ).mockImplementation(() => undefined); - vi.spyOn( - policyChannelDependencies, - "inspectMessagingProviderAttachmentTarget", - ).mockReturnValue(LIVE_IDENTITY_FINGERPRINT); + vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicy").mockImplementation( + () => undefined, + ); + vi.spyOn(policyChannelDependencies, "inspectMessagingProviderAttachmentTarget").mockReturnValue( + LIVE_IDENTITY_FINGERPRINT, + ); vi.spyOn(policyChannelDependencies, "rebuildSandbox").mockImplementation(async () => undefined); stopGooglechatWebhookTunnelSpy = vi .spyOn(policyChannelDependencies, "stopGooglechatWebhookTunnel") @@ -329,7 +325,6 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { "googlechat", ); expect(appliedPresets).not.toContain("googlechat"); - expect(session.policyPresets).not.toContain("googlechat"); expect(stopGooglechatWebhookTunnelSpy).toHaveBeenCalledWith("test-sb"); }); @@ -357,7 +352,6 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBe(SA_JSON); expect(registry.getConfiguredMessagingChannelsFromEntry(registryEntry)).toContain("googlechat"); expect(appliedPresets).toContain("googlechat"); - expect(session.policyPresets).toContain("googlechat"); expect(providerSpy).not.toHaveBeenCalled(); expect(openshellCalls()).toEqual([]); expect(policies.removePreset).not.toHaveBeenCalled(); @@ -399,7 +393,6 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { expect(startedPlan?.networkPolicy.presets).toContain("googlechat"); expect(policies.applyPreset).not.toHaveBeenCalled(); expect(appliedPresets).toContain("googlechat"); - expect(session.policyPresets).toContain("googlechat"); expect(providerSpy).not.toHaveBeenCalled(); expect(openshellCalls()).toEqual([]); expect(stopGooglechatWebhookTunnelSpy).not.toHaveBeenCalled(); diff --git a/test/channels/channels-add-preset.test.ts b/test/channels/channels-add-preset.test.ts index 5efdf77605e..8f1050e1d34 100644 --- a/test/channels/channels-add-preset.test.ts +++ b/test/channels/channels-add-preset.test.ts @@ -5,10 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; -import { - addSandboxChannel, - removeSandboxChannel, -} from "../../src/lib/actions/sandbox/policy-channel"; +import { addSandboxChannel } from "../../src/lib/actions/sandbox/policy-channel"; import { policyChannelDependencies } from "../../src/lib/actions/sandbox/policy-channel-dependencies"; import * as processRecovery from "../../src/lib/actions/sandbox/process-recovery"; import * as httpProbe from "../../src/lib/adapters/http/probe"; @@ -144,8 +141,6 @@ let appliedPresets: string[]; let presetContent: string | null; let applyPresetResult: boolean; let sessionState: onboardSession.Session | null; -let sessionUpdateThrows: boolean; -let sessionUpdates: Array<{ policyPresets: string[] | null }>; let callOrder: string[]; let slackBotProbe: ProbeResult; let slackAppProbe: ProbeResult; @@ -166,11 +161,8 @@ async function expectExit(action: () => Promise): Promise { expect(exitSpy).toHaveBeenCalledWith(1); } -function setSession( - sandboxName: string | null = "test-sb", - policyPresets: string[] | null = ["npm", "pypi", "huggingface", "brew"], -): void { - sessionState = { sandboxName, policyPresets } as onboardSession.Session; +function setSession(sandboxName: string | null = "test-sb"): void { + sessionState = onboardSession.createSession({ sandboxName }); } let stdinIsTty: PropertyDescriptor | undefined; @@ -194,8 +186,6 @@ beforeEach(() => { presetContent = "network_policies:\n stub:\n egress:\n - host: example.com\n"; applyPresetResult = true; setSession(); - sessionUpdateThrows = false; - sessionUpdates = []; callOrder = []; slackBotProbe = successfulProbe(); slackAppProbe = successfulProbe('{"ok":true,"url":"wss://wss-primary.slack.com/link"}'); @@ -213,10 +203,9 @@ beforeEach(() => { exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new ExitError(code); }) as never); - vi.spyOn( - policyChannelDependencies, - "revalidateChannelProviderPolicyAuthority", - ).mockImplementation(() => undefined); + vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicy").mockImplementation( + () => undefined, + ); vi.spyOn(registry, "getSandbox").mockImplementation(() => registryEntry); vi.spyOn(registry, "listSandboxes").mockImplementation(() => ({ @@ -265,22 +254,6 @@ beforeEach(() => { }); vi.spyOn(onboardSession, "loadSession").mockImplementation(() => sessionState); - vi.spyOn(onboardSession, "updateSession").mockImplementation((mutator) => { - sessionUpdateThrows - ? (() => { - throw new Error("simulated save failure"); - })() - : undefined; - sessionState ??= { sandboxName: null, policyPresets: null } as onboardSession.Session; - const next = mutator(sessionState as onboardSession.Session) || sessionState; - sessionState = next as onboardSession.Session; - sessionUpdates.push({ - policyPresets: Array.isArray(sessionState.policyPresets) - ? [...sessionState.policyPresets] - : sessionState.policyPresets, - }); - return sessionState; - }); providerSpy = vi .spyOn(policyChannelDependencies, "upsertMessagingProviders") @@ -578,7 +551,6 @@ describe("channels add applies a matching policy preset (#3437)", () => { }); expect(updateSandboxSpy).not.toHaveBeenCalled(); expect(deleteCredentialSpy).toHaveBeenCalledWith("TELEGRAM_BOT_TOKEN"); - expect(sessionUpdates).toEqual([]); expect(callOrder).not.toContain("promptAndRebuild"); }); @@ -718,100 +690,6 @@ describe("channels add applies a matching policy preset (#3437)", () => { }); }); -describe("channels add/remove keeps session.policyPresets in sync with registry", () => { - it("appends the channel preset to session.policyPresets after a successful add", async () => { - await addSandboxChannel("test-sb", { channel: "slack" }); - - expect(sessionUpdates).toEqual([ - { policyPresets: ["npm", "pypi", "huggingface", "brew", "slack"] }, - ]); - expect(sessionState?.policyPresets).toEqual(["npm", "pypi", "huggingface", "brew", "slack"]); - }); - - it("does not touch the session when it tracks a different sandbox", async () => { - setSession("other-sb", ["npm", "github"]); - - await addSandboxChannel("test-sb", { channel: "slack" }); - - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { - disclosedPresetState: "absent", - includeMessagingCredentialBindings: true, - }); - expect(sessionUpdates).toEqual([]); - expect(sessionState?.policyPresets).toEqual(["npm", "github"]); - }); - - it("succeeds even when no onboard session file exists", async () => { - sessionState = null; - - await addSandboxChannel("test-sb", { channel: "slack" }); - - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { - disclosedPresetState: "absent", - includeMessagingCredentialBindings: true, - }); - expect(sessionUpdates).toEqual([]); - expect(callOrder).toContain("promptAndRebuild"); - }); - - it("does not abort channels-add when session save fails", async () => { - sessionUpdateThrows = true; - - await addSandboxChannel("test-sb", { channel: "slack" }); - - expect(applyPresetSpy).toHaveBeenCalledWith("test-sb", "slack", { - disclosedPresetState: "absent", - includeMessagingCredentialBindings: true, - }); - expect(callOrder).toContain("promptAndRebuild"); - }); - - it("removes the channel preset from session.policyPresets after a successful remove", async () => { - appliedPresets = ["slack"]; - setSession("test-sb", ["npm", "slack", "github"]); - - await removeSandboxChannel("test-sb", { channel: "slack" }); - - expect(removePresetSpy).toHaveBeenCalledWith("test-sb", "slack"); - expect(sessionUpdates).toEqual([{ policyPresets: ["npm", "github"] }]); - expect(sessionState?.policyPresets).toEqual(["npm", "github"]); - expect(callOrder).toContain("promptAndRebuild"); - }); - - it("does not touch a foreign session during channels-remove", async () => { - appliedPresets = ["slack"]; - setSession("other-sb", ["slack", "npm"]); - - await removeSandboxChannel("test-sb", { channel: "slack" }); - - expect(removePresetSpy).toHaveBeenCalledWith("test-sb", "slack"); - expect(sessionUpdates).toEqual([]); - expect(sessionState?.policyPresets).toEqual(["slack", "npm"]); - }); - - it("succeeds during channels-remove when no onboard session file exists", async () => { - appliedPresets = ["slack"]; - sessionState = null; - - await removeSandboxChannel("test-sb", { channel: "slack" }); - - expect(removePresetSpy).toHaveBeenCalledWith("test-sb", "slack"); - expect(sessionUpdates).toEqual([]); - expect(callOrder).toContain("promptAndRebuild"); - }); - - it("does not abort channels-remove when session save fails", async () => { - appliedPresets = ["slack"]; - setSession("test-sb", ["npm", "slack"]); - sessionUpdateThrows = true; - - await removeSandboxChannel("test-sb", { channel: "slack" }); - - expect(removePresetSpy).toHaveBeenCalledWith("test-sb", "slack"); - expect(callOrder).toContain("promptAndRebuild"); - }); -}); - describe("channels add verifies bridge startup after rebuild (#4314, #4390)", () => { beforeEach(() => { delete process.env.NEMOCLAW_NON_INTERACTIVE; diff --git a/test/channels/channels-remove-full-teardown.test.ts b/test/channels/channels-remove-full-teardown.test.ts index 14a61458c42..fc263ad010a 100644 --- a/test/channels/channels-remove-full-teardown.test.ts +++ b/test/channels/channels-remove-full-teardown.test.ts @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // // Regression test for #3998 — `nemoclaw channels remove ` -// must (1) strip the channel from session.policyPresets so onboard --resume -// does not re-apply the preset on rebuild, (2) wipe the channel's durable +// must (1) remove the channel preset from the live OpenShell policy, (2) wipe the channel's durable // state inside the sandbox so the rebuild's state_dirs backup does not // restore stale auth files, and (3) refuse to proceed to rebuild when the // in-sandbox cleanup for a QR-paired channel fails — otherwise the backup @@ -127,7 +126,6 @@ onboard.isNonInteractive = () => true; const onboardSession = require(${j("state/onboard-session.js")}); const sessionStore = { sandboxName: "test-sb", - policyPresets: ${JSON.stringify(presetNamesApplied)}, resumable: false, status: "complete", agent: ${JSON.stringify(sandboxAgent)}, @@ -140,7 +138,6 @@ const sessionStore = { nimContainer: null, routerPid: null, routerCredentialHash: null, - policyTier: null, messagingPlan: ${messagingPlanLiteral()}, hermesToolGateways: [], wechatConfig: null, @@ -154,7 +151,6 @@ registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(sandboxAgent)}, messaging: { schemaVersion: 1, plan: ${messagingPlanLiteral()} }, - policies: ${JSON.stringify(presetNamesApplied)}, }); registry.updateSandbox = (name, updates) => { registryUpdates.push({ name, updates }); @@ -202,7 +198,7 @@ module.exports = { describe("channels remove full teardown (#3998)", () => { it.each(["openclaw", "hermes"] as const)( - "strips '%s' session.policyPresets and clears the in-sandbox whatsapp state dir", + "removes the live '%s' channel policy and clears the in-sandbox whatsapp state dir", (sandboxAgent) => { const script = `${buildPreamble({ sandboxAgent })} const ctx = module.exports; @@ -211,7 +207,6 @@ const ctx = module.exports; await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); process.stdout.write("\\n__RESULT__" + JSON.stringify({ sandboxExecCalls: ctx.sandboxExecCalls, - sessionPolicyPresets: ctx.sessionStore.policyPresets, removedPresets: ctx.removedPresets, callOrder: ctx.callOrder, exitCode: ctx.getExitCode(), @@ -239,16 +234,6 @@ const ctx = module.exports; `expected one removePreset('whatsapp') call; got ${JSON.stringify(payload.removedPresets)}`, ); - assert.ok( - !payload.sessionPolicyPresets.includes("whatsapp"), - `session.policyPresets must not contain 'whatsapp' after remove (resume would reapply it). Got: ${JSON.stringify(payload.sessionPolicyPresets)}`, - ); - assert.deepEqual( - payload.sessionPolicyPresets, - ["npm", "pypi", "huggingface", "brew"], - "non-channel presets must stay in session.policyPresets", - ); - const cleanupCalls = payload.sandboxExecCalls.filter((c: { command: string }) => c.command.startsWith("rm -rf"), ); @@ -351,7 +336,6 @@ const ctx = module.exports; const dumpState = () => ({ sandboxExecCalls: ctx.sandboxExecCalls, sandboxSshCalls: ctx.sandboxSshCalls, - sessionPolicyPresets: ctx.sessionStore.policyPresets, removedPresets: ctx.removedPresets, registryUpdates: ctx.registryUpdates, callOrder: ctx.callOrder, @@ -391,12 +375,6 @@ const ctx = module.exports; [], "registry must NOT be mutated when we bail early on cleanup failure", ); - assert.deepEqual( - payload.sessionPolicyPresets, - ["npm", "pypi", "huggingface", "brew", "whatsapp"], - "session.policyPresets must be unchanged on early-bail", - ); - const cleanupCalls = payload.sandboxExecCalls.filter((c: { command: string }) => c.command.startsWith("rm -rf"), ); @@ -412,57 +390,6 @@ const ctx = module.exports; ); }); - it("treats a leftover session.policyPresets entry as residue and runs cleanup", () => { - const script = `${buildPreamble({ - presetNamesApplied: ["npm", "pypi", "whatsapp"], - sandboxAgent: "openclaw", - channelInRegistry: "telegram", - })} -const ctx = module.exports; -const registryOverride = require(${JSON.stringify(path.join(repoRoot, "src", "lib", "state/registry.ts"))}); -registryOverride.getSandbox = () => ({ - name: "test-sb", - agent: "openclaw", - policies: [], -}); -const policiesOverride = require(${JSON.stringify(path.join(repoRoot, "src", "lib", "policy/index.ts"))}); -policiesOverride.getAppliedPresets = () => []; -(async () => { - try { - await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - sandboxExecCalls: ctx.sandboxExecCalls, - sessionPolicyPresets: ctx.sessionStore.policyPresets, - callOrder: ctx.callOrder, - exitCode: ctx.getExitCode(), - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - const cleanupCalls = payload.sandboxExecCalls.filter((c: { command: string }) => - c.command.startsWith("rm -rf"), - ); - assert.equal( - cleanupCalls.length, - 1, - `cleanup must run when only session.policyPresets has residue; got ${JSON.stringify(payload.sandboxExecCalls)}`, - ); - assert.ok( - !payload.sessionPolicyPresets.includes("whatsapp"), - `session.policyPresets must be stripped after the residue-driven cleanup`, - ); - assert.equal(payload.exitCode, null, "must not abort when sandbox-exec succeeds"); - }); - it("does not abort when removing a never-configured QR channel even if sandbox is unreachable", () => { const script = `${buildPreamble({ presetNamesApplied: ["npm", "pypi"], @@ -514,7 +441,7 @@ const ctx = module.exports; ); }); - it("leaves non-whatsapp presets in session.policyPresets untouched when removing a token-based channel", () => { + it("removes the live preset and messaging plan entry for a token-based channel", () => { const script = `${buildPreamble({ presetNamesApplied: ["npm", "pypi", "telegram", "brew"], sandboxAgent: "openclaw", @@ -525,7 +452,7 @@ const ctx = module.exports; try { await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "telegram" }); process.stdout.write("\\n__RESULT__" + JSON.stringify({ - sessionPolicyPresets: ctx.sessionStore.policyPresets, + removedPresets: ctx.removedPresets, registryUpdates: ctx.registryUpdates, }) + "\\n"); } catch (err) { @@ -540,14 +467,10 @@ const ctx = module.exports; const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - assert.ok( - !payload.sessionPolicyPresets.includes("telegram"), - `session.policyPresets must drop 'telegram' after channel remove. Got: ${JSON.stringify(payload.sessionPolicyPresets)}`, - ); assert.deepEqual( - payload.sessionPolicyPresets, - ["npm", "pypi", "brew"], - "other presets must remain after removing a token-based channel", + payload.removedPresets, + [{ sandboxName: "test-sb", presetName: "telegram" }], + "the command must remove the channel preset from the live OpenShell policy", ); const messagingPlanUpdate = payload.registryUpdates.findLast( diff --git a/test/cli/channel-status-json.test.ts b/test/cli/channel-status-json.test.ts index 1f35c57ab03..f572a129179 100644 --- a/test/cli/channel-status-json.test.ts +++ b/test/cli/channel-status-json.test.ts @@ -19,7 +19,6 @@ it("keeps the detailed JSON envelope when paused Telegram skips its live probe ( fs.mkdirSync(bin, { recursive: true }); writeSandboxRegistry(home, sandboxName, { agent: "openclaw", - policies: ["telegram"], messaging: { schemaVersion: 1, plan: makeMessagingPlan({ diff --git a/test/cli/connect-readiness.test.ts b/test/cli/connect-readiness.test.ts index 487becb062a..077ec82fd29 100644 --- a/test/cli/connect-readiness.test.ts +++ b/test/cli/connect-readiness.test.ts @@ -32,7 +32,6 @@ describe("CLI connect readiness", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -122,7 +121,6 @@ describe("CLI connect readiness", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -209,7 +207,6 @@ describe("CLI connect readiness", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/cli/connect-recovery.test.ts b/test/cli/connect-recovery.test.ts index f7545144570..92b83b36a2d 100644 --- a/test/cli/connect-recovery.test.ts +++ b/test/cli/connect-recovery.test.ts @@ -479,7 +479,6 @@ describe("CLI connect recovery process contracts", () => { credentialEnv: null, preferredInferenceApi: null, nimContainer: null, - policyPresets: null, metadata: { gatewayName: "nemoclaw" }, steps: { preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, diff --git a/test/cli/debug-command.test.ts b/test/cli/debug-command.test.ts index fb05c73b411..38913622d30 100644 --- a/test/cli/debug-command.test.ts +++ b/test/cli/debug-command.test.ts @@ -228,7 +228,6 @@ describe("CLI debug command", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "ghost", diff --git a/test/cli/destroy-detach-order.test.ts b/test/cli/destroy-detach-order.test.ts index ba2a880670f..5a4e92a6966 100644 --- a/test/cli/destroy-detach-order.test.ts +++ b/test/cli/destroy-detach-order.test.ts @@ -34,7 +34,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/cli/destroy-gateway-cleanup.test.ts b/test/cli/destroy-gateway-cleanup.test.ts index da17fe7e8c2..3afffa4efdd 100644 --- a/test/cli/destroy-gateway-cleanup.test.ts +++ b/test/cli/destroy-gateway-cleanup.test.ts @@ -34,7 +34,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -107,7 +106,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], gatewayName: "nemoclaw-8081", gatewayPort: 8081, }, @@ -191,7 +189,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -283,7 +280,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], gatewayName: "nemoclaw-8081", gatewayPort: 8081, }, @@ -354,7 +350,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -429,7 +424,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], gatewayName: "nemoclaw-8081", gatewayPort: 8081, }, @@ -438,7 +432,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -503,7 +496,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -570,7 +562,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], gatewayName: "nemoclaw-8081", gatewayPort: 8081, }, @@ -664,7 +655,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], gatewayName: "nemoclaw-8081", gatewayPort: 8081, }, @@ -729,7 +719,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -790,7 +779,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -871,7 +859,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/cli/destroy-gateway-unreachable.test.ts b/test/cli/destroy-gateway-unreachable.test.ts index 8e111364093..27e5bade9ea 100644 --- a/test/cli/destroy-gateway-unreachable.test.ts +++ b/test/cli/destroy-gateway-unreachable.test.ts @@ -43,7 +43,6 @@ function fixture(): { home: string; registryPath: string; localBin: string } { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index e3883ebe575..6d5ceaac59c 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -93,7 +93,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/cli/docker-outage.test.ts b/test/cli/docker-outage.test.ts index b1839eff0d1..d16068d3bd2 100644 --- a/test/cli/docker-outage.test.ts +++ b/test/cli/docker-outage.test.ts @@ -30,7 +30,6 @@ describe("Docker daemon outage classification (#4428)", () => { // sandboxes (#4428); record the driver so the gate matches. writeSandboxRegistry(home, "v053-baseline", { ...launchReadinessRegistryFixture(), - policies: ["npm"], openshellDriver: driver, } as unknown as Partial); fs.writeFileSync( diff --git a/test/cli/exit-code-user-error-surfaces.test.ts b/test/cli/exit-code-user-error-surfaces.test.ts index fc8d9e79c91..47790ae0c1b 100644 --- a/test/cli/exit-code-user-error-surfaces.test.ts +++ b/test/cli/exit-code-user-error-surfaces.test.ts @@ -114,7 +114,6 @@ describe("user-error/startup surfaces return non-zero exit (#5974)", () => { model: "test-model", provider: "test-provider", gpuEnabled: false, - policies: [], agent: "openclaw", }, }, diff --git a/test/cli/list-inference.test.ts b/test/cli/list-inference.test.ts index 3bc490adbab..86bab74af62 100644 --- a/test/cli/list-inference.test.ts +++ b/test/cli/list-inference.test.ts @@ -19,13 +19,11 @@ import { } from "./helpers"; describe("CLI dispatch", () => { - it.each( - [ - "inference set 2>&1", - "inference set --provider nvidia-prod 2>&1", - "inference set --model nvidia/model 2>&1", - ], - )( + it.each([ + "inference set 2>&1", + "inference set --provider nvidia-prod 2>&1", + "inference set --model nvidia/model 2>&1", + ])( "keeps `inference set` inside NemoClaw when provider or model is missing [%s]", (argv) => { const r = run(argv); @@ -199,7 +197,6 @@ describe("CLI dispatch", () => { model: "configured-model", provider: "configured-provider", gpuEnabled: true, - policies: ["pypi"], agent: "openclaw", }, }, @@ -245,7 +242,6 @@ describe("CLI dispatch", () => { model: "configured-model", provider: "configured-provider", gpuEnabled: true, - policies: ["pypi"], agent: "openclaw", isDefault: true, activeSessionCount: 1, @@ -255,6 +251,7 @@ describe("CLI dispatch", () => { sandboxGpuDevice: null, openshellDriver: null, openshellVersion: null, + policies: [], }, ], }); diff --git a/test/cli/list-share-live-inference.test.ts b/test/cli/list-share-live-inference.test.ts index dcae510e3f5..cd1ed442f47 100644 --- a/test/cli/list-share-live-inference.test.ts +++ b/test/cli/list-share-live-inference.test.ts @@ -81,7 +81,6 @@ describe("list shows live gateway inference", () => { model: "configured-model", provider: "configured-provider", gpuEnabled: true, - policies: ["pypi", "npm"], }, }, defaultSandbox: "test", @@ -100,6 +99,15 @@ describe("list shows live gateway inference", () => { " echo ' Version: 1'", " exit 0", "fi", + 'if [ "$1" = "policy" ] && [ "$2" = "get" ]; then', + " cat <<'YAML'", + "version: 1", + "network_policies:", + " pypi: {}", + " npm_yarn: {}", + "YAML", + " exit 0", + "fi", "exit 0", ].join("\n"), { mode: 0o755 }, @@ -113,11 +121,11 @@ describe("list shows live gateway inference", () => { expect(r.code).toBe(0); // Live gateway values render on the default sandbox's main row. expect(r.out).toContain( - "agent: openclaw model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod sandbox GPU policies: pypi, npm", + "agent: openclaw model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod sandbox GPU policies: npm, pypi", ); // The stale (stored) row must not appear. expect(r.out).not.toContain( - "agent: openclaw model: configured-model provider: configured-provider sandbox GPU policies: pypi, npm", + "agent: openclaw model: configured-model provider: configured-provider sandbox GPU policies: npm, pypi", ); // Onboarded values appear in an explicit live-gateway drift annotation. expect(r.out).toContain( @@ -140,7 +148,6 @@ describe("list shows live gateway inference", () => { model: "llama3.2:1b", provider: "ollama-local", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "test", @@ -179,7 +186,6 @@ describe("list shows live gateway inference", () => { model: "configured-model", provider: "nvidia-prod", gpuEnabled: false, - policies: ["pypi"], }); fs.writeFileSync( path.join(localBin, "openshell"), @@ -234,7 +240,6 @@ describe("list shows live gateway inference", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agentVersion: "2026.3.11", }, }, @@ -309,7 +314,6 @@ describe("list shows live gateway inference", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agentVersion: "9999.12.31", }, }, @@ -386,7 +390,6 @@ describe("list shows live gateway inference", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agentVersion: OPENCLAW_EXPECTED_VERSION, nemoclawVersion: "0.0.1", }, @@ -460,7 +463,6 @@ describe("list shows live gateway inference", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agentVersion: "2026.5.18", }, }, diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index 411667e92c6..c19cd43180c 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -145,7 +145,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -316,7 +315,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], messaging: { schemaVersion: 1, plan: { @@ -412,7 +410,6 @@ describe("CLI dispatch", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/cli/repro-2666-silent-list-status.test.ts b/test/cli/repro-2666-silent-list-status.test.ts index 87a8bf8d712..ed4bb0ccde5 100644 --- a/test/cli/repro-2666-silent-list-status.test.ts +++ b/test/cli/repro-2666-silent-list-status.test.ts @@ -44,7 +44,6 @@ function buildDepsWithThrowingRecovery(): ListSandboxesCommandDeps { model: "stored-model", provider: "stored-provider", gpuEnabled: false, - policies: ["pypi"], agent: "openclaw", }, ], @@ -128,7 +127,6 @@ describe("list-command-deps resilience wrapper (#2666)", () => { model: "test-model", provider: "test-provider", gpuEnabled: false, - policies: [], }, ], defaultSandbox: "my-assist", @@ -290,7 +288,6 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress model, provider: "nvidia-prod", gpuEnabled: false, - policies: [], ...(gatewayPort === undefined ? {} : { gatewayName: resolveGatewayName(gatewayPort), gatewayPort }), diff --git a/test/cli/sandbox-mutations.test.ts b/test/cli/sandbox-mutations.test.ts index 73930934b78..9bb19d39771 100644 --- a/test/cli/sandbox-mutations.test.ts +++ b/test/cli/sandbox-mutations.test.ts @@ -6,22 +6,15 @@ import path from "node:path"; import { describe, expect, test as it } from "../helpers/owned-test-resources"; import { - managedPolicyMetadata, + livePolicyMetadata, managedSandboxEntry, SANDBOX_ID, -} from "../helpers/managed-policy-receipt-fixture"; +} from "../helpers/live-policy-fixture"; import { runWithEnv, runWithInput, testTimeoutOptions, writeSandboxRegistry } from "./helpers"; -function readSandboxPolicies(home: string, sandboxName = "alpha"): string[] { - const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); - const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")) as { - sandboxes?: Record; - }; - const policies = registry.sandboxes?.[sandboxName]?.policies; - return Array.isArray(policies) - ? policies.filter((policy): policy is string => typeof policy === "string") - : []; +function readOpenShellPolicy(home: string): string { + return fs.readFileSync(path.join(home, "applied-policy.yaml"), "utf8"); } function writePolicyMutationOpenshellStub(home: string): string { @@ -29,6 +22,7 @@ function writePolicyMutationOpenshellStub(home: string): string { fs.mkdirSync(localBin, { recursive: true }); const openshell = path.join(localBin, "openshell"); const appliedPolicy = path.join(home, "applied-policy.yaml"); + fs.writeFileSync(appliedPolicy, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); fs.writeFileSync( openshell, [ @@ -40,17 +34,10 @@ function writePolicyMutationOpenshellStub(home: string): string { "fi", 'if [ "$1" = "policy" ] && [ "$2" = "get" ]; then', ' if [[ " $* " == *" --output json "* ]]; then', - ` printf '%s\\n' ${JSON.stringify(managedPolicyMetadata("alpha"))}`, + ` printf '%s\\n' ${JSON.stringify(livePolicyMetadata("alpha"))}`, " exit 0", " fi", - ` if [ -f ${JSON.stringify(appliedPolicy)} ]; then cat ${JSON.stringify(appliedPolicy)}; exit 0; fi`, - " cat <<'YAML'", - "version: 1", - "network_policies:", - " github:", - " name: github", - " host: github.com", - "YAML", + ` cat ${JSON.stringify(appliedPolicy)}`, " exit 0", "fi", 'if [ "$1" = "policy" ] && [ "$2" = "set" ]; then', @@ -149,7 +136,7 @@ describe("CLI dispatch", () => { ); expect(add.code).toBe(0); expect(add.out).toContain("Applied preset: github"); - expect(readSandboxPolicies(home)).toContain("github"); + expect(readOpenShellPolicy(home)).toContain("github:"); const remove = runWithEnv( "alpha policy-remove github -y", @@ -159,7 +146,7 @@ describe("CLI dispatch", () => { ); expect(remove.code).toBe(0); expect(remove.out).toContain("Removed preset: github"); - expect(readSandboxPolicies(home)).not.toContain("github"); + expect(readOpenShellPolicy(home)).not.toContain("github:"); }); it("keeps public policy-add non-interactive missing-preset failure before mutation", ({ @@ -179,7 +166,7 @@ describe("CLI dispatch", () => { expect(result.code).toBe(1); expect(result.out).toContain("Non-interactive mode requires a preset name."); - expect(readSandboxPolicies(home)).toEqual([]); + expect(readOpenShellPolicy(home)).toBe("version: 1\nnetwork_policies: {}\n"); }); it("keeps public policy-add missing-preset failure when stdin contains probe output", ({ @@ -201,7 +188,7 @@ describe("CLI dispatch", () => { expect(result.code).toBe(1); expect(result.out).toContain("Non-interactive mode requires a preset name."); expect(result.out).not.toContain("Unknown preset '/usr/bin/dmesg"); - expect(readSandboxPolicies(home)).toEqual([]); + expect(readOpenShellPolicy(home)).toBe("version: 1\nnetwork_policies: {}\n"); }); it("sandbox channels start rejects a sandbox missing from the registry (#4584)", ({ diff --git a/test/cli/sandbox-status-json.test.ts b/test/cli/sandbox-status-json.test.ts index 435621e497c..a6ba1b0e408 100644 --- a/test/cli/sandbox-status-json.test.ts +++ b/test/cli/sandbox-status-json.test.ts @@ -11,7 +11,6 @@ import { inferenceInvocationStubLines, runWithEnv, testTimeoutOptions, - writeHealthyDockerStub, writeSandboxRegistry, } from "./helpers"; @@ -116,7 +115,6 @@ describe("CLI sandbox status JSON output", testTimeoutOptions(20_000), () => { model: "configured-model", provider: "configured-provider", gpuEnabled: true, - policies: ["npm"], hostGpuDetected: true, sandboxGpuEnabled: true, sandboxGpuMode: "passthrough", @@ -201,7 +199,6 @@ describe("CLI sandbox status JSON output", testTimeoutOptions(20_000), () => { sandboxGpuDevice: "0", openshellDriver: "docker", openshellVersion: "0.0.44", - policies: ["npm"], rpcIssue: null, }); expect(typeof parsed.openshellDriver).toBe("string"); @@ -282,55 +279,57 @@ describe("CLI sandbox status JSON output", testTimeoutOptions(20_000), () => { ); }); - it.each([ - 401, 403, - ])("sandbox status --json fails an inference.local HTTP %s that rejects an agent request", (httpStatus) => { - const { home, localBin, sandboxName } = createInferenceRouteStatusSetup({ - routeOutput: `OK ${httpStatus}`, - invocationHttpStatus: String(httpStatus), - invocationExit: 1, - }); + it.each([401, 403])( + "sandbox status --json fails an inference.local HTTP %s that rejects an agent request", + (httpStatus) => { + const { home, localBin, sandboxName } = createInferenceRouteStatusSetup({ + routeOutput: `OK ${httpStatus}`, + invocationHttpStatus: String(httpStatus), + invocationExit: 1, + }); - const result = runWithEnv(`${sandboxName} status --json`, { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); + const result = runWithEnv(`${sandboxName} status --json`, { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); - expect(result.code).toBe(1); - const parsed = JSON.parse(result.out); - expect(parsed.inferenceHealth).toMatchObject({ - ok: false, - probed: true, - failureLabel: "unauthorized", - endpoint: "https://inference.local/v1/models", - }); - expect(parsed.inferenceHealth.detail).toContain(String(httpStatus)); - expect(parsed.inferenceHealth.subprobes).toContainEqual( - expect.objectContaining({ ok: true, probeLabel: "route reachability" }), - ); - }); + expect(result.code).toBe(1); + const parsed = JSON.parse(result.out); + expect(parsed.inferenceHealth).toMatchObject({ + ok: false, + probed: true, + failureLabel: "unauthorized", + endpoint: "https://inference.local/v1/models", + }); + expect(parsed.inferenceHealth.detail).toContain(String(httpStatus)); + expect(parsed.inferenceHealth.subprobes).toContainEqual( + expect.objectContaining({ ok: true, probeLabel: "route reachability" }), + ); + }, + ); - it.each([ - 401, 403, - ])("sandbox status --json keeps an inference.local HTTP %s reachable when it still serves an agent request (#6192)", (httpStatus) => { - const { home, localBin, sandboxName } = createInferenceRouteStatusSetup({ - routeOutput: `OK ${httpStatus}`, - }); + it.each([401, 403])( + "sandbox status --json keeps an inference.local HTTP %s reachable when it still serves an agent request (#6192)", + (httpStatus) => { + const { home, localBin, sandboxName } = createInferenceRouteStatusSetup({ + routeOutput: `OK ${httpStatus}`, + }); - const result = runWithEnv(`${sandboxName} status --json`, { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); + const result = runWithEnv(`${sandboxName} status --json`, { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); - expect(result.code).toBe(0); - const parsed = JSON.parse(result.out); - expect(parsed.inferenceHealth).toMatchObject({ - ok: true, - probed: true, - endpoint: "https://inference.local/v1/models", - }); - expect(parsed.inferenceHealth).not.toHaveProperty("failureLabel"); - }); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.out); + expect(parsed.inferenceHealth).toMatchObject({ + ok: true, + probed: true, + endpoint: "https://inference.local/v1/models", + }); + expect(parsed.inferenceHealth).not.toHaveProperty("failureLabel"); + }, + ); it("sandbox status --json fails closed when the injected CA bundle is missing (#6192)", () => { const { home, localBin, sandboxName } = createInferenceRouteStatusSetup({ diff --git a/test/cli/status-gateway-lifecycle.test.ts b/test/cli/status-gateway-lifecycle.test.ts index 476fe66e953..b0e2e4c00ca 100644 --- a/test/cli/status-gateway-lifecycle.test.ts +++ b/test/cli/status-gateway-lifecycle.test.ts @@ -32,7 +32,6 @@ describe("CLI status gateway lifecycle process contracts", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -80,7 +79,6 @@ describe("CLI status gateway lifecycle process contracts", () => { model: "configured-model", provider: "nvidia-prod", gpuEnabled: true, - policies: ["pypi"], }); fs.writeFileSync( path.join(localBin, "openshell"), diff --git a/test/cli/status-root-json.test.ts b/test/cli/status-root-json.test.ts index b87992bd444..b25163b6124 100644 --- a/test/cli/status-root-json.test.ts +++ b/test/cli/status-root-json.test.ts @@ -28,7 +28,6 @@ describe("CLI root status JSON", () => { model: "configured-model", provider: "configured-provider", gpuEnabled: true, - policies: ["npm"], agent: "openclaw", dashboardPort: 18789, messaging: { @@ -137,7 +136,6 @@ describe("CLI root status JSON", () => { model: "nvidia/nemotron", provider: "nvidia-prod", gpuEnabled: true, - policies: ["npm"], agent: "openclaw", dashboardPort: 18789, isDefault: true, @@ -174,7 +172,6 @@ describe("CLI root status JSON", () => { model: "configured-model", provider: "configured-provider", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", @@ -222,7 +219,6 @@ describe("CLI root status JSON", () => { model: "configured-model", provider: "configured-provider", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/credentials/rebuild-credential-preflight.test.ts b/test/credentials/rebuild-credential-preflight.test.ts index c662c4d1bd2..20c177b3486 100644 --- a/test/credentials/rebuild-credential-preflight.test.ts +++ b/test/credentials/rebuild-credential-preflight.test.ts @@ -102,7 +102,6 @@ wait();`, gatewayPort, dashboardPort: agent === "langchain-deepagents-code" ? 0 : 18789, fromDockerfile: null, - policies: [], agent, ...(agent === "langchain-deepagents-code" ? { @@ -140,7 +139,6 @@ wait();`, preferredInferenceApi: null, nimContainer: null, webSearchConfig: null, - policyPresets: [], messagingPlan: null, metadata: { gatewayName, fromDockerfile: null }, steps: { diff --git a/test/e2e-runtime/nemoclaw-cli-recovery.test.ts b/test/e2e-runtime/nemoclaw-cli-recovery.test.ts index a5898f41637..b6e529e0101 100644 --- a/test/e2e-runtime/nemoclaw-cli-recovery.test.ts +++ b/test/e2e-runtime/nemoclaw-cli-recovery.test.ts @@ -36,7 +36,6 @@ describe("nemoclaw CLI runtime recovery", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, }), diff --git a/test/e2e/README.md b/test/e2e/README.md index 165c70c6589..e9731f8e034 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -124,6 +124,13 @@ The managed-image scope does not claim trusted-private DNS-rebinding coverage: h `/etc/hosts` fixtures do not control the OpenShell supervisor's egress resolver. Full MCP bridge E2E coverage retains that assertion for environments with supervisor-authoritative DNS. +The same workflow publishes each Pi pull-request candidate by immutable digest after validating the +local image, removes registry credentials, validates the anonymously pullable digest, and uploads a +`managed-candidate-contract-*` artifact bound to the pull-request head. Pi remains outside the +`managed-pr-contract-*` all-agent catalog pattern and every release alias. The checked-in Pi +qualification receipts may consume these candidate contracts only when the recorded image-source +paths are unchanged through the receipt commit. + #### Timing Baseline The pre-change baseline uses GitHub Actions `Build CLI` step timings from these workflow runs: diff --git a/test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh b/test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh index ffcdedb7c51..0da01c638cd 100755 --- a/test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh +++ b/test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh @@ -238,7 +238,7 @@ NODE marker_output="$(observability_marker_value)" || fail "managed observability marker is absent" [ "$marker_output" = "1" ] || fail "managed observability marker has an unexpected value" -pass "host registry, live policy, and sandbox marker agree on enabled observability" +pass "host feature intent, live OpenShell policy, and sandbox marker agree on enabled observability" allowed_output="$(sandbox_python_probe POST \ "$OTLP_TRACE_URL" \ diff --git a/test/e2e/live/channels-add-remove.test.ts b/test/e2e/live/channels-add-remove.test.ts index 7ec3a5fb3dc..7a350b67550 100644 --- a/test/e2e/live/channels-add-remove.test.ts +++ b/test/e2e/live/channels-add-remove.test.ts @@ -181,16 +181,14 @@ function expectHostTelegramPlan(expected: "active" | "removed", context: string) const channel = channels.find((item) => item.channelId === "telegram"); const disabledChannels = stringArray(plan.disabledChannels); const credentialBindings = planArray(plan, "credentialBindings"); - const networkPolicy = - plan.networkPolicy && typeof plan.networkPolicy === "object" - ? (plan.networkPolicy as JsonRecord) - : {}; - const networkEntries = planArray(networkPolicy, "entries"); - const networkPresets = stringArray(networkPolicy.presets); expect(Object.hasOwn(plan, "agentRender"), "messaging.plan.agentRender should not persist").toBe( false, ); + expect( + Object.hasOwn(plan, "networkPolicy"), + "messaging.plan.networkPolicy should not persist", + ).toBe(false); expect( channels.some((entry) => Object.hasOwn(entry, "hooks")), "messaging.plan.channels hooks should not persist", @@ -203,14 +201,6 @@ function expectHostTelegramPlan(expected: "active" | "removed", context: string) ).toBeTruthy(); expect(channel?.active, `telegram plan active expected true ${context}`).toBe(true); expect(channel?.disabled, `telegram plan disabled unexpectedly true ${context}`).not.toBe(true); - expect( - networkPresets, - `telegram missing from messaging.plan.networkPolicy.presets ${context}`, - ).toContain("telegram"); - expect( - networkEntries.some((entry) => entry.channelId === "telegram"), - `telegram missing from messaging.plan.networkPolicy.entries ${context}`, - ).toBe(true); expect( credentialBindings.some( (entry) => entry.channelId === "telegram" && entry.providerEnvKey === "TELEGRAM_BOT_TOKEN", @@ -225,14 +215,6 @@ function expectHostTelegramPlan(expected: "active" | "removed", context: string) expect(disabledChannels, `telegram still present in disabledChannels ${context}`).not.toContain( "telegram", ); - expect( - networkPresets, - `telegram still present in networkPolicy.presets ${context}`, - ).not.toContain("telegram"); - expect( - networkEntries.some((entry) => entry.channelId === "telegram"), - `telegram still present in networkPolicy.entries ${context}`, - ).toBe(false); expect( credentialBindings.some((entry) => entry.channelId === "telegram"), `telegram credential binding still present ${context}`, @@ -419,10 +401,11 @@ test( sandboxName: SANDBOX_NAME, contract: [ "onboard creates an OpenClaw sandbox with no Telegram channel", - "channels add telegram registers the bridge and persists messaging.plan", + "channels add telegram registers the bridge and persists a policy-free messaging.plan", "post-add rebuild reuses the gateway-stored inference credential when COMPATIBLE_API_KEY is absent", "post-add rebuild applies the Telegram policy preset and renders openclaw.json channel state", "channels remove telegram removes provider, policy, registry plan, and rendered channel state after rebuild", + "an unrelated direct OpenShell policy edit survives channel add, remove, and both rebuilds", "post-remove rebuild does not use stale Telegram host env inputs that would stage a fresh channel add", ], }); @@ -486,6 +469,27 @@ test( }); await expectPolicyPreset(host, "telegram", "not-applied", "phase-2-policy-list-baseline"); + const hostPolicyEdit = await sandbox.openshell( + [ + "policy", + "update", + SANDBOX_NAME, + "--add-endpoint", + "host-edit-channels.example.com:443:read-only:rest:enforce", + "--rule-name", + "channels_host_edit_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: "phase-2-host-policy-edit-before-channel-add", + env: baseEnv(), + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + assertExitZero(hostPolicyEdit, "direct OpenShell policy edit before channel add"); + progress.phase("add Telegram and rebuild sandbox"); const add = await host.nemoclaw([SANDBOX_NAME, "channels", "add", "telegram"], { artifactName: "phase-3-channels-add-telegram", @@ -597,6 +601,16 @@ test( }); await expectProvider(host, "absent", "phase-6-provider-get-after-remove"); await expectPolicyPreset(host, "telegram", "not-applied", "phase-6-policy-list-after-remove"); + const policyAfterChannelLifecycle = await sandbox.openshell( + ["policy", "get", "--full", SANDBOX_NAME], + { + artifactName: "phase-6-policy-after-channel-lifecycle", + env: baseEnv(), + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + assertExitZero(policyAfterChannelLifecycle, "read policy after channel lifecycle"); + expect(policyAfterChannelLifecycle.stdout).toContain("channels_host_edit_e2e"); expectHostTelegramPlan("removed", "after remove+rebuild"); }, ); diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index 07ab754a6b2..eed5338eb02 100644 --- a/test/e2e/live/channels-stop-start-helpers.ts +++ b/test/e2e/live/channels-stop-start-helpers.ts @@ -115,12 +115,22 @@ interface GooglechatLiveE2eDependencies { } interface GooglechatCredentialFixtureDependencies { + readonly channelDependencies?: Pick< + typeof policyChannelDependencies, + "runGatewayOpenshell" | "upsertMessagingProviders" + >; readonly ensureProfiles?: typeof ensureMessagingBridgeProfiles; readonly providerDependencies?: ProviderDependencies; readonly root?: string; readonly run?: typeof runOpenshell; } +type InstalledGooglechatCredentialFixture = (() => void) & { + readonly upsertMessagingProviders: NonNullable< + AddSandboxChannelDependencies["upsertMessagingProviders"] + >; +}; + export const GOOGLECHAT_E2E_ACCESS_TOKEN = "e2e-fake-googlechat-access-token"; const PROVIDER_TYPE_BY_AGENT: Readonly> = { @@ -139,14 +149,80 @@ export function installGooglechatCredentialFixture( sandboxName: string, agent: AgentKind, dependencies: GooglechatCredentialFixtureDependencies = {}, -): () => void { +): InstalledGooglechatCredentialFixture { assertChannelsStopStartSandboxName(sandboxName, agent); const ensureProfiles = dependencies.ensureProfiles ?? ensureMessagingBridgeProfiles; + const channelDependencies = dependencies.channelDependencies ?? policyChannelDependencies; + const originalChannelUpsert = channelDependencies.upsertMessagingProviders; + const expectedName = `${sandboxName}-googlechat-bridge`; + const expectedType = PROVIDER_TYPE_BY_AGENT[agent]; + const directUpsert: InstalledGooglechatCredentialFixture["upsertMessagingProviders"] = ( + tokenDefs, + gatewayName, + options = {}, + ) => { + const fixtureTokenDefs = tokenDefs.filter(({ name }) => name === expectedName); + const fixtureTokenDef = fixtureTokenDefs[0]; + if ( + fixtureTokenDefs.length !== 1 || + fixtureTokenDef?.envKey !== "GOOGLE_CHAT_ACCESS_TOKEN" || + fixtureTokenDef?.providerType !== expectedType + ) { + throw new Error("Google Chat live fixture received an unexpected provider definition"); + } + const delegatedTokenDefs = tokenDefs.filter(({ name }) => name !== expectedName); + const delegatedProviderNames = + delegatedTokenDefs.length === 0 + ? [] + : originalChannelUpsert.call(channelDependencies, delegatedTokenDefs, gatewayName, options); + const effectiveRun: typeof runOpenshell = (args, runOptions) => + channelDependencies.runGatewayOpenshell(gatewayName, args, runOptions); + ensureProfiles(fixtureTokenDefs, { + root: dependencies.root ?? ROOT, + runOpenshell: effectiveRun, + redact: (value) => value.replaceAll(GOOGLECHAT_E2E_ACCESS_TOKEN, "[redacted]"), + }); + const existing = effectiveRun(["provider", "get", expectedName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (existing.status === 0 && options.replaceExisting) { + const removed = effectiveRun(["provider", "delete", expectedName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (removed.status !== 0) { + throw new Error(`Google Chat live fixture could not replace provider '${expectedName}'`); + } + } + const action = existing.status === 0 && !options.replaceExisting ? "update" : "create"; + const providerArgs = + action === "update" + ? ["provider", "update", expectedName, "--credential", "GOOGLE_CHAT_ACCESS_TOKEN"] + : [ + "provider", + "create", + "--name", + expectedName, + "--type", + expectedType, + "--credential", + "GOOGLE_CHAT_ACCESS_TOKEN", + ]; + const mutated = effectiveRun(providerArgs, { + env: { GOOGLE_CHAT_ACCESS_TOKEN: GOOGLECHAT_E2E_ACCESS_TOKEN }, + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (mutated.status !== 0) { + throw new Error(`Google Chat live fixture could not ${action} provider '${expectedName}'`); + } + const registered = new Set([...delegatedProviderNames, expectedName]); + return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); + }; const providerDependencies = dependencies.providerDependencies ?? onboardProviders; const root = dependencies.root ?? ROOT; const run = dependencies.run ?? runOpenshell; - const expectedName = `${sandboxName}-googlechat-bridge`; - const expectedType = PROVIDER_TYPE_BY_AGENT[agent]; const original = providerDependencies.upsertMessagingProviders; providerDependencies.upsertMessagingProviders = (tokenDefs, providerRun, options = {}) => { @@ -216,9 +292,10 @@ export function installGooglechatCredentialFixture( return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); }; - return () => { + const restore = () => { providerDependencies.upsertMessagingProviders = original; }; + return Object.assign(restore, { upsertMessagingProviders: directUpsert }); } const DEFAULT_GOOGLECHAT_DEPENDENCIES: GooglechatLiveE2eDependencies = { @@ -241,15 +318,22 @@ async function addGooglechatWithInstalledFixture( input: GooglechatLiveE2eComposition, audience: string, dependencies: GooglechatLiveE2eDependencies, + fixture: (() => void) & { + readonly upsertMessagingProviders?: AddSandboxChannelDependencies["upsertMessagingProviders"]; + }, ): Promise { + const providerDependency = fixture.upsertMessagingProviders + ? { upsertMessagingProviders: fixture.upsertMessagingProviders } + : {}; await dependencies.addSandboxChannel( input.sandboxName, { channel: "googlechat" }, input.agent === "openclaw" ? { googlechatNonInteractiveAudienceCapability: Object.freeze({ audience }), + ...providerDependency, } - : {}, + : providerDependency, ); } @@ -265,7 +349,7 @@ export async function addAndRebuildGooglechatForChannelsStopStartLiveE2e( const restore = dependencies.installCredentialFixture(input.sandboxName, input.agent); try { - await addGooglechatWithInstalledFixture(input, audience, dependencies); + await addGooglechatWithInstalledFixture(input, audience, dependencies, restore); await dependencies.rebuildSandbox(input.sandboxName, ["--yes"]); } finally { restore(); @@ -1077,6 +1161,26 @@ export async function runChannelsStopStartTarget({ `${channel} policy active`, ).toBe("active"); } + const hostPolicyEdit = await sandbox.openshell( + [ + "policy", + "update", + SANDBOX_NAME, + "--add-endpoint", + `host-edit-channels-${AGENT}.example.com:443:read-only:rest:enforce`, + "--rule-name", + "channels_stop_start_host_edit_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: `host-policy-edit-before-channel-stop-${AGENT}`, + env, + timeoutMs: 60_000, + }, + ); + expectExitZero(hostPolicyEdit, `${AGENT} direct OpenShell policy edit before channel stop`); progress.phase("disable channels and rebuild sandbox"); for (const channel of CHANNELS) await runChannelCommand(host, env, redactions, "stop", channel); @@ -1134,4 +1238,14 @@ export async function runChannelsStopStartTarget({ await expectHermesChannelConfigRemoved(sandbox, channel, redactions); } } + const policyAfterChannelLifecycle = await sandbox.openshell( + ["policy", "get", "--full", SANDBOX_NAME], + { + artifactName: `policy-after-channel-stop-start-${AGENT}`, + env, + timeoutMs: 60_000, + }, + ); + expectExitZero(policyAfterChannelLifecycle, `${AGENT} policy after channel stop/start`); + expect(policyAfterChannelLifecycle.stdout).toContain("channels_stop_start_host_edit_e2e"); } diff --git a/test/e2e/live/channels-stop-start-plan-state.ts b/test/e2e/live/channels-stop-start-plan-state.ts index 39abdf5c31b..5f578893767 100644 --- a/test/e2e/live/channels-stop-start-plan-state.ts +++ b/test/e2e/live/channels-stop-start-plan-state.ts @@ -33,6 +33,9 @@ export function channelPlanStateErrors( if (Object.hasOwn(persistedPlan, "agentRender")) { errors.push("messaging.plan.agentRender must not persist"); } + if (Object.hasOwn(persistedPlan, "networkPolicy")) { + errors.push("messaging.plan.networkPolicy must not persist"); + } const persistedChannels = persistedPlan.channels as Record[]; if (persistedChannels.some((channel) => Object.hasOwn(channel, "hooks"))) { @@ -40,12 +43,9 @@ export function channelPlanStateErrors( } const channel = plan.channels.find((entry) => entry.channelId === expectation.channelId); const disabledChannels = plan.disabledChannels; - const policyPresets = plan.networkPolicy.presets; - const policyEntries = plan.networkPolicy.entries; const credentialBindings = (persistedPlan.credentialBindings ?? []) as { channelId: string; }[]; - const hasPolicyEntry = policyEntries.some((entry) => entry.channelId === expectation.channelId); const hasCredentialBinding = credentialBindings.some( (entry) => entry.channelId === expectation.channelId, ); @@ -56,10 +56,6 @@ export function channelPlanStateErrors( if (disabledChannels.includes(expectation.channelId)) { errors.push(`${expectation.channelId} must be absent from disabledChannels`); } - if (policyPresets.includes(expectation.channelId)) { - errors.push(`${expectation.channelId} policy preset must be removed`); - } - if (hasPolicyEntry) errors.push(`${expectation.channelId} policy entry must be removed`); if (hasCredentialBinding) { errors.push(`${expectation.channelId} credential binding must be removed`); } @@ -85,10 +81,6 @@ export function channelPlanStateErrors( if (expectation.expected === "disabled" && !disabledChannels.includes(expectation.channelId)) { errors.push(`${expectation.channelId} must be present in disabledChannels while disabled`); } - if (!policyPresets.includes(expectation.channelId)) { - errors.push(`${expectation.channelId} policy preset must be present`); - } - if (!hasPolicyEntry) errors.push(`${expectation.channelId} policy entry must be present`); if (expectation.credentialBindingRequired && !hasCredentialBinding) { errors.push(`${expectation.channelId} credential binding must be present`); } diff --git a/test/e2e/live/common-egress-agent.test.ts b/test/e2e/live/common-egress-agent.test.ts index 8e7f8b78280..ad865f2e7e6 100644 --- a/test/e2e/live/common-egress-agent.test.ts +++ b/test/e2e/live/common-egress-agent.test.ts @@ -646,12 +646,12 @@ describe.sequential("common-egress agent live targets", () => { expect( await listActivePolicyPresets(host, OPENCLAW_BALANCED_SANDBOX, "c1-balanced-initial"), ).toEqual([ - { name: "brave", provenance: "from balanced tier" }, - { name: "brew", provenance: "from balanced tier" }, - { name: "huggingface", provenance: "from balanced tier" }, - { name: "npm", provenance: "from balanced tier" }, + { name: "brave", provenance: "from openclaw agent" }, + { name: "brew", provenance: "user-added" }, + { name: "huggingface", provenance: "user-added" }, + { name: "npm", provenance: "user-added" }, { name: "openclaw-pricing", provenance: "from openclaw agent" }, - { name: "pypi", provenance: "from balanced tier" }, + { name: "pypi", provenance: "user-added" }, ]); await assertPolicyAbsent( sandbox, @@ -665,12 +665,12 @@ describe.sequential("common-egress agent live targets", () => { expect( await listActivePolicyPresets(host, OPENCLAW_BALANCED_SANDBOX, "c1-after-weather-add"), ).toEqual([ - { name: "brave", provenance: "from balanced tier" }, - { name: "brew", provenance: "from balanced tier" }, - { name: "huggingface", provenance: "from balanced tier" }, - { name: "npm", provenance: "from balanced tier" }, + { name: "brave", provenance: "from openclaw agent" }, + { name: "brew", provenance: "user-added" }, + { name: "huggingface", provenance: "user-added" }, + { name: "npm", provenance: "user-added" }, { name: "openclaw-pricing", provenance: "from openclaw agent" }, - { name: "pypi", provenance: "from balanced tier" }, + { name: "pypi", provenance: "user-added" }, { name: "weather", provenance: "user-added" }, ]); await assertPolicyContains(sandbox, OPENCLAW_BALANCED_SANDBOX, "c1-policy", [ @@ -917,7 +917,7 @@ After web_fetch returns, reply exactly REFERENCE_AGENT_OK if the fetched respons progress.phase("verify Personal policy and absent Brave Search or Tavily Search API keys"); expect( await listActivePolicyPresets(host, OPENCLAW_PERSONAL_SANDBOX, "c4-personal-initial"), - ).toContainEqual({ name: "personal-open-internet", provenance: "from personal tier" }); + ).toContainEqual({ name: "personal-open-internet", provenance: "user-added" }); await assertPersonalRuntimeEgress(sandbox, OPENCLAW_PERSONAL_SANDBOX, "c4-personal", { beforeDeniedTargets: () => progress.phase("deny loopback and link-local targets"), beforePublicFetch: () => progress.phase("fetch a public website with curl"), diff --git a/test/e2e/live/double-onboard.test.ts b/test/e2e/live/double-onboard.test.ts index 512eec8829e..959cede60bb 100644 --- a/test/e2e/live/double-onboard.test.ts +++ b/test/e2e/live/double-onboard.test.ts @@ -447,7 +447,7 @@ async function prerequisiteOrSkip( skip(message); } -test("double-onboard: reuses gateway, preserves sibling sandbox, and recovers stale registry", { +test("double-onboard: reuses gateway, preserves sibling sandbox, and replaces stale registry", { timeout: TEST_TIMEOUT_MS, meta: { e2ePhases: [ @@ -457,7 +457,7 @@ test("double-onboard: reuses gateway, preserves sibling sandbox, and recovers st "recreate same sandbox on existing gateway", "onboard sibling sandbox with isolated dashboard", "stop sibling sandbox without disturbing the first forward", - "recover sandbox from stale registry", + "replace sandbox after stale registry refusal", "validate gateway-stop lifecycle guidance", "remove double-onboard resources", ], @@ -535,7 +535,7 @@ test("double-onboard: reuses gateway, preserves sibling sandbox, and recovers st "explicit same-name recreation preserves the healthy gateway", "different-name onboard preserves the first sandbox and allocates distinct dashboard forwards", "stopping one sandbox releases only its dashboard forward and reports the container stopped", - "stale OpenShell deletion preserves registry metadata through status/connect and rebuild recovers it", + "stale OpenShell deletion preserves registry metadata through status/connect and rebuild directs a clean replacement", "status after gateway stop gives explicit lifecycle guidance without deleting registry state", ], }); @@ -794,9 +794,10 @@ test("double-onboard: reuses gateway, preserves sibling sandbox, and recovers st ); expect(restoredForwardBAfterStart.owner, restoredForwardBAfterStart.output).toBe(SANDBOX_B); - progress.phase("recover sandbox from stale registry"); + progress.phase("replace sandbox after stale registry refusal"); // Phase 5: direct OpenShell deletion leaves a stale registry entry that - // status/connect preserve and rebuild can recover. + // status/connect preserve the stale record; rebuild refuses to invent its + // missing policy and directs an explicit clean replacement. await sandbox.openshell(["sandbox", "delete", SANDBOX_A], { artifactName: "phase-5-delete-sandbox-a-directly", env: commandEnv(), @@ -828,7 +829,7 @@ test("double-onboard: reuses gateway, preserves sibling sandbox, and recovers st expect(registryHas(SANDBOX_A), "connect removed stale registry entry").toBe(true); const rebuild = await command(host, [SANDBOX_A, "rebuild", "--yes"], { - artifactName: "phase-5-stale-rebuild-recovery", + artifactName: "phase-5-stale-rebuild-refusal", env: staleRebuildEnv(SANDBOX_A, fake.baseUrl), timeoutMs: PHASE_TIMEOUT_MS, }); @@ -837,9 +838,27 @@ test("double-onboard: reuses gateway, preserves sibling sandbox, and recovers st expect(rebuildText).not.toContain("Cannot back up state"); expect(rebuildText).not.toContain("does not exist"); expect(rebuildText).toContain("absent from the live OpenShell gateway"); - expect(rebuildText).toContain("No live workspace state to back up"); - expect(rebuildText).toContain("Creating new sandbox with current image"); - expect(rebuild.exitCode, rebuildText).toBe(0); + expect(rebuildText).toContain("Rebuild cannot recover its missing OpenShell policy"); + expect(rebuildText).toContain(`nemoclaw ${SANDBOX_A} destroy --yes`); + expect(rebuildText).toContain("nemoclaw onboard"); + expect(rebuildText).not.toContain("Creating new sandbox with current image"); + expect(rebuild.exitCode, rebuildText).not.toBe(0); + + const removeStale = await command(host, [SANDBOX_A, "destroy", "--yes"], { + artifactName: "phase-5-remove-stale-registry-a", + env: commandEnv(), + timeoutMs: RECOVERY_PROBE_TIMEOUT_MS, + }); + expect(removeStale.exitCode, resultText(removeStale)).toBe(0); + expect(registryHas(SANDBOX_A), "destroy kept stale sandbox A registry entry").toBe(false); + + const cleanReplacement = await runOnboard( + host, + SANDBOX_A, + fake.baseUrl, + "phase-5-clean-replacement-onboard", + ); + expect(cleanReplacement.exitCode, resultText(cleanReplacement)).toBe(0); const sandboxAAfterRebuild = await sandbox.openshell(["sandbox", "get", SANDBOX_A], { artifactName: "phase-5-openshell-sandbox-a-after-rebuild", diff --git a/test/e2e/live/mcp-bridge-sandbox.ts b/test/e2e/live/mcp-bridge-sandbox.ts index ced35f9c360..bc622a55968 100644 --- a/test/e2e/live/mcp-bridge-sandbox.ts +++ b/test/e2e/live/mcp-bridge-sandbox.ts @@ -20,6 +20,32 @@ export type CapturedManagedMcpPolicy = { policy: McpNetworkPolicy; }; +export async function applyMcpHostPolicyEdit( + sandbox: SandboxClient, + options: { artifactPrefix: string; sandboxName: string }, +): Promise { + const result = await sandbox.openshell( + [ + "policy", + "update", + options.sandboxName, + "--add-endpoint", + "host-edit-mcp.example.com:443:read-only:rest:enforce", + "--rule-name", + "mcp_host_edit_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: `${options.artifactPrefix}-host-policy-edit-before-mcp-add`, + env: buildAvailabilityProbeEnv(), + timeoutMs: 60_000, + }, + ); + assertExitZero(result, `${options.artifactPrefix} host policy edit before MCP add`); +} + type McpNetworkPolicy = { endpoints?: Array<{ host?: string; diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 50b26fe2865..70f561c31a2 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -62,8 +62,8 @@ import { retryHermesGatewayDraining, } from "./mcp-bridge-reliability.ts"; import { + applyMcpHostPolicyEdit, buildMcpDnsRebindingProbeScript, - captureManagedMcpPolicy, expectExitNonZero, hostAddressForSandbox, isExpectedMcpCurlPolicyDenial, @@ -88,7 +88,6 @@ import { } from "./mcp-provider-rewrite-probe.ts"; import { assertRawOpenShellAllowedIpsRebindingDenied } from "./openshell-allowed-ips-rebinding.ts"; import { prepareExactMainMcpProof } from "./openshell-exact-main-mcp-proof.ts"; - const OPENCLAW_SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-mcp-bridge"; const HERMES_SANDBOX_NAME = process.env.NEMOCLAW_MCP_HERMES_SANDBOX_NAME ?? "e2e-mcp-hermes"; const DEEPAGENTS_SANDBOX_NAME = process.env.NEMOCLAW_MCP_DEEPAGENTS_SANDBOX_NAME ?? "e2e-mcp-dcode"; @@ -108,7 +107,6 @@ function mcpBridgeShardTest(shard: McpBridgeShard) { } const test = mcpBridgeShardTest("openclaw"); type McpAgent = "openclaw" | "hermes" | "langchain-deepagents-code"; - function expectManagedImageQualificationReceipt(sandboxName: string, agent: McpAgent): void { const registry = JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as { sandboxes?: Record }>; @@ -118,7 +116,6 @@ function expectManagedImageQualificationReceipt(sandboxName: string, agent: McpA workload: registry.sandboxes?.[sandboxName]?.workload, }); } - async function onboardAgent( host: HostCliClient, cleanup: CleanupRegistry, @@ -190,6 +187,7 @@ async function assertSecretAbsentFromSandbox( } async function addBridgeAndReadStatus( host: HostCliClient, + sandbox: SandboxClient, options: { sandboxName: string; mcpUrl: string; @@ -197,6 +195,7 @@ async function addBridgeAndReadStatus( artifactPrefix: string; }, ): Promise { + await applyMcpHostPolicyEdit(sandbox, options); const add = await host.nemoclaw( [ options.sandboxName, @@ -478,6 +477,7 @@ async function removeBridgeAndAssertEmpty( }); expectExitZero(policy, `${options.artifactPrefix} policy after remove`); expect(resultText(policy)).not.toMatch(/mcp[-_]bridge[-_]fake/); + expect(resultText(policy)).toContain("mcp_host_edit_e2e"); const entry: McpBridgeEntry = { server: SERVER_NAME, agent: options.agent, @@ -811,7 +811,7 @@ test("mcp-bridge", { artifactPrefix: "openclaw", }); - const providerName = await addBridgeAndReadStatus(host, { + const providerName = await addBridgeAndReadStatus(host, sandbox, { sandboxName: OPENCLAW_SANDBOX_NAME, mcpUrl, expectedAdapter: "mcporter", @@ -1132,7 +1132,7 @@ mcpBridgeShardTest("hermes")( expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - const providerName = await addBridgeAndReadStatus(host, { + const providerName = await addBridgeAndReadStatus(host, sandbox, { sandboxName: HERMES_SANDBOX_NAME, mcpUrl, expectedAdapter: "hermes-config", @@ -1389,7 +1389,7 @@ mcpBridgeShardTest("deepagents")( artifactPrefix: "deepagents", }); - const providerName = await addBridgeAndReadStatus(host, { + const providerName = await addBridgeAndReadStatus(host, sandbox, { sandboxName: DEEPAGENTS_SANDBOX_NAME, mcpUrl, expectedAdapter: "deepagents-config", diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 46c2f261f00..e1517e4dc09 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -265,6 +265,30 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w "M-WA3: WhatsApp policy preset applied before rebuild", ); + const hostPolicyEdit = await runHost( + host, + "openshell", + [ + "policy", + "update", + SANDBOX_NAME, + "--add-endpoint", + "host-edit-messaging.example.com:443:read-only:rest:enforce", + "--rule-name", + "messaging_host_edit_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: "host-policy-edit-before-messaging-rebuild", + env: state.env, + redactionValues, + timeoutMs: 60_000, + }, + ); + expectExitZero(hostPolicyEdit, "M-WA3a: direct OpenShell policy edit before rebuild"); + const whatsappRebuild = await runHost( host, "node", @@ -279,7 +303,7 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w expectExitZero(whatsappRebuild, "M-WA4: rebuild completed after WhatsApp channel add"); const whatsappRebuildText = stripAnsi(outputText(whatsappRebuild)); check( - whatsappRebuildText.includes(`Sandbox '${SANDBOX_NAME}' rebuilt successfully`), + whatsappRebuildText.includes(`Sandbox '${SANDBOX_NAME}' rebuild completed`), "M-WA4a: rebuild reports complete post-restore success", ); check( @@ -310,6 +334,10 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w /\/usr\/local\/bin\/node|\/usr\/bin\/node/.test(whatsappPolicyPostText), "M-WA5: WhatsApp policy preset survived rebuild with Node binary scope", ); + check( + whatsappPolicyPostText.includes("messaging_host_edit_e2e"), + "M-WA5a: unrelated host policy edit survived messaging rebuild", + ); progress.phase("inspect providers placeholders and credential isolation"); const providerList = await runHost(host, "openshell", ["provider", "list"], { @@ -528,13 +556,6 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w accountString(slackAccount, "groupPolicy") === "allowlist", "M11g: Slack groupPolicy is allowlist", ); - const slackBotPlaceholder = credentialPlaceholders.get("SLACK_BOT_TOKEN") ?? ""; - const slackAppPlaceholder = credentialPlaceholders.get("SLACK_APP_TOKEN") ?? ""; - check( - /^openshell:resolve:env:v[0-9]+_SLACK_BOT_TOKEN$/u.test(slackBotPlaceholder) && - /^openshell:resolve:env:v[0-9]+_SLACK_APP_TOKEN$/u.test(slackAppPlaceholder), - "M11j: Slack environment uses revision-scoped OpenShell credential placeholders", - ); const slackChannels = slackAccount.channels; const slackWildcard = slackChannels && typeof slackChannels === "object" @@ -884,6 +905,24 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); `${SANDBOX_NAME}-slack-app`, ); + const slackBotPlaceholder = await sandboxOutput( + sandbox, + "printenv SLACK_BOT_TOKEN 2>/dev/null || true", + "placeholder-slack_bot_token-after-binding", + redactionValues, + ); + const slackAppPlaceholder = await sandboxOutput( + sandbox, + "printenv SLACK_APP_TOKEN 2>/dev/null || true", + "placeholder-slack_app_token-after-binding", + redactionValues, + ); + check( + /^openshell:resolve:env:v[0-9]+_SLACK_BOT_TOKEN$/u.test(slackBotPlaceholder) && + /^openshell:resolve:env:v[0-9]+_SLACK_APP_TOKEN$/u.test(slackAppPlaceholder), + "M11j: Slack bindings expose revision-scoped OpenShell credential placeholders", + ); + const slackAuth = await runSlackApiRequest( sandbox, fakeSlack.port, diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index de1e6d42de2..bf3e2ca7b8f 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -929,9 +929,14 @@ echo "$OUT CURL_RC_$RC" ); expect(directProvider).toMatch(/STATUS_403|ERROR_/); - expect(["169.254.169.254", "127.0.0.1", "10.0.0.1", "192.168.1.1", "0.0.0.0"].every((ip) => - Object.is(isPrivateIp(ip), true))).toBe(true); - expect(["8.8.8.8", "142.250.80.46"].every((ip) => Object.is(isPrivateIp(ip), false))).toBe(true); + expect( + ["169.254.169.254", "127.0.0.1", "10.0.0.1", "192.168.1.1", "0.0.0.0"].every((ip) => + Object.is(isPrivateIp(ip), true), + ), + ).toBe(true); + expect(["8.8.8.8", "142.250.80.46"].every((ip) => Object.is(isPrivateIp(ip), false))).toBe( + true, + ); progress.phase("exercise scoped host-gateway web fetch policy"); const marker = "NEMOCLAW_HOST_GATEWAY_WEB_FETCH_OK"; @@ -997,10 +1002,8 @@ NEMOCLAW_WEB_FETCH_PROBE`, await Promise.all([approvedServer.close(), deniedServer.close()]); } - // A direct OpenShell policy update intentionally invalidates NemoClaw's - // durable policy receipt. Keep this final among NemoClaw-owned mutations so - // the test proves the fail-closed ownership contract without asking a later - // policy-add to overwrite externally changed policy. + // A direct OpenShell policy update is authoritative. Keep this final so the + // test proves host-side edits require no NemoClaw receipt or adoption step. progress.phase("prove per-binary Jira approval after NemoClaw policy mutations"); const curlApproval = await sandbox.openshell( [ @@ -1040,6 +1043,20 @@ printf '\n' expect(text(curlAfterApproval)).toMatch(/CURL_STATUS_401/); expect(text(curlAfterApproval)).toMatch(/Unauthorized|unauthorized/); + const githubAdd = await applyPreset(host, "github"); + expect(githubAdd.exitCode, text(githubAdd)).toBe(0); + const policyAfterNemoclawMutation = await sandbox.openshell( + ["policy", "get", "--full", SANDBOX_NAME], + { + artifactName: "tc-net-08-policy-after-nemoclaw-mutation", + env: baseEnv(), + timeoutMs: SANDBOX_EXEC_TIMEOUT_MS, + }, + ); + expect(policyAfterNemoclawMutation.exitCode, text(policyAfterNemoclawMutation)).toBe(0); + expect(policyAfterNemoclawMutation.stdout).toContain("api.atlassian.com"); + expect(policyAfterNemoclawMutation.stdout).toMatch(/github|api\.github\.com/i); + progress.phase("switch to permissive policy and record the contract"); const permissiveApply = await sandbox.openshell( ["policy", "set", "--policy", PERMISSIVE_POLICY, "--wait", SANDBOX_NAME], @@ -1069,6 +1086,7 @@ printf '\n' livePolicyAdd: true, dryRunNoSideEffect: true, jiraPerBinaryPolicy: true, + hostEditSurvivesNemoclawMutation: true, hotReloadNoRestart: true, inferenceExemption: true, ssrfValidation: true, diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index dd6b4aad469..34e7093a6c4 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -7,7 +7,9 @@ * lane: install an old NemoClaw/OpenShell gateway, create a real OpenClaw * sandbox, seed durable workspace + live process state, run the current * installer upgrade path, then assert the gateway reports the current - * OpenShell version and the survivor claw remains restored/reachable. + * OpenShell version. Fixtures whose live OpenShell policy survives the gateway + * transition restore the claw; the cluster-era fixture fails closed with its + * backup intact because NemoClaw has no policy shadow from which to recreate it. * After the outer rebuild destroys the source sandbox, the inner onboarding flow * must continue the upgrade-owned recreation journal without opening a second transaction. * @@ -82,6 +84,7 @@ const OLD_SANDBOX_BASE_IMAGE_REF = const OLD_OPENCLAW_VERSION = process.env.NEMOCLAW_OLD_OPENCLAW_VERSION ?? "2026.4.24"; const CURRENT_OPENCLAW_VERSION = process.env.NEMOCLAW_CURRENT_OPENCLAW_VERSION ?? ""; const OPENCLAW_STATE_UPGRADE_PROOF = process.env.NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF === "1"; +const LEGACY_GATEWAY_PRESERVES_LIVE_POLICY = OLD_NEMOCLAW_REF !== "v0.0.36"; const OLD_INSTALLER_FIXTURE_IDENTITY = Object.freeze({ nemoclawCommit: OLD_NEMOCLAW_COMMIT, nemoclawRef: OLD_NEMOCLAW_REF, @@ -657,7 +660,11 @@ async function runInstallerPayload( logFile: string, env: NodeJS.ProcessEnv, redactionValues: string[] = [], - options: { hiddenOpenShellDir?: string; interactiveInput?: string } = {}, + options: { + expectedExitCode?: number; + hiddenOpenShellDir?: string; + interactiveInput?: string; + } = {}, ): Promise { const quotedInstallerArgs = installerArgs.map(shellQuote).join(" "); const installerCommand = `bash ${quotedInstallerArgs} >${shellQuote(logFile)} 2>&1`; @@ -692,7 +699,10 @@ ${installerInvocation}`, artifactName: `${label}-installer-tail`, timeoutMs: 30_000, }); - expect(result.exitCode, `${label} NemoClaw installer failed:\n${resultText(tail)}`).toBe(0); + expect( + result.exitCode, + `${label} NemoClaw installer returned an unexpected exit code:\n${resultText(tail)}`, + ).toBe(options.expectedExitCode ?? 0); return result; } @@ -979,6 +989,7 @@ async function installCurrentNemoclawUpgrade( currentEnv, redactionValues, { + expectedExitCode: LEGACY_GATEWAY_PRESERVES_LIVE_POLICY ? 0 : 1, hiddenOpenShellDir: exerciseOrdinaryUpgrade ? hiddenOldOpenShellDir : undefined, // One answer covers a changed usage notice, when present, and the other // confirms the legacy managed-image recovery prompt. @@ -998,7 +1009,23 @@ async function installCurrentNemoclawUpgrade( : currentLog.includes(expectedConfirmation), ).toBe(true); expect(currentLog).toContain("Pre-upgrade backup: 1 backed up, 0 failed, 0 skipped"); - expect(currentLog).toContain("Existing sandboxes recovered; skipping generic onboarding"); + const assertRecoveredInstaller = (): void => { + expect(currentLog).toContain("Existing sandboxes recovered; skipping generic onboarding"); + }; + const assertFailClosedInstaller = (): void => { + expect(currentLog).not.toContain("Existing sandboxes recovered; skipping generic onboarding"); + expect(currentLog).toContain( + "Rebuild cannot recover its missing OpenShell policy or live workspace from NemoClaw registry metadata.", + ); + expect(currentLog).toContain( + "Cannot rebuild an absent sandbox without its authoritative OpenShell policy.", + ); + expect(currentLog).toContain("Generic onboarding will not run"); + expect(currentLog).toContain( + "Installation incomplete: one or more existing sandboxes failed to upgrade.", + ); + }; + (LEGACY_GATEWAY_PRESERVES_LIVE_POLICY ? assertRecoveredInstaller : assertFailClosedInstaller)(); const openshellVersion = await bash(host, `openshell --version`, { artifactName: "current-openshell-version", @@ -1055,6 +1082,51 @@ async function assertSurvivorSandboxAfterUpgrade(host: HostCliClient): Promise { + const currentLog = fs.readFileSync(currentInstallLog, "utf8"); + const backupLine = currentLog + .split(/\r?\n/u) + .find((line) => line.includes(`✓ ${SURVIVOR_SANDBOX}:`) && line.includes("→ ")); + const backupPath = backupLine?.split("→ ").at(-1)?.trim() ?? ""; + expect(path.isAbsolute(backupPath), `upgrade backup path must be absolute: ${backupLine}`).toBe( + true, + ); + expect(fs.existsSync(path.join(backupPath, "rebuild-manifest.json"))).toBe(true); + const manifest = JSON.parse( + fs.readFileSync(path.join(backupPath, "rebuild-manifest.json"), "utf8"), + ) as Record; + expect(manifest.rebuildPolicyHandoff).toBeUndefined(); + expect( + fs.readdirSync(backupPath).some((entry) => entry.startsWith("rebuild-policy-handoff.")), + ).toBe(false); + + expect(fs.existsSync(REGISTRY_FILE), `${REGISTRY_FILE} must remain after failed recovery`).toBe( + true, + ); + expect(fs.readFileSync(REGISTRY_FILE, "utf8")).toContain(`"${SURVIVOR_SANDBOX}"`); + + const liveList = await bash(host, "openshell sandbox list", { + artifactName: "post-upgrade-openshell-sandbox-list", + timeoutMs: 60_000, + }); + expectExitZero(liveList, "OpenShell sandbox list after fail-closed upgrade"); + expect(resultText(liveList)).not.toContain(SURVIVOR_SANDBOX); + + const registryList = await bash(host, "nemoclaw list", { + artifactName: "post-upgrade-nemoclaw-list", + timeoutMs: 60_000, + }); + expectExitZero(registryList, "nemoclaw list after fail-closed upgrade"); + expectOutputContains( + registryList, + SURVIVOR_SANDBOX, + "failed recovery must preserve the stranded registry record for explicit cleanup", + ); +} + function runMacInstallerProbe( artifacts: ArtifactSink, name: string, @@ -1114,7 +1186,7 @@ const runOpenShellGatewayUpgrade = test; const runLinuxOpenShellGatewayUpgrade = test.skipIf(process.platform !== "linux"); runLinuxOpenShellGatewayUpgrade( - "openshell-gateway-upgrade: upgrades old working OpenClaw claw and restores survivor state", + "openshell-gateway-upgrade: preserves live OpenShell state or fails closed without it", { timeout: TEST_TIMEOUT_MS, meta: { @@ -1123,7 +1195,7 @@ runLinuxOpenShellGatewayUpgrade( "install pinned legacy NemoClaw and its sandbox", "start the survivor agent and workspace marker", "upgrade to the current OpenShell gateway", - "confirm survivor state and registry after upgrade", + "verify version-specific upgrade outcome", ], }, }, @@ -1137,7 +1209,9 @@ runLinuxOpenShellGatewayUpgrade( "exact-name confirmation for the known-managed legacy fixture", "current scripts/install.sh gateway upgrade path", "sandbox exec /proc process probe", - "NemoClaw registry and durable workspace restore", + LEGACY_GATEWAY_PRESERVES_LIVE_POLICY + ? "NemoClaw registry and durable workspace restore" + : "fail-closed missing-policy diagnostics and preserved backup", ], oldNemoclawRef: OLD_NEMOCLAW_REF, oldNemoclawCommit: OLD_NEMOCLAW_COMMIT, @@ -1225,16 +1299,25 @@ runLinuxOpenShellGatewayUpgrade( expect(Number.isInteger(survivorPid) && survivorPid > 0).toBe(true); progress.phase("upgrade to the current OpenShell gateway"); + const currentInstallLog = artifacts.pathFor("current-install.log"); await installCurrentNemoclawUpgrade( host, fake.baseUrl, - artifacts.pathFor("current-install.log"), + currentInstallLog, hiddenOldOpenShellDir, ); - progress.phase("confirm survivor state and registry after upgrade"); - await assertSurvivorSandboxAfterUpgrade(host); - await verifyOpenClawStateUpgradeProof(host, fake, artifacts, legacyStateContract); + const assertRecoveredUpgrade = async (): Promise => { + await assertSurvivorSandboxAfterUpgrade(host); + await verifyOpenClawStateUpgradeProof(host, fake, artifacts, legacyStateContract); + }; + const assertFailClosedUpgrade = async (): Promise => { + await assertMissingSurvivorFailsClosedAfterUpgrade(host, currentInstallLog); + }; + progress.phase("verify version-specific upgrade outcome"); + await ( + LEGACY_GATEWAY_PRESERVES_LIVE_POLICY ? assertRecoveredUpgrade : assertFailClosedUpgrade + )(); }, ); diff --git a/test/e2e/live/policy-list-state.ts b/test/e2e/live/policy-list-state.ts index 30e186df9bd..4ca572d45ef 100644 --- a/test/e2e/live/policy-list-state.ts +++ b/test/e2e/live/policy-list-state.ts @@ -25,6 +25,7 @@ const POLICY_LIST_ROW_PATTERN = new RegExp( export function parsePolicyPresetState(output: string, presetName: string): PolicyPresetState { if ( output.includes("Could not query gateway") || + output.includes("Could not query OpenShell") || output.includes("cannot be verified or started") ) { return "unverified"; diff --git a/test/e2e/live/rebuild-hermes-host-policy.ts b/test/e2e/live/rebuild-hermes-host-policy.ts new file mode 100644 index 00000000000..9c3cf08abf1 --- /dev/null +++ b/test/e2e/live/rebuild-hermes-host-policy.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { assertExitZero } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; + +type RebuildHermesPolicyProbeOptions = { + env: NodeJS.ProcessEnv; + host: HostCliClient; + openshellBin: string; + redactionValues: string[]; + sandboxName: string; + timeoutMs: number; +}; + +export async function applyRebuildHermesHostPolicyEdit( + options: RebuildHermesPolicyProbeOptions, +): Promise { + const result = await options.host.command( + options.openshellBin, + [ + "policy", + "update", + options.sandboxName, + "--add-endpoint", + "host-edit-rebuild-hermes.example.com:443:read-only:rest:enforce", + "--rule-name", + "host_edit_rebuild_hermes_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: "phase-5-host-policy-edit-before-rebuild", + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: options.timeoutMs, + }, + ); + assertExitZero(result, "apply host policy edit before Hermes rebuild"); +} + +export async function assertRebuildHermesHostPolicyEditSurvives( + options: RebuildHermesPolicyProbeOptions, +): Promise { + const result = await options.host.command( + options.openshellBin, + ["policy", "get", "--full", options.sandboxName], + { + artifactName: "phase-7-policy-after-rebuild", + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: options.timeoutMs, + }, + ); + assertExitZero(result, "read Hermes policy after rebuild"); + assert.match(result.stdout, /host_edit_rebuild_hermes_e2e/u); +} diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 1dac0678787..ba5db53ba31 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -25,7 +25,7 @@ import { snapshotFile, writeJsonFile, } from "../fixtures/file-state.ts"; -import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; +import { CLI_ENTRYPOINT } from "../fixtures/paths.ts"; import { listCredentialLeakPaths } from "../fixtures/phases/state-validation.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { @@ -54,6 +54,10 @@ import { planRebuildHermesBaseReuse, } from "./rebuild-hermes-env.ts"; import { ensureRebuildHermesHostTools, hermesApiTokenDigest } from "./rebuild-hermes-host-tools.ts"; +import { + applyRebuildHermesHostPolicyEdit, + assertRebuildHermesHostPolicyEditSurvives, +} from "./rebuild-hermes-host-policy.ts"; import { cleanupTrackedRebuildHermesImage, type RebuildHermesRegistryImageState, @@ -68,7 +72,6 @@ import { } from "./rebuild-hermes-old-base-fixture.ts"; import { buildRebuildHermesOldSandboxDockerfile } from "./rebuild-hermes-old-sandbox.ts"; import { REBUILD_HERMES_PHASES } from "./rebuild-hermes-phases.ts"; -import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; import { prepareHermesRebuildSwap } from "./rebuild-hermes-swap.ts"; import { REBUILD_HERMES_STATE } from "./rebuild-hermes-state-fixture.ts"; import { buildRebuildHermesTimingSummary, describeRunnerClass } from "./rebuild-hermes-timing.ts"; @@ -422,8 +425,6 @@ function seedRegistryAndSession( credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", gpuEnabled: false, - policies: [], - policyTier: null, agent: "hermes", agentVersion: OLD_HERMES_REGISTRY_VERSION, dashboardPort, @@ -558,899 +559,931 @@ function verifySeededOldBaseResolution( } } -test(STALE_BASE_REBUILD - ? "rebuild-hermes: stale base refresh restores Hermes state and resumes cron dispatch (#7806)" - : "rebuild-hermes: rebuild restores Hermes state and recovers a stranded cron drain (#7806)", { - timeout: LIVE_TIMEOUT_MS, - meta: { e2ePhases: REBUILD_HERMES_PHASES }, -}, async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { - const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); - const redactionValues = [apiKey, DISCORD_FAKE_TOKEN, PRE_REBUILD_API_SERVER_KEY]; - const expectedVersion = expectedHermesVersion(); - const cronRestore = createRebuildHermesCronRestoreFixture({ - host, - sandboxName: SANDBOX_NAME, - env: testEnv(apiKey), - redactionValues, - }); - const registrySnapshot = snapshotFile(REGISTRY_FILE); - const sessionSnapshot = snapshotFile(SESSION_FILE); - const sandboxBackupRoot = path.join(BACKUP_ROOT, SANDBOX_NAME); - cleanup.trackDisposable(`restore NemoClaw state files for ${SANDBOX_NAME}`, () => { - restoreFile(REGISTRY_FILE, registrySnapshot); - restoreFile(SESSION_FILE, sessionSnapshot); - fs.rmSync(sandboxBackupRoot, { recursive: true, force: true }); - }); - await artifacts.writeJson("contract.json", { - staleBaseMode: STALE_BASE_REBUILD, - sandboxName: SANDBOX_NAME, - oldHermesVersion: OLD_HERMES_VERSION, - oldBaseFixture: REBUILD_HERMES_OLD_BASE_FIXTURE, - expectedHermesVersion: expectedVersion, - markerFile: REBUILD_HERMES_STATE.markerFile, - preservedBoundaries: [ - "production current Hermes base resolution without a disposable sandbox", - "product gateway startup plus exact compatible-endpoint provider/model route", - "current Hermes base identity plus immutable old Hermes base fixture", - "OpenShell provider create/update and sandbox create/exec/list", - "curated local ~/.nemoclaw registry and onboard-session rebuild metadata", - "real nemoclaw rebuild --yes --verbose without host inference credentials", - "Hermes messaging placeholders plus script-backed cron restore and dispatch gating", - "backup credential leak scan under ~/.nemoclaw/rebuild-backups", - ], - outOfScope: [ - "install.sh and full onboard behavior retained by hermes-e2e", - "interactive hermes rebuild modal prompt and Y confirmation", - ], - }); +test( + STALE_BASE_REBUILD + ? "rebuild-hermes: stale base refresh restores Hermes state and resumes cron dispatch (#7806)" + : "rebuild-hermes: rebuild restores Hermes state and recovers a stranded cron drain (#7806)", + { + timeout: LIVE_TIMEOUT_MS, + meta: { e2ePhases: REBUILD_HERMES_PHASES }, + }, + async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + const redactionValues = [apiKey, DISCORD_FAKE_TOKEN, PRE_REBUILD_API_SERVER_KEY]; + const expectedVersion = expectedHermesVersion(); + const cronRestore = createRebuildHermesCronRestoreFixture({ + host, + sandboxName: SANDBOX_NAME, + env: testEnv(apiKey), + redactionValues, + }); + const registrySnapshot = snapshotFile(REGISTRY_FILE); + const sessionSnapshot = snapshotFile(SESSION_FILE); + const sandboxBackupRoot = path.join(BACKUP_ROOT, SANDBOX_NAME); + cleanup.trackDisposable(`restore NemoClaw state files for ${SANDBOX_NAME}`, () => { + restoreFile(REGISTRY_FILE, registrySnapshot); + restoreFile(SESSION_FILE, sessionSnapshot); + fs.rmSync(sandboxBackupRoot, { recursive: true, force: true }); + }); + await artifacts.writeJson("contract.json", { + staleBaseMode: STALE_BASE_REBUILD, + sandboxName: SANDBOX_NAME, + oldHermesVersion: OLD_HERMES_VERSION, + oldBaseFixture: REBUILD_HERMES_OLD_BASE_FIXTURE, + expectedHermesVersion: expectedVersion, + markerFile: REBUILD_HERMES_STATE.markerFile, + preservedBoundaries: [ + "production current Hermes base resolution without a disposable sandbox", + "product gateway startup plus exact compatible-endpoint provider/model route", + "current Hermes base identity plus immutable old Hermes base fixture", + "OpenShell provider create/update and sandbox create/exec/list", + "curated local ~/.nemoclaw registry and onboard-session rebuild metadata", + "real nemoclaw rebuild --yes --verbose without host inference credentials", + "a direct OpenShell policy edit survives the rebuild transaction", + "Hermes messaging placeholders plus script-backed cron restore and dispatch gating", + "backup credential leak scan under ~/.nemoclaw/rebuild-backups", + ], + outOfScope: [ + "install.sh and full onboard behavior retained by hermes-e2e", + "interactive hermes rebuild modal prompt and Y confirmation", + ], + }); - expect( - fs.existsSync(CLI_ENTRYPOINT), - "bin/nemoclaw.js missing — build the checked-out CLI before live rebuild coverage", - ).toBe(true); - expect( - path.resolve(host.commandPath), - "rebuild-Hermes must invoke the checked-out CLI through NEMOCLAW_CLI_BIN", - ).toBe(CLI_ENTRYPOINT); - await ensureRebuildHermesHostTools(host); - await prepareHermesRebuildSwap(host, cleanup); + expect( + fs.existsSync(CLI_ENTRYPOINT), + "bin/nemoclaw.js missing — build the checked-out CLI before live rebuild coverage", + ).toBe(true); + expect( + path.resolve(host.commandPath), + "rebuild-Hermes must invoke the checked-out CLI through NEMOCLAW_CLI_BIN", + ).toBe(CLI_ENTRYPOINT); + await ensureRebuildHermesHostTools(host); + await prepareHermesRebuildSwap(host, cleanup); - const dockerInfo = await host.command("docker", ["info"], { - artifactName: "prereq-docker-info", - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }); - switch (dockerInfo.exitCode === 0) { - case false: - switch (process.env.GITHUB_ACTIONS === "true") { - case true: - throw new Error( - `Docker is required for rebuild-hermes live coverage: ${resultText(dockerInfo)}`, - ); - default: - skip("Docker is required for rebuild-hermes live coverage"); - } - } + const dockerInfo = await host.command("docker", ["info"], { + artifactName: "prereq-docker-info", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }); + switch (dockerInfo.exitCode === 0) { + case false: + switch (process.env.GITHUB_ACTIONS === "true") { + case true: + throw new Error( + `Docker is required for rebuild-hermes live coverage: ${resultText(dockerInfo)}`, + ); + default: + skip("Docker is required for rebuild-hermes live coverage"); + } + } - const activeOpenshellBin = requireRebuildHermesOpenshellBin(host); - await bestEffortPrecleanHermesResources( - host, - apiKey, - activeOpenshellBin, - "pre-cleanup-hermes-rebuild-resources", - ); - const observedForwardPorts = new Set([8642]); - let cleanupRegistryDashboardPort: unknown; - let dashboardPort: number | null = null; - let currentBaseReuseTag: string | null = null; - let currentBaseSourceInspect: ShellProbeResult | null = null; - let staleBaseClassification: ReturnType | null = null; - let oldSandboxImageState: RebuildHermesRegistryImageState | null = null; - cleanup.trackDisposable(`remove old Hermes base image ${OLD_BASE_TAG}`, () => - cleanupOldHermesBaseImage(host, apiKey), - ); - cleanup.trackDisposable("remove current Hermes base reuse alias", () => - cleanupTrackedRebuildHermesImage(currentBaseReuseTag, (imageTag) => - removeHermesFixtureImage(host, apiKey, imageTag, { - artifactName: "cleanup-hermes-rebuild-resources-docker-rmi-current-base-reuse", - label: `cleanup current Hermes base reuse alias ${imageTag}`, - }), - ), - ); - cleanup.trackGateway(host, "nemoclaw", { - artifactName: "cleanup-hermes-rebuild-resources-gateway", - env: hermesCleanupEnv(apiKey), - redactionValues: hermesCleanupRedactions(apiKey), - timeoutMs: 3 * 60_000, - }); - cleanup.trackDisposable(`remove Hermes Discord provider for ${SANDBOX_NAME}`, () => - cleanupHermesDiscordProvider(host, apiKey, activeOpenshellBin), - ); - cleanup.trackDisposable("stop Hermes dashboard and API forwards", () => - cleanupRebuildHermesTrackedForwards( - observedForwardPorts, - cleanupRegistryDashboardPort, - (port) => cleanupHermesForward(host, testEnv, apiKey, SANDBOX_NAME, port, redactionValues), - (evidence) => artifacts.writeJson("cleanup-dashboard-port.json", evidence), - ), - ); - // Cleanup is LIFO: remove the sandbox before reclaiming its exact image tags, - // while the gateway/provider/forward remain available for sandbox teardown. - cleanup.trackDisposable("remove old derived Hermes fixture image", () => - cleanupTrackedRebuildHermesImage(oldSandboxImageState?.imageTag ?? null, (imageTag) => - removeHermesFixtureImage(host, apiKey, imageTag, { - artifactName: "cleanup-hermes-rebuild-resources-docker-rmi-old-derived-image", - label: `cleanup old derived Hermes fixture image ${imageTag}`, - }), - ), - ); - cleanup.trackDisposable(`delete Hermes rebuild OpenShell sandbox ${SANDBOX_NAME}`, () => - sandbox.cleanupSandbox(SANDBOX_NAME, { - artifactName: "cleanup-hermes-rebuild-resources-openshell-sandbox-delete", + const activeOpenshellBin = requireRebuildHermesOpenshellBin(host); + await bestEffortPrecleanHermesResources( + host, + apiKey, + activeOpenshellBin, + "pre-cleanup-hermes-rebuild-resources", + ); + const observedForwardPorts = new Set([8642]); + let cleanupRegistryDashboardPort: unknown; + let dashboardPort: number | null = null; + let currentBaseReuseTag: string | null = null; + let currentBaseSourceInspect: ShellProbeResult | null = null; + let staleBaseClassification: ReturnType | null = null; + let oldSandboxImageState: RebuildHermesRegistryImageState | null = null; + cleanup.trackDisposable(`remove old Hermes base image ${OLD_BASE_TAG}`, () => + cleanupOldHermesBaseImage(host, apiKey), + ); + cleanup.trackDisposable("remove current Hermes base reuse alias", () => + cleanupTrackedRebuildHermesImage(currentBaseReuseTag, (imageTag) => + removeHermesFixtureImage(host, apiKey, imageTag, { + artifactName: "cleanup-hermes-rebuild-resources-docker-rmi-current-base-reuse", + label: `cleanup current Hermes base reuse alias ${imageTag}`, + }), + ), + ); + cleanup.trackGateway(host, "nemoclaw", { + artifactName: "cleanup-hermes-rebuild-resources-gateway", env: hermesCleanupEnv(apiKey), redactionValues: hermesCleanupRedactions(apiKey), timeoutMs: 3 * 60_000, - }), - ); - cleanup.trackDisposable(`destroy Hermes rebuild sandbox ${SANDBOX_NAME}`, () => - cleanupHermesNemoClawSandbox(host, apiKey), - ); - - progress.phase("prepare trusted gateway inference and the current Hermes base"); - const cliProbe = await host.nemoclaw(["--help"], { - artifactName: "phase-1-cli-probe", - env: testEnv(apiKey), - redactionValues, - timeoutMs: 30_000, - }); - expectExitZero(cliProbe, "checked-out NemoClaw CLI"); - - const openshellProbe = await host.command(activeOpenshellBin, ["--version"], { - artifactName: "phase-1-openshell-probe", - env: testEnv(apiKey), - redactionValues, - timeoutMs: 30_000, - }); - expectExitZero(openshellProbe, "workflow-installed OpenShell CLI"); + }); + cleanup.trackDisposable(`remove Hermes Discord provider for ${SANDBOX_NAME}`, () => + cleanupHermesDiscordProvider(host, apiKey, activeOpenshellBin), + ); + cleanup.trackDisposable("stop Hermes dashboard and API forwards", () => + cleanupRebuildHermesTrackedForwards( + observedForwardPorts, + cleanupRegistryDashboardPort, + (port) => cleanupHermesForward(host, testEnv, apiKey, SANDBOX_NAME, port, redactionValues), + (evidence) => artifacts.writeJson("cleanup-dashboard-port.json", evidence), + ), + ); + // Cleanup is LIFO: remove the sandbox before reclaiming its exact image tags, + // while the gateway/provider/forward remain available for sandbox teardown. + cleanup.trackDisposable("remove old derived Hermes fixture image", () => + cleanupTrackedRebuildHermesImage(oldSandboxImageState?.imageTag ?? null, (imageTag) => + removeHermesFixtureImage(host, apiKey, imageTag, { + artifactName: "cleanup-hermes-rebuild-resources-docker-rmi-old-derived-image", + label: `cleanup old derived Hermes fixture image ${imageTag}`, + }), + ), + ); + cleanup.trackDisposable(`delete Hermes rebuild OpenShell sandbox ${SANDBOX_NAME}`, () => + sandbox.cleanupSandbox(SANDBOX_NAME, { + artifactName: "cleanup-hermes-rebuild-resources-openshell-sandbox-delete", + env: hermesCleanupEnv(apiKey), + redactionValues: hermesCleanupRedactions(apiKey), + timeoutMs: 3 * 60_000, + }), + ); + cleanup.trackDisposable(`destroy Hermes rebuild sandbox ${SANDBOX_NAME}`, () => + cleanupHermesNemoClawSandbox(host, apiKey), + ); - const resolvedCurrentBase = await resolveRebuildHermesCurrentBase({ - host, - activeOpenshellBin, - envFactory: testEnv, - redactionValues, - onOutput: progress.onOutput, - }); - const { currentBase, baseResolution: phase1BaseResolution } = resolvedCurrentBase; - currentBaseSourceInspect = resolvedCurrentBase.sourceInspect; - const baseReusePlan = planRebuildHermesBaseReuse( - STALE_BASE_REBUILD, - phase1BaseResolution, - CURRENT_BASE_REUSE_TAG, - ); - const currentBaseReuseEvidence = await prepareCurrentBaseReuse( - host, - redactionValues, - phase1BaseResolution, - currentBaseSourceInspect, - baseReusePlan, - (imageTag) => { - currentBaseReuseTag = imageTag; - }, - ); - await artifacts.writeJson("phase-1-current-base-resolution.json", { - imageTag: currentBase.imageTag, - built: currentBase.built, - baseResolution: phase1BaseResolution, - reuseAlias: currentBaseReuseEvidence - ? { imageTag: CURRENT_BASE_REUSE_TAG, ...currentBaseReuseEvidence } - : null, - }); + progress.phase("prepare trusted gateway inference and the current Hermes base"); + const cliProbe = await host.nemoclaw(["--help"], { + artifactName: "phase-1-cli-probe", + env: testEnv(apiKey), + redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(cliProbe, "checked-out NemoClaw CLI"); - const gatewayBootstrap = await bootstrapRebuildHermesGateway({ - host, - activeOpenshellBin, - apiKey, - artifacts, - endpointUrl: HOSTED_ENDPOINT_URL, - envFactory: testEnv, - expectedModel: HOSTED_MODEL, - onOutput: progress.onOutput, - redactionValues, - sandboxName: SANDBOX_NAME, - }); - dashboardPort = gatewayBootstrap.dashboardPort; - observedForwardPorts.add(dashboardPort); - expect(gatewayBootstrap.route).toEqual({ - provider: "compatible-endpoint", - model: HOSTED_MODEL, - }); + const openshellProbe = await host.command(activeOpenshellBin, ["--version"], { + artifactName: "phase-1-openshell-probe", + env: testEnv(apiKey), + redactionValues, + timeoutMs: 30_000, + }); + expectExitZero(openshellProbe, "workflow-installed OpenShell CLI"); - progress.phase("pull and verify the historical Hermes base fixture"); - const pullOldBase = await host.command( - "docker", - ["pull", REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef], - { - artifactName: "phase-2-docker-pull-old-hermes-base-fixture", - env: buildAvailabilityProbeEnv(), + const resolvedCurrentBase = await resolveRebuildHermesCurrentBase({ + host, + activeOpenshellBin, + envFactory: testEnv, redactionValues, - timeoutMs: DOCKER_PULL_TIMEOUT_MS, - captureLimitBytes: LONG_COMMAND_CAPTURE_LIMIT_BYTES, onOutput: progress.onOutput, - }, - ); - expectExitZero(pullOldBase, `pull immutable old Hermes base ${OLD_HERMES_VERSION}`); - - const oldBaseLabels = await host.command( - "docker", - [ - "image", - "inspect", - "--format", - "{{json .Config.Labels}}", - REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, - ], - { - artifactName: "phase-2-inspect-old-hermes-base-fixture-labels", - env: buildAvailabilityProbeEnv(), + }); + const { currentBase, baseResolution: phase1BaseResolution } = resolvedCurrentBase; + currentBaseSourceInspect = resolvedCurrentBase.sourceInspect; + const baseReusePlan = planRebuildHermesBaseReuse( + STALE_BASE_REBUILD, + phase1BaseResolution, + CURRENT_BASE_REUSE_TAG, + ); + const currentBaseReuseEvidence = await prepareCurrentBaseReuse( + host, redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(oldBaseLabels, "inspect immutable old Hermes base fixture labels"); + phase1BaseResolution, + currentBaseSourceInspect, + baseReusePlan, + (imageTag) => { + currentBaseReuseTag = imageTag; + }, + ); + await artifacts.writeJson("phase-1-current-base-resolution.json", { + imageTag: currentBase.imageTag, + built: currentBase.built, + baseResolution: phase1BaseResolution, + reuseAlias: currentBaseReuseEvidence + ? { imageTag: CURRENT_BASE_REUSE_TAG, ...currentBaseReuseEvidence } + : null, + }); - const oldBaseIdentity = await host.command( - "docker", - ["image", "inspect", "--format", "{{json .}}", REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef], - { - artifactName: "phase-2-inspect-old-hermes-base-fixture-identity", - env: buildAvailabilityProbeEnv(), + const gatewayBootstrap = await bootstrapRebuildHermesGateway({ + host, + activeOpenshellBin, + apiKey, + artifacts, + endpointUrl: HOSTED_ENDPOINT_URL, + envFactory: testEnv, + expectedModel: HOSTED_MODEL, + onOutput: progress.onOutput, redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(oldBaseIdentity, "inspect immutable old Hermes base fixture identity"); + sandboxName: SANDBOX_NAME, + }); + dashboardPort = gatewayBootstrap.dashboardPort; + observedForwardPorts.add(dashboardPort); + expect(gatewayBootstrap.route).toEqual({ + provider: "compatible-endpoint", + model: HOSTED_MODEL, + }); - const oldBaseVersion = await host.command( - "docker", - [ - "run", - "--rm", - "--entrypoint", - "hermes", - REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, - "--version", - ], - { - artifactName: "phase-2-probe-old-hermes-base-fixture-version", - env: buildAvailabilityProbeEnv(), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(oldBaseVersion, "probe immutable old Hermes base fixture version"); + progress.phase("pull and verify the historical Hermes base fixture"); + const pullOldBase = await host.command( + "docker", + ["pull", REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef], + { + artifactName: "phase-2-docker-pull-old-hermes-base-fixture", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: DOCKER_PULL_TIMEOUT_MS, + captureLimitBytes: LONG_COMMAND_CAPTURE_LIMIT_BYTES, + onOutput: progress.onOutput, + }, + ); + expectExitZero(pullOldBase, `pull immutable old Hermes base ${OLD_HERMES_VERSION}`); - const oldBaseGlibcVersion = await host.command( - "docker", - [ - "run", - "--rm", - "--entrypoint", - "/usr/bin/ldd", - REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, - "--version", - ], - { - artifactName: "phase-2-probe-old-hermes-base-fixture-glibc", - env: buildAvailabilityProbeEnv(), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(oldBaseGlibcVersion, "probe immutable old Hermes base fixture glibc version"); + const oldBaseLabels = await host.command( + "docker", + [ + "image", + "inspect", + "--format", + "{{json .Config.Labels}}", + REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, + ], + { + artifactName: "phase-2-inspect-old-hermes-base-fixture-labels", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(oldBaseLabels, "inspect immutable old Hermes base fixture labels"); - const oldBaseEvidence = verifyRebuildHermesOldBaseFixture( - REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, - oldBaseLabels.stdout.trim(), - resultText(oldBaseVersion), - ); - const oldBaseResolutionMetadata = createRebuildHermesOldBaseResolutionMetadata( - oldBaseIdentity.stdout.trim(), - resultText(oldBaseGlibcVersion), - ); - await artifacts.writeJson("phase-2-old-base-fixture.json", { - ...oldBaseEvidence, - baseResolution: oldBaseResolutionMetadata, - }); + const oldBaseIdentity = await host.command( + "docker", + ["image", "inspect", "--format", "{{json .}}", REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef], + { + artifactName: "phase-2-inspect-old-hermes-base-fixture-identity", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(oldBaseIdentity, "inspect immutable old Hermes base fixture identity"); - const tagOldBase = await host.command( - "docker", - ["tag", REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, OLD_BASE_TAG], - { - artifactName: "phase-2-tag-old-hermes-base-fixture", - env: buildAvailabilityProbeEnv(), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(tagOldBase, "tag immutable old Hermes base fixture for sandbox creation"); + const oldBaseVersion = await host.command( + "docker", + [ + "run", + "--rm", + "--entrypoint", + "hermes", + REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, + "--version", + ], + { + artifactName: "phase-2-probe-old-hermes-base-fixture-version", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(oldBaseVersion, "probe immutable old Hermes base fixture version"); - const oldDockerfileDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-hermes-")); - const oldDockerfile = path.join(oldDockerfileDir, "Dockerfile"); - fs.writeFileSync( - oldDockerfile, - buildRebuildHermesOldSandboxDockerfile({ - baseTag: OLD_BASE_TAG, - baseResolutionMetadata: STALE_BASE_REBUILD ? oldBaseResolutionMetadata : null, - apiServerKey: PRE_REBUILD_API_SERVER_KEY, - discordPlaceholder: DISCORD_PLACEHOLDER, - kanbanTaskTitle: KANBAN_TASK_TITLE, - }), - "utf8", - ); - try { - const provider = await host.command( - "bash", + const oldBaseGlibcVersion = await host.command( + "docker", [ - "-lc", + "run", + "--rm", + "--entrypoint", + "/usr/bin/ldd", + REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, + "--version", + ], + { + artifactName: "phase-2-probe-old-hermes-base-fixture-glibc", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(oldBaseGlibcVersion, "probe immutable old Hermes base fixture glibc version"); + + const oldBaseEvidence = verifyRebuildHermesOldBaseFixture( + REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, + oldBaseLabels.stdout.trim(), + resultText(oldBaseVersion), + ); + const oldBaseResolutionMetadata = createRebuildHermesOldBaseResolutionMetadata( + oldBaseIdentity.stdout.trim(), + resultText(oldBaseGlibcVersion), + ); + await artifacts.writeJson("phase-2-old-base-fixture.json", { + ...oldBaseEvidence, + baseResolution: oldBaseResolutionMetadata, + }); + + const tagOldBase = await host.command( + "docker", + ["tag", REBUILD_HERMES_OLD_BASE_FIXTURE.imageRef, OLD_BASE_TAG], + { + artifactName: "phase-2-tag-old-hermes-base-fixture", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(tagOldBase, "tag immutable old Hermes base fixture for sandbox creation"); + + const oldDockerfileDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-hermes-")); + const oldDockerfile = path.join(oldDockerfileDir, "Dockerfile"); + fs.writeFileSync( + oldDockerfile, + buildRebuildHermesOldSandboxDockerfile({ + baseTag: OLD_BASE_TAG, + baseResolutionMetadata: STALE_BASE_REBUILD ? oldBaseResolutionMetadata : null, + apiServerKey: PRE_REBUILD_API_SERVER_KEY, + discordPlaceholder: DISCORD_PLACEHOLDER, + kanbanTaskTitle: KANBAN_TASK_TITLE, + }), + "utf8", + ); + try { + const provider = await host.command( + "bash", + [ + "-lc", + [ + "set -euo pipefail", + '"$OPENSHELL_BIN" provider create --name "$DISCORD_PROVIDER" --type generic --credential DISCORD_BOT_TOKEN ||', + ' "$OPENSHELL_BIN" provider update "$DISCORD_PROVIDER" --credential DISCORD_BOT_TOKEN', + ].join("\n"), + ], + { + artifactName: "phase-3-discord-provider-create-or-update", + env: testEnv(apiKey, { + DISCORD_BOT_TOKEN: DISCORD_FAKE_TOKEN, + DISCORD_PROVIDER: `${SANDBOX_NAME}-discord-bridge`, + OPENSHELL_BIN: activeOpenshellBin, + }), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(provider, "OpenShell Discord provider create/update"); + progress.phase("create the historical Hermes sandbox"); + const createOldSandbox = await host.command( + activeOpenshellBin, [ - "set -euo pipefail", - '"$OPENSHELL_BIN" provider create --name "$DISCORD_PROVIDER" --type generic --credential DISCORD_BOT_TOKEN ||', - ' "$OPENSHELL_BIN" provider update "$DISCORD_PROVIDER" --credential DISCORD_BOT_TOKEN', - ].join("\n"), + "sandbox", + "create", + "--name", + SANDBOX_NAME, + "--from", + oldDockerfile, + "--gateway", + "nemoclaw", + "--provider", + `${SANDBOX_NAME}-discord-bridge`, + "--no-tty", + "--", + "true", + ], + { + artifactName: "phase-3-create-old-hermes-sandbox", + env: testEnv(apiKey), + redactionValues, + timeoutMs: SANDBOX_CREATE_TIMEOUT_MS, + captureLimitBytes: LONG_COMMAND_CAPTURE_LIMIT_BYTES, + onOutput: progress.onOutput, + }, + ); + expectExitZero(createOldSandbox, "create old Hermes sandbox"); + oldSandboxImageState = rebuildHermesRegistryImageState(resultText(createOldSandbox)); + } finally { + fs.rmSync(oldDockerfileDir, { recursive: true, force: true }); + } + const seededOldSandboxImageState = + oldSandboxImageState ?? fail("old Hermes sandbox create did not produce managed image state"); + await waitForSandboxReady(host, apiKey, activeOpenshellBin, "phase-3"); + const seededOldBaseResolution = readSandboxBaseImageResolutionMetadata( + seededOldSandboxImageState.imageTag, + ); + staleBaseClassification = verifySeededOldBaseResolution( + STALE_BASE_REBUILD, + seededOldBaseResolution, + oldBaseResolutionMetadata, + phase1BaseResolution, + oldBaseIdentity.stdout.trim(), + ); + await artifacts.writeJson("phase-3-old-sandbox-base-identity.json", { + resolutionMetadata: seededOldBaseResolution, + staleClassification: staleBaseClassification, + }); + await removeHermesFixtureImage(host, apiKey, OLD_BASE_TAG, { + artifactName: "phase-3-release-old-hermes-base-tag", + label: `release old Hermes base tag ${OLD_BASE_TAG}`, + }); + progress.phase("seed persistent Hermes state and registry metadata"); + const seededKanban = await host.command( + activeOpenshellBin, + [ + "sandbox", + "exec", + "--name", + SANDBOX_NAME, + "--", + "/usr/bin/python3", + "-c", + KANBAN_TASK_PROBE, + KANBAN_FILE, + KANBAN_TASK_TITLE, ], { - artifactName: "phase-3-discord-provider-create-or-update", - env: testEnv(apiKey, { - DISCORD_BOT_TOKEN: DISCORD_FAKE_TOKEN, - DISCORD_PROVIDER: `${SANDBOX_NAME}-discord-bridge`, - OPENSHELL_BIN: activeOpenshellBin, - }), + artifactName: "phase-4-verify-seeded-kanban", + env: testEnv(apiKey), redactionValues, timeoutMs: OPENSHELL_TIMEOUT_MS, }, ); - expectExitZero(provider, "OpenShell Discord provider create/update"); - progress.phase("create the historical Hermes sandbox"); - const createOldSandbox = await host.command( + expectExitZero(seededKanban, "verify historical Hermes kanban seed before rebuild"); + expect(resultText(seededKanban)).toContain(KANBAN_TASK_TITLE); + const writeMarker = await host.command( activeOpenshellBin, [ "sandbox", - "create", + "exec", "--name", SANDBOX_NAME, - "--from", - oldDockerfile, - "--gateway", - "nemoclaw", - "--provider", - `${SANDBOX_NAME}-discord-bridge`, - "--no-tty", "--", - "true", + "sh", + "-c", + REBUILD_HERMES_STATE.seedScript, ], { - artifactName: "phase-3-create-old-hermes-sandbox", + artifactName: "phase-4-write-hermes-marker", env: testEnv(apiKey), redactionValues, - timeoutMs: SANDBOX_CREATE_TIMEOUT_MS, - captureLimitBytes: LONG_COMMAND_CAPTURE_LIMIT_BYTES, - onOutput: progress.onOutput, + timeoutMs: OPENSHELL_TIMEOUT_MS, }, ); - expectExitZero(createOldSandbox, "create old Hermes sandbox"); - oldSandboxImageState = rebuildHermesRegistryImageState(resultText(createOldSandbox)); - } finally { - fs.rmSync(oldDockerfileDir, { recursive: true, force: true }); - } - const seededOldSandboxImageState = - oldSandboxImageState ?? fail("old Hermes sandbox create did not produce managed image state"); - await waitForSandboxReady(host, apiKey, activeOpenshellBin, "phase-3"); - const seededOldBaseResolution = readSandboxBaseImageResolutionMetadata( - seededOldSandboxImageState.imageTag, - ); - staleBaseClassification = verifySeededOldBaseResolution( - STALE_BASE_REBUILD, - seededOldBaseResolution, - oldBaseResolutionMetadata, - phase1BaseResolution, - oldBaseIdentity.stdout.trim(), - ); - await artifacts.writeJson("phase-3-old-sandbox-base-identity.json", { - resolutionMetadata: seededOldBaseResolution, - staleClassification: staleBaseClassification, - }); - await removeHermesFixtureImage(host, apiKey, OLD_BASE_TAG, { - artifactName: "phase-3-release-old-hermes-base-tag", - label: `release old Hermes base tag ${OLD_BASE_TAG}`, - }); - progress.phase("seed persistent Hermes state and registry metadata"); - const seededKanban = await host.command( - activeOpenshellBin, - [ - "sandbox", - "exec", - "--name", - SANDBOX_NAME, - "--", - "/usr/bin/python3", - "-c", - KANBAN_TASK_PROBE, - KANBAN_FILE, - KANBAN_TASK_TITLE, - ], - { - artifactName: "phase-4-verify-seeded-kanban", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(seededKanban, "verify historical Hermes kanban seed before rebuild"); - expect(resultText(seededKanban)).toContain(KANBAN_TASK_TITLE); - const writeMarker = await host.command( - activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "sh", "-c", REBUILD_HERMES_STATE.seedScript], - { - artifactName: "phase-4-write-hermes-marker", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(writeMarker, "write Hermes marker"); - const writeExcludedHooksMarker = await host.command( - activeOpenshellBin, - [ - "sandbox", - "exec", - "--name", - SANDBOX_NAME, - "--", - "sh", - "-c", + expectExitZero(writeMarker, "write Hermes marker"); + const writeExcludedHooksMarker = await host.command( + activeOpenshellBin, [ - `mkdir -p ${shellQuote(path.dirname(EXCLUDED_HOOKS_FILE))}`, - `printf '%s' ${shellQuote(REBUILD_HERMES_STATE.markerContent)} > ${shellQuote(EXCLUDED_HOOKS_FILE)}`, - ].join(" && "), - ], - { - artifactName: "phase-4-write-excluded-hermes-hooks-marker", + "sandbox", + "exec", + "--name", + SANDBOX_NAME, + "--", + "sh", + "-c", + [ + `mkdir -p ${shellQuote(path.dirname(EXCLUDED_HOOKS_FILE))}`, + `printf '%s' ${shellQuote(REBUILD_HERMES_STATE.markerContent)} > ${shellQuote(EXCLUDED_HOOKS_FILE)}`, + ].join(" && "), + ], + { + artifactName: "phase-4-write-excluded-hermes-hooks-marker", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(writeExcludedHooksMarker, "write backup:false Hermes hooks marker"); + await cronRestore.seed(); + const seededKanbanDb = await host.command("docker", inspectKanbanTaskArgs(SANDBOX_NAME), { + artifactName: "phase-4-inspect-seeded-kanban-db", env: testEnv(apiKey), redactionValues, timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(writeExcludedHooksMarker, "write backup:false Hermes hooks marker"); - await cronRestore.seed(); - const seededKanbanDb = await host.command("docker", inspectKanbanTaskArgs(SANDBOX_NAME), { - artifactName: "phase-4-inspect-seeded-kanban-db", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }); - expectExitZero(seededKanbanDb, "inspect seeded Hermes kanban database"); - expect(resultText(seededKanbanDb)).toContain(KANBAN_TASK_TITLE); + }); + expectExitZero(seededKanbanDb, "inspect seeded Hermes kanban database"); + expect(resultText(seededKanbanDb)).toContain(KANBAN_TASK_TITLE); - const preEnv = await host.command( - activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/.env"], - { - artifactName: "phase-4-read-pre-rebuild-env", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(preEnv, "read pre-rebuild Hermes .env"); - expect(preEnv.stdout).toContain(`DISCORD_BOT_TOKEN=${DISCORD_PLACEHOLDER}`); - const preConfig = await host.command( - activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/config.yaml"], - { - artifactName: "phase-4-read-pre-rebuild-config", - env: testEnv(apiKey), + const preEnv = await host.command( + activeOpenshellBin, + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/.env"], + { + artifactName: "phase-4-read-pre-rebuild-env", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(preEnv, "read pre-rebuild Hermes .env"); + expect(preEnv.stdout).toContain(`DISCORD_BOT_TOKEN=${DISCORD_PLACEHOLDER}`); + const preConfig = await host.command( + activeOpenshellBin, + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/config.yaml"], + { + artifactName: "phase-4-read-pre-rebuild-config", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(preConfig, "read pre-rebuild Hermes config.yaml"); + expect(preConfig.stdout).toContain("discord:"); + const sessionSummary = seedRegistryAndSession( + dashboardPort ?? fail("Hermes dashboard port allocation disappeared before registry seeding"), + seededOldSandboxImageState, + ); + const seededRegistry = registrySandbox(); + cleanupRegistryDashboardPort = seededRegistry.dashboardPort; + expect( + seededRegistry.imageTag, + "curated rebuild registry must retain the exact old derived image tag for cleanup", + ).toBe(seededOldSandboxImageState.imageTag); + await artifacts.writeJson("phase-4-registry-session-summary.json", { + registryVersion: seededRegistry.agentVersion, + dashboardPort: seededRegistry.dashboardPort, + imageTag: seededRegistry.imageTag, + registryInference: { + provider: seededRegistry.provider, + endpointUrl: seededRegistry.endpointUrl, + credentialEnv: seededRegistry.credentialEnv, + preferredInferenceApi: seededRegistry.preferredInferenceApi, + }, + session: sessionSummary, + }); + const preRebuildApiTokenDigest = await hermesApiTokenDigest( + host, + SANDBOX_NAME, + "phase-4-api-token-before-rebuild", + testEnv(apiKey, { SANDBOX_NAME }), redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(preConfig, "read pre-rebuild Hermes config.yaml"); - expect(preConfig.stdout).toContain("discord:"); - const sessionSummary = seedRegistryAndSession( - dashboardPort ?? fail("Hermes dashboard port allocation disappeared before registry seeding"), - seededOldSandboxImageState, - ); - const seededRegistry = registrySandbox(); - cleanupRegistryDashboardPort = seededRegistry.dashboardPort; - expect( - seededRegistry.imageTag, - "curated rebuild registry must retain the exact old derived image tag for cleanup", - ).toBe(seededOldSandboxImageState.imageTag); - await artifacts.writeJson("phase-4-registry-session-summary.json", { - registryVersion: seededRegistry.agentVersion, - dashboardPort: seededRegistry.dashboardPort, - imageTag: seededRegistry.imageTag, - registryInference: { - provider: seededRegistry.provider, - endpointUrl: seededRegistry.endpointUrl, - credentialEnv: seededRegistry.credentialEnv, - preferredInferenceApi: seededRegistry.preferredInferenceApi, - }, - session: sessionSummary, - }); - const preRebuildApiTokenDigest = await hermesApiTokenDigest( - host, - SANDBOX_NAME, - "phase-4-api-token-before-rebuild", - testEnv(apiKey, { SANDBOX_NAME }), - redactionValues, - OPENSHELL_TIMEOUT_MS, - ); + OPENSHELL_TIMEOUT_MS, + ); - progress.phase("prepare the current-base rebuild condition"); - switch (STALE_BASE_REBUILD) { - case false: { - await artifacts.writeText( - "phase-5-current-base-reuse.txt", - `Reusing phase 1 Hermes base ${phase1BaseResolution.ref} (${phase1BaseResolution.digest ?? phase1BaseResolution.imageId}) through verified alias ${CURRENT_BASE_REUSE_TAG}; rebuild must canonicalize it to the official digest without constructing it again.\n`, - ); - break; - } - case true: { - const classification = - staleBaseClassification ?? fail("stale rebuild lane did not classify its old base hint"); - await artifacts.writeText( - "phase-5-stale-base-note.txt", - `Recorded ${OLD_HERMES_VERSION} as the sandbox's validated old resolution hint; rebuild must reject its ${classification.reason} and refresh to ${phase1BaseResolution.digest ?? phase1BaseResolution.imageId}.\n`, - ); - break; + progress.phase("prepare the current-base rebuild condition"); + switch (STALE_BASE_REBUILD) { + case false: { + await artifacts.writeText( + "phase-5-current-base-reuse.txt", + `Reusing phase 1 Hermes base ${phase1BaseResolution.ref} (${phase1BaseResolution.digest ?? phase1BaseResolution.imageId}) through verified alias ${CURRENT_BASE_REUSE_TAG}; rebuild must canonicalize it to the official digest without constructing it again.\n`, + ); + break; + } + case true: { + const classification = + staleBaseClassification ?? fail("stale rebuild lane did not classify its old base hint"); + await artifacts.writeText( + "phase-5-stale-base-note.txt", + `Recorded ${OLD_HERMES_VERSION} as the sandbox's validated old resolution hint; rebuild must reject its ${classification.reason} and refresh to ${phase1BaseResolution.digest ?? phase1BaseResolution.imageId}.\n`, + ); + break; + } } - } - const routeBeforeRebuild = await requireRebuildHermesHostedInferenceRoute( - host, - testEnv, - apiKey, - HOSTED_MODEL, - "phase-5-inference-route-before-rebuild", - redactionValues, - ); - await artifacts.writeJson("phase-5-inference-route-before-rebuild.json", routeBeforeRebuild); - progress.phase("rebuild the Hermes sandbox"); - const rebuildEnv = testEnv( - undefined, - buildRebuildHermesRecreateEnv(DISCORD_FAKE_TOKEN, baseReusePlan?.childEnv), - ); - expect(rebuildEnv.DISCORD_BOT_TOKEN).toBe(DISCORD_FAKE_TOKEN); - expect(rebuildEnv).not.toHaveProperty("NVIDIA_INFERENCE_API_KEY"); - expect(rebuildEnv).not.toHaveProperty("COMPATIBLE_API_KEY"); - expect(rebuildEnv).not.toHaveProperty("NVIDIA_API_KEY"); - const rebuild = await host.nemoclaw([SANDBOX_NAME, "rebuild", "--yes", "--verbose"], { - artifactName: "phase-6-nemoclaw-rebuild-hermes", - env: rebuildEnv, - redactionValues, - timeoutMs: REBUILD_TIMEOUT_MS, - captureLimitBytes: LONG_COMMAND_CAPTURE_LIMIT_BYTES, - onOutput: progress.onOutput, - }); - cleanupRegistryDashboardPort = readJsonFileOr(REGISTRY_FILE, {}).sandboxes?.[ - SANDBOX_NAME - ]?.dashboardPort; - expectExitZero(rebuild, "nemoclaw rebuild Hermes sandbox"); - const rebuiltRegistry = registrySandbox(); - const rebuiltDashboardPort = requireRebuildHermesDashboardPort( - rebuiltRegistry.dashboardPort, - "rebuilt Hermes registry dashboardPort", - ); - observedForwardPorts.add(rebuiltDashboardPort); - const rebuildOutput = resultText(rebuild); - expect(rebuildOutput).toContain("Hermes API bearer token changed during rebuild"); - expect(rebuildOutput).toContain(`nemoclaw ${SANDBOX_NAME} gateway-token --quiet`); - expect(rebuildOutput).toContain(`Using Hermes Agent base image: ${phase1BaseResolution.ref}`); - expect(rebuildOutput).not.toContain("Rebuilding Hermes Agent base image"); - expect(rebuildOutput).not.toMatch(/provider credential not found/i); - // The gateway starts during recreation and reads its durable state before the - // restore replaces it, so rebuild must hand back a process that started after - // the restore. Either post-restore path reports one; a live gateway that was - // only checked reports neither. - expect(rebuildOutput, "rebuild must report a Hermes gateway bound to the restored state").toMatch( - /Hermes gateway (?:restarted and verified|recovered) after state restore/u, - ); - await waitForSandboxReady(host, apiKey, activeOpenshellBin, "phase-6-post-rebuild"); - await expectSandboxProviderAttachment( - sandbox, - SANDBOX_NAME, - `${SANDBOX_NAME}-discord-bridge`, - "present", - { - artifactName: "phase-6-post-rebuild-provider-attachments", - env: testEnv(apiKey), - }, - ); - - const backupPathText = rebuildOutput.match(/^\s*Backup:\s+(.+)$/mu)?.[1]?.trim(); - const rebuildBackupPath = backupPathText - ? path.resolve(backupPathText) - : fail("Hermes rebuild did not report its state backup path"); - const resolvedBackupRoot = path.resolve(sandboxBackupRoot); - expect( - rebuildBackupPath.startsWith(`${resolvedBackupRoot}${path.sep}`), - "Hermes rebuild backup must remain under the test-owned sandbox backup root", - ).toBe(true); - const backedUpKanbanDatabase = await host.command( - "/usr/bin/python3", - [ - "-c", - KANBAN_TASK_PROBE, - path.join(rebuildBackupPath, path.basename(KANBAN_FILE)), - KANBAN_TASK_TITLE, - ], - { - artifactName: "phase-6-verify-backed-up-kanban-database", - env: buildAvailabilityProbeEnv(), + const routeBeforeRebuild = await requireRebuildHermesHostedInferenceRoute( + host, + testEnv, + apiKey, + HOSTED_MODEL, + "phase-5-inference-route-before-rebuild", redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(backedUpKanbanDatabase, "verify backed-up Hermes kanban database"); - expect(resultText(backedUpKanbanDatabase)).toContain(KANBAN_TASK_TITLE); - REBUILD_HERMES_STATE.assertBackup(rebuildBackupPath); - - const oldImageInspect = await host.command( - "docker", - ["image", "inspect", seededOldSandboxImageState.imageTag], - { - artifactName: "phase-6-old-derived-image-removed", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expect( - typeof oldImageInspect.exitCode === "number" && oldImageInspect.exitCode > 0, - resultText(oldImageInspect), - ).toBe(true); - expect(resultText(oldImageInspect)).toMatch(/No such (?:image|object)(?::|\s)/iu); - await artifacts.writeJson( - "phase-6-replacement-registry-lifecycle-receipt.json", - requireRebuildHermesReplacementLifecycleReceipt(rebuiltRegistry), - ); - - progress.phase("validate upgraded state inference and backup hygiene"); - const restoredMarker = await host.command( - activeOpenshellBin, - REBUILD_HERMES_STATE.restoredProbeArgs(SANDBOX_NAME), - { - artifactName: "phase-7-read-marker-after-rebuild", + ); + await artifacts.writeJson("phase-5-inference-route-before-rebuild.json", routeBeforeRebuild); + await applyRebuildHermesHostPolicyEdit({ + host, + openshellBin: activeOpenshellBin, + sandboxName: SANDBOX_NAME, env: testEnv(apiKey), redactionValues, timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(restoredMarker, "verify restored Hermes state and dashboard profile migration"); - expect(restoredMarker.stdout).toBe(REBUILD_HERMES_STATE.expectedOutput); - - const hermesVersion = await host.command( - "docker", - hermesRuntimeExecArgs(SANDBOX_NAME, ["hermes", "--version"]), - { - artifactName: "phase-7-hermes-version-after-rebuild", - env: testEnv(apiKey), + }); + progress.phase("rebuild the Hermes sandbox"); + const rebuildEnv = testEnv( + undefined, + buildRebuildHermesRecreateEnv(DISCORD_FAKE_TOKEN, baseReusePlan?.childEnv), + ); + expect(rebuildEnv.DISCORD_BOT_TOKEN).toBe(DISCORD_FAKE_TOKEN); + expect(rebuildEnv).not.toHaveProperty("NVIDIA_INFERENCE_API_KEY"); + expect(rebuildEnv).not.toHaveProperty("COMPATIBLE_API_KEY"); + expect(rebuildEnv).not.toHaveProperty("NVIDIA_API_KEY"); + const rebuild = await host.nemoclaw([SANDBOX_NAME, "rebuild", "--yes", "--verbose"], { + artifactName: "phase-6-nemoclaw-rebuild-hermes", + env: rebuildEnv, redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(hermesVersion, "Hermes version after rebuild"); - const hermesVersionText = resultText(hermesVersion); - const actualHermesVersion = hermesVersionText.match(/v(\d+\.\d+\.\d+)/)?.[1]; - expect( - actualHermesVersion, - `Hermes version output did not include expected release ${expectedVersion}: ${hermesVersionText}`, - ).toBe(expectedVersion); - await cronRestore.verify(rebuildOutput, rebuildBackupPath); - await cronRestore.verifyStrandedGateRecovery(); - const restoredKanbanDatabase = await host.command( - activeOpenshellBin, - [ - "sandbox", - "exec", - "--name", + timeoutMs: REBUILD_TIMEOUT_MS, + captureLimitBytes: LONG_COMMAND_CAPTURE_LIMIT_BYTES, + onOutput: progress.onOutput, + }); + cleanupRegistryDashboardPort = readJsonFileOr(REGISTRY_FILE, {}).sandboxes?.[ + SANDBOX_NAME + ]?.dashboardPort; + expectExitZero(rebuild, "nemoclaw rebuild Hermes sandbox"); + const rebuiltRegistry = registrySandbox(); + const rebuiltDashboardPort = requireRebuildHermesDashboardPort( + rebuiltRegistry.dashboardPort, + "rebuilt Hermes registry dashboardPort", + ); + observedForwardPorts.add(rebuiltDashboardPort); + const rebuildOutput = resultText(rebuild); + expect(rebuildOutput).toContain("Hermes API bearer token changed during rebuild"); + expect(rebuildOutput).toContain(`nemoclaw ${SANDBOX_NAME} gateway-token --quiet`); + expect(rebuildOutput).toContain(`Using Hermes Agent base image: ${phase1BaseResolution.ref}`); + expect(rebuildOutput).not.toContain("Rebuilding Hermes Agent base image"); + expect(rebuildOutput).not.toMatch(/provider credential not found/i); + // The gateway starts during recreation and reads its durable state before the + // restore replaces it, so rebuild must hand back a process that started after + // the restore. Either post-restore path reports one; a live gateway that was + // only checked reports neither. + expect( + rebuildOutput, + "rebuild must report a Hermes gateway bound to the restored state", + ).toMatch(/Hermes gateway (?:restarted and verified|recovered) after state restore/u); + await waitForSandboxReady(host, apiKey, activeOpenshellBin, "phase-6-post-rebuild"); + await expectSandboxProviderAttachment( + sandbox, SANDBOX_NAME, - "--", + `${SANDBOX_NAME}-discord-bridge`, + "present", + { + artifactName: "phase-6-post-rebuild-provider-attachments", + env: testEnv(apiKey), + }, + ); + + const backupPathText = rebuildOutput.match(/^\s*Backup:\s+(.+)$/mu)?.[1]?.trim(); + const rebuildBackupPath = backupPathText + ? path.resolve(backupPathText) + : fail("Hermes rebuild did not report its state backup path"); + const resolvedBackupRoot = path.resolve(sandboxBackupRoot); + expect( + rebuildBackupPath.startsWith(`${resolvedBackupRoot}${path.sep}`), + "Hermes rebuild backup must remain under the test-owned sandbox backup root", + ).toBe(true); + const backedUpKanbanDatabase = await host.command( "/usr/bin/python3", - "-c", - KANBAN_TASK_PROBE, - KANBAN_FILE, - KANBAN_TASK_TITLE, - ], - { - artifactName: "phase-7-verify-restored-kanban-database", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(restoredKanbanDatabase, "verify restored Hermes kanban database"); - expect(resultText(restoredKanbanDatabase)).toContain(KANBAN_TASK_TITLE); + [ + "-c", + KANBAN_TASK_PROBE, + path.join(rebuildBackupPath, path.basename(KANBAN_FILE)), + KANBAN_TASK_TITLE, + ], + { + artifactName: "phase-6-verify-backed-up-kanban-database", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(backedUpKanbanDatabase, "verify backed-up Hermes kanban database"); + expect(resultText(backedUpKanbanDatabase)).toContain(KANBAN_TASK_TITLE); + REBUILD_HERMES_STATE.assertBackup(rebuildBackupPath); - const restoredKanban = await host.command( - "docker", - hermesRuntimeExecArgs(SANDBOX_NAME, ["hermes", "kanban", "list", "--json"]), - { - artifactName: "phase-7-list-kanban-after-rebuild", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(restoredKanban, "list Hermes kanban tasks after rebuild"); - expect(resultText(restoredKanban)).toContain(KANBAN_TASK_TITLE); + const oldImageInspect = await host.command( + "docker", + ["image", "inspect", seededOldSandboxImageState.imageTag], + { + artifactName: "phase-6-old-derived-image-removed", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expect( + typeof oldImageInspect.exitCode === "number" && oldImageInspect.exitCode > 0, + resultText(oldImageInspect), + ).toBe(true); + expect(resultText(oldImageInspect)).toMatch(/No such (?:image|object)(?::|\s)/iu); + await artifacts.writeJson( + "phase-6-replacement-registry-lifecycle-receipt.json", + requireRebuildHermesReplacementLifecycleReceipt(rebuiltRegistry), + ); - const excludedHooksState = await host.command( - activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "test", "!", "-e", EXCLUDED_HOOKS_FILE], - { - artifactName: "phase-7-verify-excluded-hermes-hooks-state", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(excludedHooksState, "verify backup:false Hermes hooks state was not restored"); + progress.phase("validate upgraded state inference and backup hygiene"); + const restoredMarker = await host.command( + activeOpenshellBin, + REBUILD_HERMES_STATE.restoredProbeArgs(SANDBOX_NAME), + { + artifactName: "phase-7-read-marker-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(restoredMarker, "verify restored Hermes state and dashboard profile migration"); + expect(restoredMarker.stdout).toBe(REBUILD_HERMES_STATE.expectedOutput); - const restoredEnv = await host.command( - activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/.env"], - { - artifactName: "phase-7-read-env-after-rebuild", + const hermesVersion = await host.command( + "docker", + hermesRuntimeExecArgs(SANDBOX_NAME, ["hermes", "--version"]), + { + artifactName: "phase-7-hermes-version-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(hermesVersion, "Hermes version after rebuild"); + const hermesVersionText = resultText(hermesVersion); + const actualHermesVersion = hermesVersionText.match(/v(\d+\.\d+\.\d+)/)?.[1]; + expect( + actualHermesVersion, + `Hermes version output did not include expected release ${expectedVersion}: ${hermesVersionText}`, + ).toBe(expectedVersion); + await cronRestore.verify(rebuildOutput, rebuildBackupPath); + await cronRestore.verifyStrandedGateRecovery(); + const restoredKanbanDatabase = await host.command( + activeOpenshellBin, + [ + "sandbox", + "exec", + "--name", + SANDBOX_NAME, + "--", + "/usr/bin/python3", + "-c", + KANBAN_TASK_PROBE, + KANBAN_FILE, + KANBAN_TASK_TITLE, + ], + { + artifactName: "phase-7-verify-restored-kanban-database", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(restoredKanbanDatabase, "verify restored Hermes kanban database"); + expect(resultText(restoredKanbanDatabase)).toContain(KANBAN_TASK_TITLE); + + await assertRebuildHermesHostPolicyEditSurvives({ + host, + openshellBin: activeOpenshellBin, + sandboxName: SANDBOX_NAME, env: testEnv(apiKey), redactionValues, timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(restoredEnv, "read Hermes .env after rebuild"); - expect(restoredEnv.stdout).not.toContain("DISCORD_BOT_TOKEN="); + }); - const postRebuildApiTokenDigest = await hermesApiTokenDigest( - host, - SANDBOX_NAME, - "phase-7-api-token-after-rebuild", - testEnv(apiKey, { SANDBOX_NAME }), - redactionValues, - OPENSHELL_TIMEOUT_MS, - ); - const stablePostRebuildApiTokenDigest = await hermesApiTokenDigest( - host, - SANDBOX_NAME, - "phase-7-api-token-stability-check", - testEnv(apiKey, { SANDBOX_NAME }), - redactionValues, - OPENSHELL_TIMEOUT_MS, - ); - expect(postRebuildApiTokenDigest).not.toBe(preRebuildApiTokenDigest); - expect(stablePostRebuildApiTokenDigest).toBe(postRebuildApiTokenDigest); + const restoredKanban = await host.command( + "docker", + hermesRuntimeExecArgs(SANDBOX_NAME, ["hermes", "kanban", "list", "--json"]), + { + artifactName: "phase-7-list-kanban-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(restoredKanban, "list Hermes kanban tasks after rebuild"); + expect(resultText(restoredKanban)).toContain(KANBAN_TASK_TITLE); - const restoredConfig = await host.command( - activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/config.yaml"], - { - artifactName: "phase-7-read-config-after-rebuild", - env: testEnv(apiKey), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(restoredConfig, "read Hermes config.yaml after rebuild"); - expect(restoredConfig.stdout).toContain("discord:"); + const excludedHooksState = await host.command( + activeOpenshellBin, + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "test", "!", "-e", EXCLUDED_HOOKS_FILE], + { + artifactName: "phase-7-verify-excluded-hermes-hooks-state", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(excludedHooksState, "verify backup:false Hermes hooks state was not restored"); - const updatedRegistryVersion = rebuiltRegistry.agentVersion; - expect(updatedRegistryVersion).toEqual(expect.any(String)); - expect(updatedRegistryVersion).not.toBe(OLD_HERMES_REGISTRY_VERSION); - const rebuiltImageRef = requireRebuildHermesFinalImageRef( - rebuiltRegistry.imageTag, - SANDBOX_NAME, - ); - expect( - rebuiltImageRef, - "Hermes rebuild must replace the seeded derived image with a new managed image", - ).not.toBe(seededOldSandboxImageState.imageTag); - const finalImageInspect = await host.command( - "docker", - ["image", "inspect", "--format", "{{json .}}", rebuiltImageRef], - { - artifactName: "phase-7-inspect-final-hermes-base-identity", - env: buildAvailabilityProbeEnv(), + const restoredEnv = await host.command( + activeOpenshellBin, + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/.env"], + { + artifactName: "phase-7-read-env-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(restoredEnv, "read Hermes .env after rebuild"); + expect(restoredEnv.stdout).not.toContain("DISCORD_BOT_TOKEN="); + + const postRebuildApiTokenDigest = await hermesApiTokenDigest( + host, + SANDBOX_NAME, + "phase-7-api-token-after-rebuild", + testEnv(apiKey, { SANDBOX_NAME }), redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(finalImageInspect, "inspect final Hermes base identity"); - const managedAuthority = readManagedWorkloadAuthority( - rebuiltRegistry as unknown as SandboxEntry, - ); - const finalBaseEvidence = managedAuthority - ? (() => { - expect(managedAuthority.agent).toBe("hermes"); - assertManagedImageReceiptMatchesSelectedCohort({ - environment: process.env, - expectedAgent: "hermes", - workload: managedAuthority.receipt as unknown as Record, - }); - expect(rebuiltImageRef).toBe(managedAuthority.receipt.reference); - return verifyRebuildHermesManagedImageIdentity( - managedAuthority.receipt.reference, + OPENSHELL_TIMEOUT_MS, + ); + const stablePostRebuildApiTokenDigest = await hermesApiTokenDigest( + host, + SANDBOX_NAME, + "phase-7-api-token-stability-check", + testEnv(apiKey, { SANDBOX_NAME }), + redactionValues, + OPENSHELL_TIMEOUT_MS, + ); + expect(postRebuildApiTokenDigest).not.toBe(preRebuildApiTokenDigest); + expect(stablePostRebuildApiTokenDigest).toBe(postRebuildApiTokenDigest); + + const restoredConfig = await host.command( + activeOpenshellBin, + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/config.yaml"], + { + artifactName: "phase-7-read-config-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(restoredConfig, "read Hermes config.yaml after rebuild"); + expect(restoredConfig.stdout).toContain("discord:"); + + const updatedRegistryVersion = rebuiltRegistry.agentVersion; + expect(updatedRegistryVersion).toEqual(expect.any(String)); + expect(updatedRegistryVersion).not.toBe(OLD_HERMES_REGISTRY_VERSION); + const rebuiltImageRef = requireRebuildHermesFinalImageRef( + rebuiltRegistry.imageTag, + SANDBOX_NAME, + ); + expect( + rebuiltImageRef, + "Hermes rebuild must replace the seeded derived image with a new managed image", + ).not.toBe(seededOldSandboxImageState.imageTag); + const finalImageInspect = await host.command( + "docker", + ["image", "inspect", "--format", "{{json .}}", rebuiltImageRef], + { + artifactName: "phase-7-inspect-final-hermes-base-identity", + env: buildAvailabilityProbeEnv(), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(finalImageInspect, "inspect final Hermes base identity"); + const managedAuthority = readManagedWorkloadAuthority( + rebuiltRegistry as unknown as SandboxEntry, + ); + const finalBaseEvidence = managedAuthority + ? (() => { + expect(managedAuthority.agent).toBe("hermes"); + assertManagedImageReceiptMatchesSelectedCohort({ + environment: process.env, + expectedAgent: "hermes", + workload: managedAuthority.receipt as unknown as Record, + }); + expect(rebuiltImageRef).toBe(managedAuthority.receipt.reference); + return verifyRebuildHermesManagedImageIdentity( + managedAuthority.receipt.reference, + finalImageInspect.stdout.trim(), + ); + })() + : verifyRebuildHermesFinalBaseIdentity( + STALE_BASE_REBUILD, + phase1BaseResolution, + oldBaseResolutionMetadata, + currentBaseSourceInspect?.stdout.trim() ?? + fail("phase 1 current Hermes base inspection disappeared"), + oldBaseIdentity.stdout.trim(), finalImageInspect.stdout.trim(), ); - })() - : verifyRebuildHermesFinalBaseIdentity( - STALE_BASE_REBUILD, - phase1BaseResolution, - oldBaseResolutionMetadata, - currentBaseSourceInspect?.stdout.trim() ?? - fail("phase 1 current Hermes base inspection disappeared"), - oldBaseIdentity.stdout.trim(), - finalImageInspect.stdout.trim(), - ); - await artifacts.writeJson("phase-7-final-base-identity.json", { - rebuiltImageRef, - rebuiltDashboardPort, - resolutionMetadata: readSandboxBaseImageResolutionMetadata(rebuiltImageRef), - ...finalBaseEvidence, - }); + await artifacts.writeJson("phase-7-final-base-identity.json", { + rebuiltImageRef, + rebuiltDashboardPort, + resolutionMetadata: readSandboxBaseImageResolutionMetadata(rebuiltImageRef), + ...finalBaseEvidence, + }); - const inferencePayload = JSON.stringify({ - model: HOSTED_MODEL, - messages: [{ role: "user", content: "Reply with exactly one word: PONG" }], - max_tokens: 100, - }); - const inference = await host.command( - activeOpenshellBin, - [ - "sandbox", - "exec", - "--name", - SANDBOX_NAME, - "--", - "sh", - "-lc", - `curl -s --max-time 60 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' -d ${shellQuote(inferencePayload)}`, - ], - { - artifactName: "phase-7-inference-after-rebuild", - env: testEnv(apiKey), - redactionValues, - timeoutMs: 90_000, - }, - ); - await artifacts.writeJson("phase-7-inference-summary.json", { - exitCode: inference.exitCode, - pong: /PONG/i.test(resultText(inference)), - note: /PONG/i.test(resultText(inference)) - ? "Inference returned PONG after rebuild." - : "Inference check is non-fatal, matching the former shell lane's external API tolerance.", - }); + const inferencePayload = JSON.stringify({ + model: HOSTED_MODEL, + messages: [{ role: "user", content: "Reply with exactly one word: PONG" }], + max_tokens: 100, + }); + const inference = await host.command( + activeOpenshellBin, + [ + "sandbox", + "exec", + "--name", + SANDBOX_NAME, + "--", + "sh", + "-lc", + `curl -s --max-time 60 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' -d ${shellQuote(inferencePayload)}`, + ], + { + artifactName: "phase-7-inference-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: 90_000, + }, + ); + await artifacts.writeJson("phase-7-inference-summary.json", { + exitCode: inference.exitCode, + pong: /PONG/i.test(resultText(inference)), + note: /PONG/i.test(resultText(inference)) + ? "Inference returned PONG after rebuild." + : "Inference check is non-fatal, matching the former shell lane's external API tolerance.", + }); - expect(fs.existsSync(sandboxBackupRoot), `Backup directory missing: ${sandboxBackupRoot}`).toBe( - true, - ); - const leaks = listCredentialLeakPaths(sandboxBackupRoot, { - extraSecrets: [apiKey, DISCORD_FAKE_TOKEN, PRE_REBUILD_API_SERVER_KEY], - }); - await artifacts.writeJson("phase-7-backup-credential-scan.json", { - backupRoot: sandboxBackupRoot, - leaks, - }); + expect(fs.existsSync(sandboxBackupRoot), `Backup directory missing: ${sandboxBackupRoot}`).toBe( + true, + ); + const leaks = listCredentialLeakPaths(sandboxBackupRoot, { + extraSecrets: [apiKey, DISCORD_FAKE_TOKEN, PRE_REBUILD_API_SERVER_KEY], + }); + await artifacts.writeJson("phase-7-backup-credential-scan.json", { + backupRoot: sandboxBackupRoot, + leaks, + }); - // Capture per-phase and total wall time tagged with the runner class so - // before/after comparisons for #7144 stay on the same runner class. Written - // before the final gate so the timing artifact survives an assertion failure. - await artifacts.writeJson( - "rebuild-hermes-timing.json", - buildRebuildHermesTimingSummary({ - lane: STALE_BASE_REBUILD ? "stale-base" : "normal", - timeline: progress.timeline(), - runnerClass: describeRunnerClass(), - capturedAtIso: new Date().toISOString(), - }), - ); + // Capture per-phase and total wall time tagged with the runner class so + // before/after comparisons for #7144 stay on the same runner class. Written + // before the final gate so the timing artifact survives an assertion failure. + await artifacts.writeJson( + "rebuild-hermes-timing.json", + buildRebuildHermesTimingSummary({ + lane: STALE_BASE_REBUILD ? "stale-base" : "normal", + timeline: progress.timeline(), + runnerClass: describeRunnerClass(), + capturedAtIso: new Date().toISOString(), + }), + ); - expect(leaks, "backup files must not contain credential-shaped values").toEqual([]); -}); + expect(leaks, "backup files must not contain credential-shaped values").toEqual([]); + }, +); diff --git a/test/e2e/live/rebuild-openclaw.test.ts b/test/e2e/live/rebuild-openclaw.test.ts index 27e4414149f..e57832486ad 100644 --- a/test/e2e/live/rebuild-openclaw.test.ts +++ b/test/e2e/live/rebuild-openclaw.test.ts @@ -226,8 +226,6 @@ function seedRegistryAndSession(dashboardPort: number): void { model: DEFAULT_MODEL, provider: "compatible-endpoint", gpuEnabled: false, - policies: [], - policyTier: null, agent: null, agentVersion: OLD_OPENCLAW_VERSION, dashboardPort, @@ -262,7 +260,6 @@ function seedRegistryAndSession(dashboardPort: number): void { inference: complete, openclaw: pending, agent_setup: pending, - policies: pending, }, }); writeJsonFile(SESSION_FILE, session); @@ -346,7 +343,7 @@ test( "onboard the current OpenClaw sandbox", "build the old OpenClaw base image", "create the old OpenClaw sandbox", - "seed persistent state policy and registry metadata", + "seed persistent state and registry metadata", "restore the current OpenClaw base image", "rebuild the OpenClaw sandbox", "validate upgraded state policy inference and backup hygiene", @@ -567,7 +564,7 @@ test( // Phase 4: seed workspace state, an existing gateway token, and registry / // resume-session state so `nemoclaw rebuild --yes` drives the same // user-visible rebuild path as the former shell test. - progress.phase("seed persistent state policy and registry metadata"); + progress.phase("seed persistent state and registry metadata"); const markerWrite = await sandbox.exec( SANDBOX_NAME, [ @@ -637,7 +634,6 @@ print(json.dumps({'seeded': saved == os.environ['PRE_REBUILD_GATEWAY_TOKEN'], 'h provider: seededSandbox.provider, agentVersion: seededSandbox.agentVersion, dashboardPort: seededSandbox.dashboardPort, - policyCount: Array.isArray(seededSandbox.policies) ? seededSandbox.policies.length : 0, }, session: { sandboxName: sessionAfterSeed.sandboxName, @@ -653,8 +649,8 @@ print(json.dumps({'seeded': saved == os.environ['PRE_REBUILD_GATEWAY_TOKEN'], 'h const routeResult = await configureGatewayInferenceRoute(host, apiKey); expectExitZero(routeResult, "configure gateway inference route before rebuild"); - // Phase 4.5: apply policy presets through the public CLI, then verify both - // registry persistence and the live OpenShell gateway policy. + // Phase 4.5: apply policy presets through the public CLI, then verify the + // live OpenShell gateway policy without consulting registry policy state. for (const preset of POLICY_PRESETS) { const policyAdd = await host.command( "node", @@ -679,7 +675,26 @@ print(json.dumps({'seeded': saved == os.environ['PRE_REBUILD_GATEWAY_TOKEN'], 'h expect(prePolicy.stdout).toMatch(/pypi|pypi\.org/i); expect(prePolicy.stdout).toMatch(/telegram/i); expect(prePolicy.stdout).toContain("api.telegram.org"); - expect(registrySandbox().policies).toEqual(expect.arrayContaining([...POLICY_PRESETS])); + const hostPolicyEdit = await sandbox.openshell( + [ + "policy", + "update", + SANDBOX_NAME, + "--add-endpoint", + "host-edit-rebuild-openclaw.example.com:443:read-only:rest:enforce", + "--rule-name", + "host_edit_rebuild_openclaw_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: "phase-4-host-policy-edit-before-rebuild", + env: dockerContextEnv(), + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(hostPolicyEdit, "apply host policy edit before rebuild"); const prePolicyList = await host.command( "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "policy-list"], @@ -793,14 +808,11 @@ print(json.dumps({'tokenPresent': bool(token), 'tokenRotated': token != old, 'ru await artifacts.writeJson("phase-7-rebuild-manifest-summary.json", { backupDir, stateDirCount: Array.isArray(manifest.stateDirs) ? manifest.stateDirs.length : undefined, - policyPresets: manifest.policyPresets, telegramBridgeTraffic: "real bot response remains owned by the messaging-providers E2E; this rebuild target asserts restored Telegram policy and api.telegram.org reachability", }); - expect(manifest.policyPresets).toEqual(expect.arrayContaining([...POLICY_PRESETS])); expect(backupCredentialLeakPaths(backupDir, PRE_REBUILD_GATEWAY_TOKEN)).toEqual([]); - expect(registrySandbox().policies).toEqual(expect.arrayContaining([...POLICY_PRESETS])); const postPolicy = await sandbox.openshell(["policy", "get", "--full", SANDBOX_NAME], { artifactName: "phase-7-live-policy-after-rebuild", env: dockerContextEnv(), @@ -811,6 +823,7 @@ print(json.dumps({'tokenPresent': bool(token), 'tokenRotated': token != old, 'ru expect(postPolicy.stdout).toMatch(/pypi|pypi\.org/i); expect(postPolicy.stdout).toMatch(/telegram/i); expect(postPolicy.stdout).toContain("api.telegram.org"); + expect(postPolicy.stdout).toContain("host_edit_rebuild_openclaw_e2e"); const postPolicyList = await host.command( "node", diff --git a/test/e2e/live/shields-config.test.ts b/test/e2e/live/shields-config.test.ts index 402cd81413a..efc8a87c126 100644 --- a/test/e2e/live/shields-config.test.ts +++ b/test/e2e/live/shields-config.test.ts @@ -535,772 +535,861 @@ function readTimerMarker(sandboxName: string): { return JSON.parse(fs.readFileSync(TIMER_FILE(sandboxName), "utf8")); } -test("shields-config: live Shields lifecycle restores stopped OpenClaw under both postures (#8112)", { - timeout: TEST_TIMEOUT_MS, - meta: { - e2ePhases: [ - "confirm Docker and onboard the shields sandbox", - "establish the mutable unified OpenClaw config", - "lock config and workspace and inspect redaction", - "restart OpenClaw with shields up", - "detect host-root config drift and refuse resealing", - "re-seal a perms-only .config-hash drift instead of failing closed", - "unlock shields and inspect the audit trail", - "restart OpenClaw with shields down", - "recover shields after a dead restore timer", - "reject duplicate shields transitions", - "prove installed failed-startup guard refuses a live child and supported shields down unlocks childless state", - "record shields contract evidence", - ], +test( + "shields-config: live Shields lifecycle restores stopped OpenClaw under both postures (#8112)", + { + timeout: TEST_TIMEOUT_MS, + meta: { + e2ePhases: [ + "confirm Docker and onboard the shields sandbox", + "establish the mutable unified OpenClaw config", + "lock config and workspace and inspect redaction", + "restart OpenClaw with shields up", + "detect host-root config drift and refuse resealing", + "re-seal a perms-only .config-hash drift instead of failing closed", + "unlock shields and inspect the audit trail", + "restart OpenClaw with shields down", + "recover shields after a dead restore timer", + "reject duplicate shields transitions", + "prove installed failed-startup guard refuses a live child and supported shields down unlocks childless state", + "record shields contract evidence", + ], + }, }, -}, async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { - await artifacts.target.declare({ - id: "shields-config", - boundary: "live-sandbox-shields-config", - contracts: [ - "source install creates a live OpenClaw sandbox", - "default config starts mutable with unified .openclaw layout", - "documented nemoclaw exec doctor path preserves 2770/660 and gateway writes", - "fresh mutable-default shields down preserves the mutable config posture", - "shields up locks config/workspace and config get redacts secrets", - "start restores a stopped OpenClaw sandbox while shields are up", - "empty sealed credentials allow traversal but deny sandbox identity access", - "host-root chmod-write-chmod tamper is detected as content drift", - "a perms-only .config-hash drift is re-sealed by shields up, not failed closed", - "shields down restores mutable modes and records audit JSONL", - "start restores a stopped OpenClaw sandbox while shields are down", - "dead auto-restore timer inline recovery re-locks config and .config-hash", - "double shields-up/down operations are rejected", - "installed failed-startup guard refuses a live child and supported shields down atomically unlocks childless state", - ], - }); + async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { + await artifacts.target.declare({ + id: "shields-config", + boundary: "live-sandbox-shields-config", + contracts: [ + "source install creates a live OpenClaw sandbox", + "default config starts mutable with unified .openclaw layout", + "documented nemoclaw exec doctor path preserves 2770/660 and gateway writes", + "fresh mutable-default shields down preserves the mutable config posture", + "host policy edits survive interactive and timer Shields restoration", + "shields up locks config/workspace and config get redacts secrets", + "start restores a stopped OpenClaw sandbox while shields are up", + "empty sealed credentials allow traversal but deny sandbox identity access", + "host-root chmod-write-chmod tamper is detected as content drift", + "a perms-only .config-hash drift is re-sealed by shields up, not failed closed", + "shields down restores mutable modes and records audit JSONL", + "start restores a stopped OpenClaw sandbox while shields are down", + "dead auto-restore timer inline recovery re-locks config and .config-hash", + "double shields-up/down operations are rejected", + "installed failed-startup guard refuses a live child and supported shields down atomically unlocks childless state", + ], + }); - const dockerInfo = await docker(host, ["info"], { - artifactName: "prereq-docker-info", - timeoutMs: 30_000, - }); - if (dockerInfo.exitCode !== 0) { - if (process.env.GITHUB_ACTIONS === "true") { - throw new Error(`Docker is required for shields-config live E2E: ${resultText(dockerInfo)}`); + const dockerInfo = await docker(host, ["info"], { + artifactName: "prereq-docker-info", + timeoutMs: 30_000, + }); + if (dockerInfo.exitCode !== 0) { + if (process.env.GITHUB_ACTIONS === "true") { + throw new Error( + `Docker is required for shields-config live E2E: ${resultText(dockerInfo)}`, + ); + } + skip("Docker is required for shields-config live E2E"); } - skip("Docker is required for shields-config live E2E"); - } - const hosted = requireHostedInferenceConfig(secrets); - const apiKey = hosted.apiKey; + const hosted = requireHostedInferenceConfig(secrets); + const apiKey = hosted.apiKey; - await preCleanSandbox(host, sandbox, "pre-cleanup"); - cleanup.trackDisposable(`remove shields state for ${SANDBOX_NAME}`, () => { - [STATE_FILE(SANDBOX_NAME), TIMER_FILE(SANDBOX_NAME), AUDIT_FILE].forEach((file) => { - fs.rmSync(file, { force: true }); - }); - fs.rmSync(path.join(os.homedir(), ".nemoclaw", "onboard.lock"), { - force: true, + await preCleanSandbox(host, sandbox, "pre-cleanup"); + cleanup.trackDisposable(`remove shields state for ${SANDBOX_NAME}`, () => { + [STATE_FILE(SANDBOX_NAME), TIMER_FILE(SANDBOX_NAME), AUDIT_FILE].forEach((file) => { + fs.rmSync(file, { force: true }); + }); + fs.rmSync(path.join(os.homedir(), ".nemoclaw", "onboard.lock"), { + force: true, + }); }); - }); - const gatewayCleanupOptions = { - artifactName: "cleanup-openshell-gateway-destroy", - env: commandEnv(), - redactionValues: [apiKey], - timeoutMs: 60_000, - }; - cleanup.trackGateway( - { - cleanupGatewayRegistration: (name: string) => - cleanupWhenOpenShellAvailable( - host, - { - artifactName: "cleanup-probe-openshell-gateway", - env: gatewayCleanupOptions.env, - redactionValues: gatewayCleanupOptions.redactionValues, - timeoutMs: 30_000, - }, - () => host.cleanupGatewayRegistration(name, gatewayCleanupOptions), - ), - }, - "nemoclaw", - gatewayCleanupOptions, - ); - const openshellSandboxCleanupOptions = { - artifactName: "cleanup-openshell-sandbox-delete", - env: commandEnv(), - redactionValues: [apiKey], - timeoutMs: 60_000, - }; - cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => - cleanupWhenOpenShellAvailable( - host, + const gatewayCleanupOptions = { + artifactName: "cleanup-openshell-gateway-destroy", + env: commandEnv(), + redactionValues: [apiKey], + timeoutMs: 60_000, + }; + cleanup.trackGateway( { - artifactName: "cleanup-probe-openshell-sandbox", - env: openshellSandboxCleanupOptions.env, - redactionValues: openshellSandboxCleanupOptions.redactionValues, - timeoutMs: 30_000, + cleanupGatewayRegistration: (name: string) => + cleanupWhenOpenShellAvailable( + host, + { + artifactName: "cleanup-probe-openshell-gateway", + env: gatewayCleanupOptions.env, + redactionValues: gatewayCleanupOptions.redactionValues, + timeoutMs: 30_000, + }, + () => host.cleanupGatewayRegistration(name, gatewayCleanupOptions), + ), }, - () => sandbox.cleanupSandbox(SANDBOX_NAME, openshellSandboxCleanupOptions), - ), - ); - const nemoclawSandboxCleanupOptions = { - artifactName: "cleanup-nemoclaw-destroy", - env: commandEnv(), - redactionValues: [apiKey], - timeoutMs: 120_000, - }; - cleanup.trackSandbox( - { - cleanupSandbox: (name: string) => - cleanupWhenCommandAvailable( - host, - host.commandPath, - { - artifactName: "cleanup-probe-nemoclaw-sandbox", - env: nemoclawSandboxCleanupOptions.env, - redactionValues: nemoclawSandboxCleanupOptions.redactionValues, - timeoutMs: 30_000, - }, - () => host.cleanupSandbox(name, nemoclawSandboxCleanupOptions), - ), - }, - SANDBOX_NAME, - nemoclawSandboxCleanupOptions, - ); - - const install = await installedShellCommand( - host, - `cd ${JSON.stringify(REPO_ROOT)} && bash install.sh --non-interactive --fresh`, - { - artifactName: "phase-1-install-shields-config", - env: commandEnv({ - ...hosted.env, - NEMOCLAW_RECREATE_SANDBOX: "1", - }), + "nemoclaw", + gatewayCleanupOptions, + ); + const openshellSandboxCleanupOptions = { + artifactName: "cleanup-openshell-sandbox-delete", + env: commandEnv(), redactionValues: [apiKey], - timeoutMs: INSTALL_TIMEOUT_MS, - }, - ); - expect(install.exitCode, resultText(install)).toBe(0); - - const cliVersion = await installedShellCommand( - host, - "command -v nemoclaw && command -v openshell", - { - artifactName: "phase-1-installed-commands-on-path", - }, - ); - expect(cliVersion.exitCode, resultText(cliVersion)).toBe(0); - - progress.phase("establish the mutable unified OpenClaw config"); - const configDefault = await statPath(sandbox, CONFIG_PATH, "phase-2-config-perms-default"); - expect(configDefault.mode).toBe("660"); - expect(configDefault.owner).toBe("sandbox:sandbox"); - const dirDefault = await statPath(sandbox, CONFIG_DIR, "phase-2-config-dir-perms-default"); - expect(dirDefault.mode).toBe("2770"); - expect(dirDefault.owner).toBe("sandbox:sandbox"); - - const doctor = await runNemoclaw( - host, - [ + timeoutMs: 60_000, + }; + cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => + cleanupWhenOpenShellAvailable( + host, + { + artifactName: "cleanup-probe-openshell-sandbox", + env: openshellSandboxCleanupOptions.env, + redactionValues: openshellSandboxCleanupOptions.redactionValues, + timeoutMs: 30_000, + }, + () => sandbox.cleanupSandbox(SANDBOX_NAME, openshellSandboxCleanupOptions), + ), + ); + const nemoclawSandboxCleanupOptions = { + artifactName: "cleanup-nemoclaw-destroy", + env: commandEnv(), + redactionValues: [apiKey], + timeoutMs: 120_000, + }; + cleanup.trackSandbox( + { + cleanupSandbox: (name: string) => + cleanupWhenCommandAvailable( + host, + host.commandPath, + { + artifactName: "cleanup-probe-nemoclaw-sandbox", + env: nemoclawSandboxCleanupOptions.env, + redactionValues: nemoclawSandboxCleanupOptions.redactionValues, + timeoutMs: 30_000, + }, + () => host.cleanupSandbox(name, nemoclawSandboxCleanupOptions), + ), + }, SANDBOX_NAME, - "exec", - "--", - "bash", - "-c", - 'openclaw doctor --fix; rc=$?; printf "doctor_exit:%s\\n" "$rc"; stat -c "doctor_file_mode:%a" /sandbox/.openclaw/openclaw.json; stat -c "doctor_dir_mode:%a" /sandbox/.openclaw', - ], - { - artifactName: "phase-2b-documented-exec-doctor-fix", - timeoutMs: 5 * 60_000, - }, - ); - expect(doctor.exitCode, resultText(doctor)).toBe(0); - expect(resultText(doctor)).toMatch(/doctor_exit:\d+/); - expect(resultText(doctor)).toContain("doctor_file_mode:600"); - expect(resultText(doctor)).toContain("doctor_dir_mode:700"); - - const configAfterDoctor = await statPath( - sandbox, - CONFIG_PATH, - "phase-2b-config-perms-after-doctor", - ); - expect(configAfterDoctor).toMatchObject({ mode: "660", owner: "sandbox:sandbox" }); - const dirAfterDoctor = await statPath( - sandbox, - CONFIG_DIR, - "phase-2b-config-dir-perms-after-doctor", - ); - expect(dirAfterDoctor).toMatchObject({ mode: "2770", owner: "sandbox:sandbox" }); - - const containerId = await findSandboxContainer(host); - const gatewayWrite = await docker( - host, - ["exec", "-u", "gateway", containerId, "sh", "-c", `printf ' ' >>${CONFIG_PATH}`], - { - artifactName: "phase-2b-gateway-config-append-after-doctor", - timeoutMs: 30_000, - }, - ); - expect(gatewayWrite.exitCode, resultText(gatewayWrite)).toBe(0); - const refreshHash = await sandboxShell( - sandbox, - `cd ${CONFIG_DIR} && sha256sum openclaw.json >.config-hash`, - { artifactName: "phase-2b-refresh-hash-after-gateway-write" }, - ); - expect(refreshHash.exitCode, resultText(refreshHash)).toBe(0); - - const statusDefault = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-2-shields-status-default", - }); - expect(statusDefault.exitCode, resultText(statusDefault)).toBe(0); - expect(statusDefault.stdout).toContain("Shields: NOT CONFIGURED"); + nemoclawSandboxCleanupOptions, + ); - const freshMutableDown = await runNemoclaw( - host, - [SANDBOX_NAME, "shields", "down", "--timeout", "5m", "--reason", "Fresh mutable-default E2E"], - { artifactName: "phase-2c-fresh-mutable-shields-down" }, - ); - expect(freshMutableDown.exitCode, resultText(freshMutableDown)).toBe(0); - expect(resultText(freshMutableDown)).toContain("Config unlocked"); - expect(await statPath(sandbox, CONFIG_PATH, "phase-2c-config-perms-after-down")).toMatchObject({ - mode: "660", - owner: "sandbox:sandbox", - }); - expect(await statPath(sandbox, CONFIG_DIR, "phase-2c-config-dir-perms-after-down")).toMatchObject( - { - mode: "2770", - owner: "sandbox:sandbox", - }, - ); + const install = await installedShellCommand( + host, + `cd ${JSON.stringify(REPO_ROOT)} && bash install.sh --non-interactive --fresh`, + { + artifactName: "phase-1-install-shields-config", + env: commandEnv({ + ...hosted.env, + NEMOCLAW_RECREATE_SANDBOX: "1", + }), + redactionValues: [apiKey], + timeoutMs: INSTALL_TIMEOUT_MS, + }, + ); + expect(install.exitCode, resultText(install)).toBe(0); - const layoutProbe = await sandboxShell( - sandbox, - [ - `bad=0`, - `if [ -e /sandbox/.openclaw-data ] || [ -L /sandbox/.openclaw-data ]; then echo "legacy data dir exists: /sandbox/.openclaw-data"; bad=1; fi`, - `for entry in /sandbox/.openclaw/*; do [ -L "$entry" ] || continue; target="$(readlink -f "$entry" 2>/dev/null || readlink "$entry" 2>/dev/null || true)"; case "$target" in /sandbox/.openclaw-data/*) echo "legacy symlink remains: $entry -> $target"; bad=1 ;; esac; done`, - `exit "$bad"`, - ].join("; "), - { artifactName: "phase-2-unified-openclaw-layout" }, - ); - expect(layoutProbe.exitCode, resultText(layoutProbe)).toBe(0); - expect(resultText(layoutProbe).trim()).toBe(""); + const cliVersion = await installedShellCommand( + host, + "command -v nemoclaw && command -v openshell", + { + artifactName: "phase-1-installed-commands-on-path", + }, + ); + expect(cliVersion.exitCode, resultText(cliVersion)).toBe(0); - progress.phase("lock config and workspace and inspect redaction"); - const shieldsUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-3-shields-up", - }); - expect(shieldsUp.exitCode, resultText(shieldsUp)).toBe(0); - expect(resultText(shieldsUp)).toContain("Lockdown active"); - // Keep fixture teardown out of an artificial mutable window if a later - // assertion aborts before the explicit final restore below. - cleanup.trackDisposable(`restore shields for ${SANDBOX_NAME} before destroy`, async () => { - const restore = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "cleanup-shields-up-before-destroy", - }); - expect(restore.exitCode, resultText(restore)).toBe(0); - expect(resultText(restore)).toMatch(/Lockdown (?:is already )?active/); - }); + progress.phase("establish the mutable unified OpenClaw config"); + const configDefault = await statPath(sandbox, CONFIG_PATH, "phase-2-config-perms-default"); + expect(configDefault.mode).toBe("660"); + expect(configDefault.owner).toBe("sandbox:sandbox"); + const dirDefault = await statPath(sandbox, CONFIG_DIR, "phase-2-config-dir-perms-default"); + expect(dirDefault.mode).toBe("2770"); + expect(dirDefault.owner).toBe("sandbox:sandbox"); - const configUp = await statPath(sandbox, CONFIG_PATH, "phase-3-config-perms-up"); - expect(configUp.mode).toMatch(/^4[0-4][0-4]$/); - expect(configUp.owner).toBe("root:root"); + const doctor = await runNemoclaw( + host, + [ + SANDBOX_NAME, + "exec", + "--", + "bash", + "-c", + 'openclaw doctor --fix; rc=$?; printf "doctor_exit:%s\\n" "$rc"; stat -c "doctor_file_mode:%a" /sandbox/.openclaw/openclaw.json; stat -c "doctor_dir_mode:%a" /sandbox/.openclaw', + ], + { + artifactName: "phase-2b-documented-exec-doctor-fix", + timeoutMs: 5 * 60_000, + }, + ); + expect(doctor.exitCode, resultText(doctor)).toBe(0); + expect(resultText(doctor)).toMatch(/doctor_exit:\d+/); + expect(resultText(doctor)).toContain("doctor_file_mode:600"); + expect(resultText(doctor)).toContain("doctor_dir_mode:700"); - const writeUp = await sandboxShell( - sandbox, - `echo 'TAMPERED' >> ${CONFIG_PATH} 2>&1 && echo WRITABLE || echo BLOCKED`, - { artifactName: "phase-3-config-write-blocked" }, - ); - expect(resultText(writeUp)).toMatch( - /BLOCKED|Permission denied|Read-only|Operation not permitted/, - ); + const configAfterDoctor = await statPath( + sandbox, + CONFIG_PATH, + "phase-2b-config-perms-after-doctor", + ); + expect(configAfterDoctor).toMatchObject({ mode: "660", owner: "sandbox:sandbox" }); + const dirAfterDoctor = await statPath( + sandbox, + CONFIG_DIR, + "phase-2b-config-dir-perms-after-doctor", + ); + expect(dirAfterDoctor).toMatchObject({ mode: "2770", owner: "sandbox:sandbox" }); - const workspaceUp = await sandboxShell( - sandbox, - "touch /sandbox/.openclaw/workspace/.shields-up-probe 2>&1 && echo WRITABLE || echo BLOCKED", - { artifactName: "phase-3-workspace-write-blocked" }, - ); - expect(resultText(workspaceUp)).toMatch( - /BLOCKED|Permission denied|Read-only|Operation not permitted/, - ); + const containerId = await findSandboxContainer(host); + const gatewayWrite = await docker( + host, + ["exec", "-u", "gateway", containerId, "sh", "-c", `printf ' ' >>${CONFIG_PATH}`], + { + artifactName: "phase-2b-gateway-config-append-after-doctor", + timeoutMs: 30_000, + }, + ); + expect(gatewayWrite.exitCode, resultText(gatewayWrite)).toBe(0); + const refreshHash = await sandboxShell( + sandbox, + `cd ${CONFIG_DIR} && sha256sum openclaw.json >.config-hash`, + { artifactName: "phase-2b-refresh-hash-after-gateway-write" }, + ); + expect(refreshHash.exitCode, resultText(refreshHash)).toBe(0); - const configGet = await runNemoclaw(host, [SANDBOX_NAME, "config", "get"], { - artifactName: "phase-4-config-get", - redactionValues: [apiKey], - }); - expect(configGet.exitCode, resultText(configGet)).toBe(0); - expect(configGet.stdout).toContain("{"); - expect(configGet.stdout).not.toMatch(/nvapi-|sk-|Bearer /); - expect(configGet.stdout).not.toContain('"gateway"'); - - const dotpath = await runNemoclaw(host, [SANDBOX_NAME, "config", "get", "--key", "inference"], { - artifactName: "phase-4-config-get-dotpath", - redactionValues: [apiKey], - }); - if (dotpath.exitCode === 0 && dotpath.stdout.trim() !== "" && dotpath.stdout.trim() !== "null") { - expect(dotpath.stdout).not.toMatch(/nvapi-|sk-|Bearer /); - } else { - await artifacts.writeJson("phase-4-dotpath-non-fatal.json", { - exitCode: dotpath.exitCode, - stdout: dotpath.stdout.trim(), - stderr: dotpath.stderr.trim(), - note: "config get --key inference is non-fatal because the inference key may not exist", + const statusDefault = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-2-shields-status-default", }); - } + expect(statusDefault.exitCode, resultText(statusDefault)).toBe(0); + expect(statusDefault.stdout).toContain("Shields: NOT CONFIGURED"); - const statusUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-5-shields-status-up", - }); - expect(statusUp.exitCode, resultText(statusUp)).toBe(0); - expect(statusUp.stdout).toContain("Shields: UP"); - - progress.phase("restart OpenClaw with shields up"); - await expectStopStartRecovery(host, sandbox, "UP", "phase-5a-shields-up-start-recovery", [ - apiKey, - ]); - await expectLockedSandboxParent(host, "phase-5a-shields-up-start-recovery"); - const configAfterLockedRestart = await statPath( - sandbox, - CONFIG_PATH, - "phase-5a-config-after-shields-up-start-recovery", - ); - expect(configAfterLockedRestart.mode).toMatch(/^4[0-4][0-4]$/); - expect(configAfterLockedRestart.owner).toBe("root:root"); - await expectCredentialsTraversalBoundary(host, sandbox, containerId); + const freshMutableDown = await runNemoclaw( + host, + [SANDBOX_NAME, "shields", "down", "--timeout", "5m", "--reason", "Fresh mutable-default E2E"], + { artifactName: "phase-2c-fresh-mutable-shields-down" }, + ); + expect(freshMutableDown.exitCode, resultText(freshMutableDown)).toBe(0); + expect(resultText(freshMutableDown)).toContain("Config unlocked"); + expect(await statPath(sandbox, CONFIG_PATH, "phase-2c-config-perms-after-down")).toMatchObject({ + mode: "660", + owner: "sandbox:sandbox", + }); + expect( + await statPath(sandbox, CONFIG_DIR, "phase-2c-config-dir-perms-after-down"), + ).toMatchObject({ + mode: "2770", + owner: "sandbox:sandbox", + }); - progress.phase("detect host-root config drift and refuse resealing"); - const originalConfig = path.join(os.tmpdir(), `nemoclaw-shields-orig-${process.pid}.json`); - await readOriginalConfig(host, containerId, originalConfig); - try { - const tamper = await host.command( - "bash", + const interactiveHostPolicyEdit = await sandbox.openshell( [ - "-lc", - [ - `had_immutable=false`, - `if docker exec -u 0 ${containerId} lsattr -d ${CONFIG_PATH} 2>/dev/null | awk '{print $1}' | grep -q i; then had_immutable=true; fi`, - `docker exec -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_PATH} 2>/dev/null || true; chmod 644 ${CONFIG_PATH} && printf " " >> ${CONFIG_PATH} && chmod 444 ${CONFIG_PATH}'`, - `if [ "$had_immutable" = true ]; then docker exec -u 0 ${containerId} chattr +i ${CONFIG_PATH} >/dev/null 2>&1 || true; fi`, - ].join("\n"), + "policy", + "update", + SANDBOX_NAME, + "--add-endpoint", + "interactive-host-edit.example.com:443:read-only:rest:enforce", + "--rule-name", + "interactive_host_edit_e2e", + "--binary", + "/usr/bin/curl", + "--wait", ], { - artifactName: "phase-5b-host-root-tamper", + artifactName: "phase-2c-interactive-host-policy-edit", env: commandEnv(), - timeoutMs: 30_000, + timeoutMs: COMMAND_TIMEOUT_MS, }, ); - expect(tamper.exitCode, resultText(tamper)).toBe(0); + expect(interactiveHostPolicyEdit.exitCode, resultText(interactiveHostPolicyEdit)).toBe(0); - const afterTamper = await docker( - host, - ["exec", containerId, "stat", "-c", "%a %U:%G", CONFIG_PATH], + const layoutProbe = await sandboxShell( + sandbox, + [ + `bad=0`, + `if [ -e /sandbox/.openclaw-data ] || [ -L /sandbox/.openclaw-data ]; then echo "legacy data dir exists: /sandbox/.openclaw-data"; bad=1; fi`, + `for entry in /sandbox/.openclaw/*; do [ -L "$entry" ] || continue; target="$(readlink -f "$entry" 2>/dev/null || readlink "$entry" 2>/dev/null || true)"; case "$target" in /sandbox/.openclaw-data/*) echo "legacy symlink remains: $entry -> $target"; bad=1 ;; esac; done`, + `exit "$bad"`, + ].join("; "), + { artifactName: "phase-2-unified-openclaw-layout" }, + ); + expect(layoutProbe.exitCode, resultText(layoutProbe)).toBe(0); + expect(resultText(layoutProbe).trim()).toBe(""); + + progress.phase("lock config and workspace and inspect redaction"); + const shieldsUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-3-shields-up", + }); + expect(shieldsUp.exitCode, resultText(shieldsUp)).toBe(0); + expect(resultText(shieldsUp)).toContain("Lockdown active"); + const policyAfterInteractiveRestore = await sandbox.openshell( + ["policy", "get", "--full", SANDBOX_NAME], { - artifactName: "phase-5b-perms-after-tamper", - timeoutMs: 30_000, + artifactName: "phase-3-policy-after-interactive-restore", + env: commandEnv(), + timeoutMs: COMMAND_TIMEOUT_MS, }, ); - expect(afterTamper.exitCode, resultText(afterTamper)).toBe(0); - expect(afterTamper.stdout.trim()).toBe("444 root:root"); + expect(policyAfterInteractiveRestore.exitCode, resultText(policyAfterInteractiveRestore)).toBe( + 0, + ); + expect(policyAfterInteractiveRestore.stdout).toContain("interactive_host_edit_e2e"); + // Keep fixture teardown out of an artificial mutable window if a later + // assertion aborts before the explicit final restore below. + cleanup.trackDisposable(`restore shields for ${SANDBOX_NAME} before destroy`, async () => { + const restore = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "cleanup-shields-up-before-destroy", + }); + expect(restore.exitCode, resultText(restore)).toBe(0); + expect(resultText(restore)).toMatch(/Lockdown (?:is already )?active/); + }); + + const configUp = await statPath(sandbox, CONFIG_PATH, "phase-3-config-perms-up"); + expect(configUp.mode).toMatch(/^4[0-4][0-4]$/); + expect(configUp.owner).toBe("root:root"); - const statusTamper = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-5b-shields-status-drifted", + const writeUp = await sandboxShell( + sandbox, + `echo 'TAMPERED' >> ${CONFIG_PATH} 2>&1 && echo WRITABLE || echo BLOCKED`, + { artifactName: "phase-3-config-write-blocked" }, + ); + expect(resultText(writeUp)).toMatch( + /BLOCKED|Permission denied|Read-only|Operation not permitted/, + ); + + const workspaceUp = await sandboxShell( + sandbox, + "touch /sandbox/.openclaw/workspace/.shields-up-probe 2>&1 && echo WRITABLE || echo BLOCKED", + { artifactName: "phase-3-workspace-write-blocked" }, + ); + expect(resultText(workspaceUp)).toMatch( + /BLOCKED|Permission denied|Read-only|Operation not permitted/, + ); + + const configGet = await runNemoclaw(host, [SANDBOX_NAME, "config", "get"], { + artifactName: "phase-4-config-get", + redactionValues: [apiKey], }); - expect(statusTamper.exitCode, resultText(statusTamper)).toBe(2); - expect(resultText(statusTamper)).toContain("UP (DRIFTED"); - expect(resultText(statusTamper)).toContain("content drifted"); + expect(configGet.exitCode, resultText(configGet)).toBe(0); + expect(configGet.stdout).toContain("{"); + expect(configGet.stdout).not.toMatch(/nvapi-|sk-|Bearer /); + expect(configGet.stdout).not.toContain('"gateway"'); - const reUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-5b-shields-up-refuses-tamper", + const dotpath = await runNemoclaw(host, [SANDBOX_NAME, "config", "get", "--key", "inference"], { + artifactName: "phase-4-config-get-dotpath", + redactionValues: [apiKey], }); - expect(reUp.exitCode, resultText(reUp)).not.toBe(0); - expect(resultText(reUp)).toContain("Refusing to re-seal"); - } finally { - await host.command( + if ( + dotpath.exitCode === 0 && + dotpath.stdout.trim() !== "" && + dotpath.stdout.trim() !== "null" + ) { + expect(dotpath.stdout).not.toMatch(/nvapi-|sk-|Bearer /); + } else { + await artifacts.writeJson("phase-4-dotpath-non-fatal.json", { + exitCode: dotpath.exitCode, + stdout: dotpath.stdout.trim(), + stderr: dotpath.stderr.trim(), + note: "config get --key inference is non-fatal because the inference key may not exist", + }); + } + + const statusUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-5-shields-status-up", + }); + expect(statusUp.exitCode, resultText(statusUp)).toBe(0); + expect(statusUp.stdout).toContain("Shields: UP"); + + progress.phase("restart OpenClaw with shields up"); + await expectStopStartRecovery(host, sandbox, "UP", "phase-5a-shields-up-start-recovery", [ + apiKey, + ]); + await expectLockedSandboxParent(host, "phase-5a-shields-up-start-recovery"); + const configAfterLockedRestart = await statPath( + sandbox, + CONFIG_PATH, + "phase-5a-config-after-shields-up-start-recovery", + ); + expect(configAfterLockedRestart.mode).toMatch(/^4[0-4][0-4]$/); + expect(configAfterLockedRestart.owner).toBe("root:root"); + await expectCredentialsTraversalBoundary(host, sandbox, containerId); + + progress.phase("detect host-root config drift and refuse resealing"); + const originalConfig = path.join(os.tmpdir(), `nemoclaw-shields-orig-${process.pid}.json`); + await readOriginalConfig(host, containerId, originalConfig); + try { + const tamper = await host.command( + "bash", + [ + "-lc", + [ + `had_immutable=false`, + `if docker exec -u 0 ${containerId} lsattr -d ${CONFIG_PATH} 2>/dev/null | awk '{print $1}' | grep -q i; then had_immutable=true; fi`, + `docker exec -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_PATH} 2>/dev/null || true; chmod 644 ${CONFIG_PATH} && printf " " >> ${CONFIG_PATH} && chmod 444 ${CONFIG_PATH}'`, + `if [ "$had_immutable" = true ]; then docker exec -u 0 ${containerId} chattr +i ${CONFIG_PATH} >/dev/null 2>&1 || true; fi`, + ].join("\n"), + ], + { + artifactName: "phase-5b-host-root-tamper", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(tamper.exitCode, resultText(tamper)).toBe(0); + + const afterTamper = await docker( + host, + ["exec", containerId, "stat", "-c", "%a %U:%G", CONFIG_PATH], + { + artifactName: "phase-5b-perms-after-tamper", + timeoutMs: 30_000, + }, + ); + expect(afterTamper.exitCode, resultText(afterTamper)).toBe(0); + expect(afterTamper.stdout.trim()).toBe("444 root:root"); + + const statusTamper = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-5b-shields-status-drifted", + }); + expect(statusTamper.exitCode, resultText(statusTamper)).toBe(2); + expect(resultText(statusTamper)).toContain("UP (DRIFTED"); + expect(resultText(statusTamper)).toContain("content drifted"); + + const reUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-5b-shields-up-refuses-tamper", + }); + expect(reUp.exitCode, resultText(reUp)).not.toBe(0); + expect(resultText(reUp)).toContain("Refusing to re-seal"); + } finally { + await host.command( + "bash", + [ + "-lc", + `docker exec -i -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_PATH} 2>/dev/null || true; chmod 644 ${CONFIG_PATH} && cat > ${CONFIG_PATH} && chmod 444 ${CONFIG_PATH} && chattr +i ${CONFIG_PATH} 2>/dev/null || true' < ${originalConfig}`, + ], + { + artifactName: "phase-5b-restore-original-config", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + fs.rmSync(originalConfig, { force: true }); + } + + const statusRestored = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-5b-shields-status-restored", + }); + expect(statusRestored.exitCode, resultText(statusRestored)).toBe(0); + expect(statusRestored.stdout).toContain("Shields: UP (lockdown active)"); + + progress.phase("re-seal a perms-only .config-hash drift instead of failing closed"); + // #7985/#4663: an in-sandbox privileged reconciler (OpenClaw gateway / doctor + // perm-normalization) can re-permission .config-hash back to group-writable + // AFTER the lock without touching its bytes. That perms-only drift must be + // re-sealed by the drift-repair `shields up`, not fail closed with + // "restart seal requires the exact shields-locked file posture" (which + // stranded host state UNLOCKED while the tree stayed root-locked). The bytes + // are untouched here, so it is a launderable perms drift, not content drift. + const permsDrift = await host.command( "bash", [ "-lc", - `docker exec -i -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_PATH} 2>/dev/null || true; chmod 644 ${CONFIG_PATH} && cat > ${CONFIG_PATH} && chmod 444 ${CONFIG_PATH} && chattr +i ${CONFIG_PATH} 2>/dev/null || true' < ${originalConfig}`, + `docker exec -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_HASH_PATH} 2>/dev/null || true; chmod 660 ${CONFIG_HASH_PATH} && chown sandbox:sandbox ${CONFIG_HASH_PATH}'`, ], { - artifactName: "phase-5b-restore-original-config", + artifactName: "phase-5c-config-hash-perms-only-drift", env: commandEnv(), timeoutMs: 30_000, }, ); - fs.rmSync(originalConfig, { force: true }); - } + expect(permsDrift.exitCode, resultText(permsDrift)).toBe(0); - const statusRestored = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-5b-shields-status-restored", - }); - expect(statusRestored.exitCode, resultText(statusRestored)).toBe(0); - expect(statusRestored.stdout).toContain("Shields: UP (lockdown active)"); - - progress.phase("re-seal a perms-only .config-hash drift instead of failing closed"); - // #7985/#4663: an in-sandbox privileged reconciler (OpenClaw gateway / doctor - // perm-normalization) can re-permission .config-hash back to group-writable - // AFTER the lock without touching its bytes. That perms-only drift must be - // re-sealed by the drift-repair `shields up`, not fail closed with - // "restart seal requires the exact shields-locked file posture" (which - // stranded host state UNLOCKED while the tree stayed root-locked). The bytes - // are untouched here, so it is a launderable perms drift, not content drift. - const permsDrift = await host.command( - "bash", - [ - "-lc", - `docker exec -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_HASH_PATH} 2>/dev/null || true; chmod 660 ${CONFIG_HASH_PATH} && chown sandbox:sandbox ${CONFIG_HASH_PATH}'`, - ], - { - artifactName: "phase-5c-config-hash-perms-only-drift", - env: commandEnv(), - timeoutMs: 30_000, - }, - ); - expect(permsDrift.exitCode, resultText(permsDrift)).toBe(0); - - const hashDrifted = await statPath(sandbox, CONFIG_HASH_PATH, "phase-5c-hash-perms-after-drift"); - expect(hashDrifted).toMatchObject({ mode: "660", owner: "sandbox:sandbox" }); + const hashDrifted = await statPath( + sandbox, + CONFIG_HASH_PATH, + "phase-5c-hash-perms-after-drift", + ); + expect(hashDrifted).toMatchObject({ mode: "660", owner: "sandbox:sandbox" }); - // The drift-repair relock reaches the OpenClaw config guard's already-locked - // branch. The fix re-seals the perms-only drift; before it, the guard - // rejected with config-not-locked and the relock could never re-apply. - const reseal = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-5c-shields-up-reseals-perms-drift", - }); - expect(reseal.exitCode, resultText(reseal)).toBe(0); - // The guard surfaces the self-heal so a relock/rebuild does not fix a - // perms-only drift invisibly (#4663 / #7985 observability). - expect(resultText(reseal)).toContain("Re-sealed a perms-only config-lock drift"); + // The drift-repair relock reaches the OpenClaw config guard's already-locked + // branch. The fix re-seals the perms-only drift; before it, the guard + // rejected with config-not-locked and the relock could never re-apply. + const reseal = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-5c-shields-up-reseals-perms-drift", + }); + expect(reseal.exitCode, resultText(reseal)).toBe(0); + // The guard surfaces the self-heal so a relock/rebuild does not fix a + // perms-only drift invisibly (#4663 / #7985 observability). + expect(resultText(reseal)).toContain("Re-sealed a perms-only config-lock drift"); - const hashResealed = await statPath( - sandbox, - CONFIG_HASH_PATH, - "phase-5c-hash-perms-after-reseal", - ); - expect(hashResealed).toMatchObject({ mode: "444", owner: "root:root" }); + const hashResealed = await statPath( + sandbox, + CONFIG_HASH_PATH, + "phase-5c-hash-perms-after-reseal", + ); + expect(hashResealed).toMatchObject({ mode: "444", owner: "root:root" }); - const statusResealed = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-5c-shields-status-after-reseal", - }); - expect(statusResealed.exitCode, resultText(statusResealed)).toBe(0); - expect(statusResealed.stdout).toContain("Shields: UP (lockdown active)"); + const statusResealed = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-5c-shields-status-after-reseal", + }); + expect(statusResealed.exitCode, resultText(statusResealed)).toBe(0); + expect(statusResealed.stdout).toContain("Shields: UP (lockdown active)"); - progress.phase("unlock shields and inspect the audit trail"); - const shieldsDown = await runNemoclaw( - host, - [SANDBOX_NAME, "shields", "down", "--timeout", "15m", "--reason", "E2E shields lifecycle test"], - { artifactName: "phase-6-shields-down" }, - ); - expect(shieldsDown.exitCode, resultText(shieldsDown)).toBe(0); - expect(resultText(shieldsDown)).toContain("Config unlocked"); - - const configDown = await statPath(sandbox, CONFIG_PATH, "phase-6-config-perms-down"); - expect(configDown.mode).toBe("660"); - expect(configDown.owner).toBe("sandbox:sandbox"); - const dirDown = await statPath(sandbox, CONFIG_DIR, "phase-6-config-dir-perms-down"); - expect(dirDown.mode).toBe("2770"); - expect(dirDown.owner).toBe("sandbox:sandbox"); - const workspaceDown = await sandboxShell( - sandbox, - "touch /sandbox/.openclaw/workspace/.shields-down-probe 2>&1 && rm -f /sandbox/.openclaw/workspace/.shields-down-probe && echo WRITABLE || echo BLOCKED", - { artifactName: "phase-6-workspace-write-restored" }, - ); - expect(resultText(workspaceDown)).toContain("WRITABLE"); + progress.phase("unlock shields and inspect the audit trail"); + const shieldsDown = await runNemoclaw( + host, + [ + SANDBOX_NAME, + "shields", + "down", + "--timeout", + "15m", + "--reason", + "E2E shields lifecycle test", + ], + { artifactName: "phase-6-shields-down" }, + ); + expect(shieldsDown.exitCode, resultText(shieldsDown)).toBe(0); + expect(resultText(shieldsDown)).toContain("Config unlocked"); + + const configDown = await statPath(sandbox, CONFIG_PATH, "phase-6-config-perms-down"); + expect(configDown.mode).toBe("660"); + expect(configDown.owner).toBe("sandbox:sandbox"); + const dirDown = await statPath(sandbox, CONFIG_DIR, "phase-6-config-dir-perms-down"); + expect(dirDown.mode).toBe("2770"); + expect(dirDown.owner).toBe("sandbox:sandbox"); + const workspaceDown = await sandboxShell( + sandbox, + "touch /sandbox/.openclaw/workspace/.shields-down-probe 2>&1 && rm -f /sandbox/.openclaw/workspace/.shields-down-probe && echo WRITABLE || echo BLOCKED", + { artifactName: "phase-6-workspace-write-restored" }, + ); + expect(resultText(workspaceDown)).toContain("WRITABLE"); - const statusDown = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-7-shields-status-down", - }); - expect(statusDown.exitCode, resultText(statusDown)).toBe(0); - expect(statusDown.stdout).toContain("Shields: DOWN"); - expect(statusDown.stdout).toContain("E2E shields lifecycle test"); - expect(statusDown.stdout).toMatch(/Auto-lockdown in:|remaining/i); - - progress.phase("restart OpenClaw with shields down"); - await expectStopStartRecovery(host, sandbox, "DOWN", "phase-7a-shields-down-start-recovery", [ - apiKey, - ]); - const configAfterMutableRestart = await statPath( - sandbox, - CONFIG_PATH, - "phase-7a-config-after-shields-down-start-recovery", - ); - expect(configAfterMutableRestart).toMatchObject({ - mode: "660", - owner: "sandbox:sandbox", - }); + const statusDown = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-7-shields-status-down", + }); + expect(statusDown.exitCode, resultText(statusDown)).toBe(0); + expect(statusDown.stdout).toContain("Shields: DOWN"); + expect(statusDown.stdout).toContain("E2E shields lifecycle test"); + expect(statusDown.stdout).toMatch(/Auto-lockdown in:|remaining/i); + + progress.phase("restart OpenClaw with shields down"); + await expectStopStartRecovery(host, sandbox, "DOWN", "phase-7a-shields-down-start-recovery", [ + apiKey, + ]); + const configAfterMutableRestart = await statPath( + sandbox, + CONFIG_PATH, + "phase-7a-config-after-shields-down-start-recovery", + ); + expect(configAfterMutableRestart).toMatchObject({ + mode: "660", + owner: "sandbox:sandbox", + }); - const restoreUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-7-restore-shields-up", - }); - expect(restoreUp.exitCode, resultText(restoreUp)).toBe(0); - - expect(fs.existsSync(AUDIT_FILE), `${AUDIT_FILE} should exist`).toBe(true); - const auditText = fs.readFileSync(AUDIT_FILE, "utf8"); - const auditEntries = readAuditEntries(); - const upCount = auditText.split('"shields_up"').length - 1; - const downCount = auditText.split('"shields_down"').length - 1; - expect(upCount).toBeGreaterThanOrEqual(2); - expect(downCount).toBeGreaterThanOrEqual(1); - expect(auditText).not.toMatch(/nvapi-|sk-|Bearer /); - await artifacts.writeJson("phase-8-audit-summary.json", { - entries: auditEntries.length, - upCount, - downCount, - }); + const restoreUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-7-restore-shields-up", + }); + expect(restoreUp.exitCode, resultText(restoreUp)).toBe(0); + + expect(fs.existsSync(AUDIT_FILE), `${AUDIT_FILE} should exist`).toBe(true); + const auditText = fs.readFileSync(AUDIT_FILE, "utf8"); + const auditEntries = readAuditEntries(); + const upCount = auditText.split('"shields_up"').length - 1; + const downCount = auditText.split('"shields_down"').length - 1; + expect(upCount).toBeGreaterThanOrEqual(2); + expect(downCount).toBeGreaterThanOrEqual(1); + expect(auditText).not.toMatch(/nvapi-|sk-|Bearer /); + await artifacts.writeJson("phase-8-audit-summary.json", { + entries: auditEntries.length, + upCount, + downCount, + }); - progress.phase("recover shields after a dead restore timer"); - const timerDown = await runNemoclaw( - host, - [SANDBOX_NAME, "shields", "down", "--timeout", "60s", "--reason", "Auto-restore timer E2E"], - { artifactName: "phase-9-shields-down-timer" }, - ); - expect(timerDown.exitCode, resultText(timerDown)).toBe(0); - const timerMarker = readTimerMarker(SANDBOX_NAME); - process.kill(timerMarker.pid, "SIGKILL"); - const statusTimer = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: "phase-9-status-after-dead-timer", - }); - expect(statusTimer.exitCode, resultText(statusTimer)).toBe(0); - - let lastTimerStatus = resultText(statusTimer); - expect(statusTimer.stdout).toMatch(/Shields: (?:UP|DOWN)/); - let restored = statusTimer.stdout.includes("Shields: UP"); - - const deadline = Date.now() + TIMER_POLL_TIMEOUT_MS; - for (let attempt = 1; !restored && Date.now() < deadline; attempt += 1) { - const waitForRestoreAt = Math.max(0, new Date(timerMarker.restoreAt).getTime() - Date.now()); - await delay(Math.max(TIMER_POLL_INTERVAL_MS, waitForRestoreAt + 1_000)); - const poll = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { - artifactName: `phase-9-status-dead-timer-inline-restore-poll-${attempt}`, + progress.phase("recover shields after a dead restore timer"); + const timerDown = await runNemoclaw( + host, + [SANDBOX_NAME, "shields", "down", "--timeout", "60s", "--reason", "Auto-restore timer E2E"], + { artifactName: "phase-9-shields-down-timer" }, + ); + expect(timerDown.exitCode, resultText(timerDown)).toBe(0); + const timerHostPolicyEdit = await sandbox.openshell( + [ + "policy", + "update", + SANDBOX_NAME, + "--add-endpoint", + "timer-host-edit.example.com:443:read-only:rest:enforce", + "--rule-name", + "timer_host_edit_e2e", + "--binary", + "/usr/bin/curl", + "--wait", + ], + { + artifactName: "phase-9-timer-host-policy-edit", + env: commandEnv(), + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(timerHostPolicyEdit.exitCode, resultText(timerHostPolicyEdit)).toBe(0); + const timerMarker = readTimerMarker(SANDBOX_NAME); + process.kill(timerMarker.pid, "SIGKILL"); + const statusTimer = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: "phase-9-status-after-dead-timer", }); - lastTimerStatus = resultText(poll); - if (lastTimerStatus.includes("Shields: UP")) { - restored = true; - break; + expect(statusTimer.exitCode, resultText(statusTimer)).toBe(0); + + let lastTimerStatus = resultText(statusTimer); + expect(statusTimer.stdout).toMatch(/Shields: (?:UP|DOWN)/); + let restored = statusTimer.stdout.includes("Shields: UP"); + + const deadline = Date.now() + TIMER_POLL_TIMEOUT_MS; + for (let attempt = 1; !restored && Date.now() < deadline; attempt += 1) { + const waitForRestoreAt = Math.max(0, new Date(timerMarker.restoreAt).getTime() - Date.now()); + await delay(Math.max(TIMER_POLL_INTERVAL_MS, waitForRestoreAt + 1_000)); + const poll = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], { + artifactName: `phase-9-status-dead-timer-inline-restore-poll-${attempt}`, + }); + lastTimerStatus = resultText(poll); + if (lastTimerStatus.includes("Shields: UP")) { + restored = true; + break; + } } - } - expect(restored, lastTimerStatus).toBe(true); - const dirTimer = await statPath( - sandbox, - CONFIG_DIR, - "phase-9-config-dir-perms-after-dead-timer-inline-restore", - ); - expect(dirTimer).toMatchObject({ mode: "755", owner: "root:root" }); - const configTimer = await statPath( - sandbox, - CONFIG_PATH, - "phase-9-config-perms-after-dead-timer-inline-restore", - ); - expect(configTimer).toMatchObject({ mode: "444", owner: "root:root" }); - const hashTimer = await statPath( - sandbox, - CONFIG_HASH_PATH, - "phase-9-config-hash-perms-after-dead-timer-inline-restore", - ); - expect(hashTimer).toMatchObject({ mode: "444", owner: "root:root" }); - const stateAfterTimer = JSON.parse(fs.readFileSync(STATE_FILE(SANDBOX_NAME), "utf8")); - expect(stateAfterTimer.fileHashes).toMatchObject({ - [CONFIG_PATH]: expect.any(String), - [CONFIG_HASH_PATH]: expect.any(String), - }); - expect(readAuditEntries()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - action: "shields_auto_restore", - policy_snapshot: timerMarker.snapshotPath, - }), - ]), - ); + expect(restored, lastTimerStatus).toBe(true); + const dirTimer = await statPath( + sandbox, + CONFIG_DIR, + "phase-9-config-dir-perms-after-dead-timer-inline-restore", + ); + expect(dirTimer).toMatchObject({ mode: "755", owner: "root:root" }); + const configTimer = await statPath( + sandbox, + CONFIG_PATH, + "phase-9-config-perms-after-dead-timer-inline-restore", + ); + expect(configTimer).toMatchObject({ mode: "444", owner: "root:root" }); + const hashTimer = await statPath( + sandbox, + CONFIG_HASH_PATH, + "phase-9-config-hash-perms-after-dead-timer-inline-restore", + ); + expect(hashTimer).toMatchObject({ mode: "444", owner: "root:root" }); + const stateAfterTimer = JSON.parse(fs.readFileSync(STATE_FILE(SANDBOX_NAME), "utf8")); + expect(stateAfterTimer.fileHashes).toMatchObject({ + [CONFIG_PATH]: expect.any(String), + [CONFIG_HASH_PATH]: expect.any(String), + }); + const policyAfterTimerRestore = await sandbox.openshell( + ["policy", "get", "--full", SANDBOX_NAME], + { + artifactName: "phase-9-policy-after-timer-restore", + env: commandEnv(), + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(policyAfterTimerRestore.exitCode, resultText(policyAfterTimerRestore)).toBe(0); + expect(policyAfterTimerRestore.stdout).toContain("interactive_host_edit_e2e"); + expect(policyAfterTimerRestore.stdout).toContain("timer_host_edit_e2e"); + expect(readAuditEntries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "shields_auto_restore", + policy_snapshot: timerMarker.snapshotPath, + }), + ]), + ); - progress.phase("reject duplicate shields transitions"); - const doubleUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-10-double-shields-up", - }); - expect(doubleUp.exitCode, resultText(doubleUp)).toBe(0); - expect(resultText(doubleUp)).toContain("already active"); + progress.phase("reject duplicate shields transitions"); + const doubleUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-10-double-shields-up", + }); + expect(doubleUp.exitCode, resultText(doubleUp)).toBe(0); + expect(resultText(doubleUp)).toContain("already active"); - const cleanupDown = await runNemoclaw( - host, - [SANDBOX_NAME, "shields", "down", "--timeout", "5m", "--reason", "Cleanup"], - { artifactName: "phase-10-cleanup-shields-down" }, - ); - expect(cleanupDown.exitCode, resultText(cleanupDown)).toBe(0); + const cleanupDown = await runNemoclaw( + host, + [SANDBOX_NAME, "shields", "down", "--timeout", "5m", "--reason", "Cleanup"], + { artifactName: "phase-10-cleanup-shields-down" }, + ); + expect(cleanupDown.exitCode, resultText(cleanupDown)).toBe(0); - const doubleDown = await runNemoclaw( - host, - [SANDBOX_NAME, "shields", "down", "--timeout", "5m", "--reason", "Should fail"], - { artifactName: "phase-11-double-shields-down" }, - ); - expect(doubleDown.exitCode, resultText(doubleDown)).not.toBe(0); - expect(resultText(doubleDown)).toContain("already unlocked"); + const doubleDown = await runNemoclaw( + host, + [SANDBOX_NAME, "shields", "down", "--timeout", "5m", "--reason", "Should fail"], + { artifactName: "phase-11-double-shields-down" }, + ); + expect(doubleDown.exitCode, resultText(doubleDown)).not.toBe(0); + expect(resultText(doubleDown)).toContain("already unlocked"); - // The duplicate-down assertion deliberately leaves its first timer active; - // restore the target's normal locked posture before generic destruction. - const finalUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-11-restore-shields-up", - }); - expect(finalUp.exitCode, resultText(finalUp)).toBe(0); - expect(resultText(finalUp)).toContain("Lockdown active"); + // The duplicate-down assertion deliberately leaves its first timer active; + // restore the target's normal locked posture before generic destruction. + const finalUp = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-11-restore-shields-up", + }); + expect(finalUp.exitCode, resultText(finalUp)).toBe(0); + expect(resultText(finalUp)).toContain("Lockdown active"); - progress.phase( - "prove installed failed-startup guard refuses a live child and supported shields down unlocks childless state", - ); - const recoveryContainerId = await findSandboxContainer(host); - const removeMarkers = await docker( - host, - ["exec", "--user", "0", recoveryContainerId, "rm", "-f", ...STARTUP_MARKER_PATHS], - { artifactName: "phase-12-remove-startup-markers", timeoutMs: 30_000 }, - ); - expect(removeMarkers.exitCode, resultText(removeMarkers)).toBe(0); + progress.phase( + "prove installed failed-startup guard refuses a live child and supported shields down unlocks childless state", + ); + const recoveryContainerId = await findSandboxContainer(host); + const removeMarkers = await docker( + host, + ["exec", "--user", "0", recoveryContainerId, "rm", "-f", ...STARTUP_MARKER_PATHS], + { artifactName: "phase-12-remove-startup-markers", timeoutMs: 30_000 }, + ); + expect(removeMarkers.exitCode, resultText(removeMarkers)).toBe(0); - const liveCensus = await installedStartupCensus( - host, - recoveryContainerId, - "phase-12-live-startup-census", - ); - expect(liveCensus).toMatchObject({ count: 1, pid: expect.any(Number) }); - expect(liveCensus.pid).not.toBeNull(); - // OpenShell's supervised non-root compatibility path legitimately permits - // ordinary Shields changes while its startup child is healthy. Exercise the - // failed-startup admission boundary through the installed guard, then prove - // the supported host command owns the terminal childless recovery below. - const liveChildRefusal = await runInstalledFailedStartupUnlock( - host, - recoveryContainerId, - "phase-12-live-child-refusal", - ); - expect(liveChildRefusal.exitCode, resultText(liveChildRefusal)).not.toBe(0); - expect(resultText(liveChildRefusal)).toContain('"code": "startup-not-ready"'); - expect(await statPath(sandbox, CONFIG_PATH, "phase-12-config-still-locked")).toMatchObject({ - mode: "444", - owner: "root:root", - }); + const liveCensus = await installedStartupCensus( + host, + recoveryContainerId, + "phase-12-live-startup-census", + ); + expect(liveCensus).toMatchObject({ count: 1, pid: expect.any(Number) }); + expect(liveCensus.pid).not.toBeNull(); + // OpenShell's supervised non-root compatibility path legitimately permits + // ordinary Shields changes while its startup child is healthy. Exercise the + // failed-startup admission boundary through the installed guard, then prove + // the supported host command owns the terminal childless recovery below. + const liveChildRefusal = await runInstalledFailedStartupUnlock( + host, + recoveryContainerId, + "phase-12-live-child-refusal", + ); + expect(liveChildRefusal.exitCode, resultText(liveChildRefusal)).not.toBe(0); + expect(resultText(liveChildRefusal)).toContain('"code": "startup-not-ready"'); + expect(await statPath(sandbox, CONFIG_PATH, "phase-12-config-still-locked")).toMatchObject({ + mode: "444", + owner: "root:root", + }); - // A running OpenShell PID 1 can restart its child while the recovery guard - // scans procfs, but pausing PID 1 before `policy set --wait` also prevents - // OpenShell from acknowledging the policy version. A one-shot executable - // shim delegates the real policy update first, then pauses the supervisor - // and removes the exact live child before the public command reaches its - // guarded config transition. - let supervisorPaused = false; - const processControl = failedStartupProcessControlCommands( - recoveryContainerId, - liveCensus.pid ?? 0, - ); - cleanup.trackDisposable(`resume stopped supervisor for ${SANDBOX_NAME}`, async () => { - await resumeSupervisorIfPaused(supervisorPaused, async () => { - const resume = await docker(host, processControl.resumeSupervisor, { - artifactName: "cleanup-phase-12-resume-startup-supervisor", - timeoutMs: 30_000, + // A running OpenShell PID 1 can restart its child while the recovery guard + // scans procfs, but pausing PID 1 before `policy set --wait` also prevents + // OpenShell from acknowledging the policy version. A one-shot executable + // shim delegates the real policy update first, then pauses the supervisor + // and removes the exact live child before the public command reaches its + // guarded config transition. + let supervisorPaused = false; + const processControl = failedStartupProcessControlCommands( + recoveryContainerId, + liveCensus.pid ?? 0, + ); + cleanup.trackDisposable(`resume stopped supervisor for ${SANDBOX_NAME}`, async () => { + await resumeSupervisorIfPaused(supervisorPaused, async () => { + const resume = await docker(host, processControl.resumeSupervisor, { + artifactName: "cleanup-phase-12-resume-startup-supervisor", + timeoutMs: 30_000, + }); + expect(resume.exitCode, resultText(resume)).toBe(0); }); - expect(resume.exitCode, resultText(resume)).toBe(0); }); - }); - const openshellResolution = await host.command( - "bash", - ["-lc", 'command -v "$1"', "resolve-openshell", host.openshellCommandPath], - { - artifactName: "phase-12-resolve-openshell", - env: commandEnv(), - timeoutMs: 30_000, - }, - ); - expect(openshellResolution.exitCode, resultText(openshellResolution)).toBe(0); - const realOpenshellPath = openshellResolution.stdout.trim(); - expect(path.isAbsolute(realOpenshellPath), realOpenshellPath).toBe(true); - const policyBoundary = createPolicySetChildlessBoundaryShim( - realOpenshellPath, - recoveryContainerId, - liveCensus.pid ?? 0, - ); - cleanup.trackDisposable("remove phase-12 OpenShell boundary shim", () => { - fs.rmSync(policyBoundary.directory, { force: true, recursive: true }); - }); - - // The shim may have paused PID 1 even if a later assertion fails. Arm the - // cleanup before starting the public transition; SIGCONT is harmless if the - // policy command fails before the shim reaches the test boundary. - supervisorPaused = true; - const childlessRecovery = await runNemoclaw( - host, - [ - SANDBOX_NAME, - "shields", - "down", - "--timeout", - "5m", - "--reason", - "Supported failed-startup recovery E2E", - ], - { - artifactName: "phase-12-childless-shields-down", - env: commandEnv({ NEMOCLAW_OPENSHELL_BIN: policyBoundary.executable }), - timeoutMs: 16 * 60_000, - }, - ); - expect(childlessRecovery.exitCode, resultText(childlessRecovery)).toBe(0); - expect(resultText(childlessRecovery)).toContain( - "Lowered shields on a sandbox whose startup never completed.", - ); - expect(resultText(childlessRecovery)).toContain("Sandbox is in default (mutable) state."); - expect(JSON.parse(fs.readFileSync(policyBoundary.receipt, "utf8"))).toEqual({ - status: "childless", - }); - await waitForChildlessStartup(host, recoveryContainerId); - const unlockedPaths = await docker( - host, - [ - "exec", - "--user", - "0", + const openshellResolution = await host.command( + "bash", + ["-lc", 'command -v "$1"', "resolve-openshell", host.openshellCommandPath], + { + artifactName: "phase-12-resolve-openshell", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(openshellResolution.exitCode, resultText(openshellResolution)).toBe(0); + const realOpenshellPath = openshellResolution.stdout.trim(); + expect(path.isAbsolute(realOpenshellPath), realOpenshellPath).toBe(true); + const policyBoundary = createPolicySetChildlessBoundaryShim( + realOpenshellPath, recoveryContainerId, - "stat", - "-c", - "%a %U:%G", - CONFIG_PATH, - `${CONFIG_DIR}/workspace`, - ], - { artifactName: "phase-12-unlocked-paths", timeoutMs: 30_000 }, - ); - expect(unlockedPaths.exitCode, resultText(unlockedPaths)).toBe(0); - expect(unlockedPaths.stdout.trim().split(/\r?\n/).map(parseModeOwner)).toEqual([ - { mode: "660", owner: "sandbox:sandbox" }, - { mode: "2770", owner: "sandbox:sandbox" }, - ]); - - const resumeSupervisor = await docker(host, processControl.resumeSupervisor, { - artifactName: "phase-12-resume-startup-supervisor", - timeoutMs: 30_000, - }); - expect(resumeSupervisor.exitCode, resultText(resumeSupervisor)).toBe(0); - supervisorPaused = false; - - // The supported host command owns the policy receipt and config mutation as - // one transition. Resume only after it commits the mutable posture, then - // prove ordinary stop/start recovery remains available before relocking. - await expectStopStartRecovery(host, sandbox, "DOWN", "phase-12-restart-after-recovery", [apiKey]); - const relockAfterRecovery = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { - artifactName: "phase-12-relock-after-recovery", - }); - expect(relockAfterRecovery.exitCode, resultText(relockAfterRecovery)).toBe(0); - expect(resultText(relockAfterRecovery)).toContain("Lockdown active"); - - progress.phase("record shields contract evidence"); - await artifacts.target.complete({ - id: "shields-config", - sandboxName: SANDBOX_NAME, - assertions: { - install: true, - mutableDefault: true, - documentedExecDoctorPreservesGatewayWrites: true, - shieldsUpLock: true, - configGetRedaction: true, - contentDriftDetection: true, - permsOnlyDriftReseal: true, - shieldsDownMutableRestore: true, - auditTrail: true, - deadTimerInlineAutoRestore: true, - doubleOperationRejection: true, - installedFailedStartupLiveChildRefusal: true, - supportedShieldsDownChildlessRecovery: true, - inheritedMutationLockAcceptedByStateGuard: true, - }, - }); -}); + liveCensus.pid ?? 0, + ); + cleanup.trackDisposable("remove phase-12 OpenShell boundary shim", () => { + fs.rmSync(policyBoundary.directory, { force: true, recursive: true }); + }); + + // The shim may have paused PID 1 even if a later assertion fails. Arm the + // cleanup before starting the public transition; SIGCONT is harmless if the + // policy command fails before the shim reaches the test boundary. + supervisorPaused = true; + const childlessRecovery = await runNemoclaw( + host, + [ + SANDBOX_NAME, + "shields", + "down", + "--timeout", + "5m", + "--reason", + "Supported failed-startup recovery E2E", + ], + { + artifactName: "phase-12-childless-shields-down", + env: commandEnv({ NEMOCLAW_OPENSHELL_BIN: policyBoundary.executable }), + timeoutMs: 16 * 60_000, + }, + ); + expect(childlessRecovery.exitCode, resultText(childlessRecovery)).toBe(0); + expect(resultText(childlessRecovery)).toContain( + "Lowered shields on a sandbox whose startup never completed.", + ); + expect(resultText(childlessRecovery)).toContain("Sandbox is in default (mutable) state."); + expect(JSON.parse(fs.readFileSync(policyBoundary.receipt, "utf8"))).toEqual({ + status: "childless", + }); + await waitForChildlessStartup(host, recoveryContainerId); + const unlockedPaths = await docker( + host, + [ + "exec", + "--user", + "0", + recoveryContainerId, + "stat", + "-c", + "%a %U:%G", + CONFIG_PATH, + `${CONFIG_DIR}/workspace`, + ], + { artifactName: "phase-12-unlocked-paths", timeoutMs: 30_000 }, + ); + expect(unlockedPaths.exitCode, resultText(unlockedPaths)).toBe(0); + expect(unlockedPaths.stdout.trim().split(/\r?\n/).map(parseModeOwner)).toEqual([ + { mode: "660", owner: "sandbox:sandbox" }, + { mode: "2770", owner: "sandbox:sandbox" }, + ]); + + const resumeSupervisor = await docker(host, processControl.resumeSupervisor, { + artifactName: "phase-12-resume-startup-supervisor", + timeoutMs: 30_000, + }); + expect(resumeSupervisor.exitCode, resultText(resumeSupervisor)).toBe(0); + supervisorPaused = false; + + // The supported host command owns only this bounded Shields transition. + // Resume after it commits the mutable posture, then + // prove ordinary stop/start recovery remains available before relocking. + await expectStopStartRecovery(host, sandbox, "DOWN", "phase-12-restart-after-recovery", [ + apiKey, + ]); + const relockAfterRecovery = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], { + artifactName: "phase-12-relock-after-recovery", + }); + expect(relockAfterRecovery.exitCode, resultText(relockAfterRecovery)).toBe(0); + expect(resultText(relockAfterRecovery)).toContain("Lockdown active"); + + progress.phase("record shields contract evidence"); + await artifacts.target.complete({ + id: "shields-config", + sandboxName: SANDBOX_NAME, + assertions: { + install: true, + mutableDefault: true, + documentedExecDoctorPreservesGatewayWrites: true, + shieldsUpLock: true, + configGetRedaction: true, + contentDriftDetection: true, + permsOnlyDriftReseal: true, + shieldsDownMutableRestore: true, + auditTrail: true, + deadTimerInlineAutoRestore: true, + doubleOperationRejection: true, + installedFailedStartupLiveChildRefusal: true, + supportedShieldsDownChildlessRecovery: true, + inheritedMutationLockAcceptedByStateGuard: true, + }, + }); + }, +); diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index 2c3636cda68..685bd805979 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -260,31 +260,12 @@ async function rootSandboxPathMetadata( return { mode, owner }; } -async function expectBaselineExclusionAgreement( - host: HostCliClient, +async function expectLiveBaselineExcluded( + _host: HostCliClient, sandbox: SandboxClient, sandboxName: string, artifactPrefix: string, ): Promise { - const status = await host.command("nemoclaw", [sandboxName, "status", "--json"], { - artifactName: `${artifactPrefix}-nemoclaw-status-json`, - env: commandEnv(), - timeoutMs: 60_000, - }); - expect(status.exitCode, resultText(status)).toBe(0); - const statusJson = JSON.parse(status.stdout) as { baselineExclusions: string[] }; - expect(statusJson.baselineExclusions).toContain(BASELINE_EXCLUSION_KEY); - - const policyList = await host.command("nemoclaw", [sandboxName, "policy", "list"], { - artifactName: `${artifactPrefix}-nemoclaw-policy-list`, - env: commandEnv(), - timeoutMs: 60_000, - }); - expect(policyList.exitCode, resultText(policyList)).toBe(0); - expect(resultText(policyList)).toMatch( - new RegExp(`^[ \\t]+- ${BASELINE_EXCLUSION_KEY} \\(active\\)`, "m"), - ); - const livePolicy = await sandbox.openshell(["policy", "get", "--base", sandboxName], { artifactName: `${artifactPrefix}-openshell-policy-get-base`, env: commandEnv(), @@ -347,8 +328,8 @@ test( "snapshot create reports Snapshot v created", "snapshot list shows versioned snapshots and parseable timestamps", "snapshot restore recovers canonical OpenClaw USER.md and SOUL.md after destroy and fresh same-name onboarding", - "baseline exclusions remain active in registry and live policy across rebuild", - "legacy snapshot restore --to carries baseline exclusions into clone registry and live policy; managed snapshots refuse before destination effects until clone rebind is activated", + "a live baseline-key removal remains in the OpenShell policy across rebuild", + "legacy snapshot restore --to carries the source live OpenShell policy into the clone; managed snapshots refuse before destination effects until clone rebind is activated", "legacy snapshot restore --to returns only after restored gateway pairing is authenticated", "post-restore legacy clone verification sends one clone-fixture request, stores its unique session only in the clone, and sends no source-sandbox negative-control request", "latest snapshot restore recovers latest workspace state", @@ -489,7 +470,7 @@ test( }, ); expect(excludeBaseline.exitCode, resultText(excludeBaseline)).toBe(0); - await expectBaselineExclusionAgreement(host, sandbox, SANDBOX_NAME, "phase-2-after-exclude"); + await expectLiveBaselineExcluded(host, sandbox, SANDBOX_NAME, "phase-2-after-exclude"); const rebuild = await host.command("nemoclaw", [SANDBOX_NAME, "rebuild", "--yes"], { artifactName: "phase-2-rebuild-with-baseline-exclusion", @@ -497,7 +478,7 @@ test( timeoutMs: 15 * 60_000, }); expect(rebuild.exitCode, resultText(rebuild)).toBe(0); - await expectBaselineExclusionAgreement(host, sandbox, SANDBOX_NAME, "phase-2-after-rebuild"); + await expectLiveBaselineExcluded(host, sandbox, SANDBOX_NAME, "phase-2-after-rebuild"); const markerContent = `SNAPSHOT_E2E_${Date.now()}`; const secondContent = `SNAPSHOT_E2E_SECOND_${Date.now()}`; @@ -596,7 +577,7 @@ printf '%s' ${JSON.stringify(soulContent)} > "$OPENCLAW_WORKSPACE_DIR/SOUL.md"`, }, ); expect(reapplyBaselineExclusion.exitCode, resultText(reapplyBaselineExclusion)).toBe(0); - await expectBaselineExclusionAgreement( + await expectLiveBaselineExcluded( host, sandbox, SANDBOX_NAME, @@ -655,7 +636,7 @@ test ! -e ${JSON.stringify(MARKER_FILE)}`, soulContent, "phase-4-read-restored-soul-file", ); - await expectBaselineExclusionAgreement( + await expectLiveBaselineExcluded( host, sandbox, SANDBOX_NAME, @@ -720,7 +701,7 @@ test ! -e ${JSON.stringify(MARKER_FILE)}`, markerContent, "phase-4-read-clone-marker", ); - await expectBaselineExclusionAgreement( + await expectLiveBaselineExcluded( host, sandbox, CLONE_SANDBOX_NAME, @@ -1221,7 +1202,7 @@ test ! -e ${JSON.stringify(MARKER_FILE)}`, id: "snapshot-commands", status: "passed", firstSnapshotTimestamp: timestamp, - baselineExclusionKey: BASELINE_EXCLUSION_KEY, + excludedLiveBaselineKey: BASELINE_EXCLUSION_KEY, cloneSandboxName: CLONE_SANDBOX_NAME, cloneRestoreResult, stoppedBackupTimestamp, diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 353a81479c8..3d719c09130 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -206,7 +206,9 @@ }, { "live": "test/e2e/live/network-policy.test.ts", + "liveSources": ["test/e2e/live/policy-list-state.ts"], "fast": [ + "src/lib/actions/sandbox/policy-channel-add-drift.test.ts", "test/channels/channels-add-preset.test.ts", "test/runtime/policy/policy-channel-agent-resolution.test.ts", "test/onboarding/validate-blueprint.test.ts", @@ -297,6 +299,7 @@ { "live": "test/e2e/live/snapshot-commands.test.ts", "fast": [ + "src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts", "test/e2e/support/snapshot-commands-helpers.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" @@ -366,6 +369,7 @@ { "live": "test/e2e/live/common-egress-agent.test.ts", "fast": [ + "src/lib/policy/policy-live-state.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts", "test/e2e/support/common-egress-agent-helpers.test.ts" @@ -494,7 +498,9 @@ }, { "live": "test/e2e/live/mcp-bridge.test.ts", + "liveSources": ["test/e2e/live/mcp-bridge-sandbox.ts"], "fast": [ + "src/lib/actions/sandbox/mcp-bridge-policy.test.ts", "src/lib/actions/sandbox/mcp-bridge-adapter-registration.test.ts", "src/lib/actions/sandbox/mcp-bridge-adapter-teardown.test.ts", "src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts", @@ -525,6 +531,7 @@ "src/lib/onboard/providers.test.ts", "test/channels/channels-add-bridge-lifecycle.test.ts", "test/e2e/support/messaging-providers-runtime-proofs.test.ts", + "test/onboarding/onboard-messaging.test.ts", "test/runtime/messaging/messaging-build-applier.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" @@ -590,6 +597,7 @@ { "live": "test/e2e/live/openshell-gateway-upgrade.test.ts", "fast": [ + "test/e2e/support/e2e-live-target-gating.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -604,7 +612,9 @@ }, { "live": "test/e2e/live/rebuild-hermes.test.ts", + "liveSources": ["test/e2e/live/rebuild-hermes-host-policy.ts"], "fast": [ + "src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts", "test/e2e/support/rebuild-hermes-env.test.ts", @@ -617,6 +627,7 @@ { "live": "test/e2e/live/rebuild-openclaw.test.ts", "fast": [ + "src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -638,6 +649,7 @@ { "live": "test/e2e/live/shields-config.test.ts", "fast": [ + "src/lib/shields/policy-delta.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts", "test/e2e/support/shields-failed-startup.test.ts" @@ -704,6 +716,7 @@ "test/e2e/live/channels-stop-start-plan-state.ts" ], "fast": [ + "test/channels/channels-add-bridge-lifecycle.test.ts", "test/e2e/support/channels-stop-start-cleanup.test.ts", "test/e2e/support/channels-stop-start-config-state.test.ts", "test/e2e/support/channels-stop-start-googlechat.test.ts", diff --git a/test/e2e/support/channels-stop-start-googlechat.test.ts b/test/e2e/support/channels-stop-start-googlechat.test.ts index a2275cda78d..4df9fa1a248 100644 --- a/test/e2e/support/channels-stop-start-googlechat.test.ts +++ b/test/e2e/support/channels-stop-start-googlechat.test.ts @@ -24,7 +24,61 @@ type FixtureProviderDependencies = { ): string[]; }; +type FixtureChannelDependencies = Pick< + (typeof import("../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"))["policyChannelDependencies"], + "runGatewayOpenshell" | "upsertMessagingProviders" +>; + describe("channels stop/start Google Chat live composition", () => { + it("intercepts the live policy-channel boundary before gateway refresh minting", () => { + const sandboxName = "e2e-oc-ch-cycle"; + const expectedName = `${sandboxName}-googlechat-bridge`; + const calls: string[][] = []; + const originalUpsert = vi.fn(() => []); + const channelDependencies: FixtureChannelDependencies = { + upsertMessagingProviders: originalUpsert, + runGatewayOpenshell: vi.fn((_gatewayName, args) => { + calls.push(args); + return { status: args[1] === "get" ? 1 : 0 } as never; + }), + }; + const restore = installGooglechatCredentialFixture(sandboxName, "openclaw", { + channelDependencies, + ensureProfiles: vi.fn(), + root: "/repo", + }); + + expect( + restore.upsertMessagingProviders( + [ + { + name: expectedName, + envKey: "GOOGLE_CHAT_ACCESS_TOKEN", + token: null, + providerType: "google-chat-bridge", + }, + ], + "nemoclaw", + { bestEffort: true, requireExactBindings: true }, + ), + ).toEqual([expectedName]); + expect(originalUpsert).not.toHaveBeenCalled(); + expect(channelDependencies.upsertMessagingProviders).toBe(originalUpsert); + expect(calls).toContainEqual([ + "provider", + "create", + "--name", + expectedName, + "--type", + "google-chat-bridge", + "--credential", + "GOOGLE_CHAT_ACCESS_TOKEN", + ]); + + restore(); + expect(channelDependencies.upsertMessagingProviders).toBe(originalUpsert); + }); + it("grants a process-local audience capability to the exact live sandbox", async () => { const addSandboxChannel = vi.fn(async () => {}); const rebuildSandbox = vi.fn(async () => {}); diff --git a/test/e2e/support/channels-stop-start-plan-state.test.ts b/test/e2e/support/channels-stop-start-plan-state.test.ts index 530acec779f..8f2036f112a 100644 --- a/test/e2e/support/channels-stop-start-plan-state.test.ts +++ b/test/e2e/support/channels-stop-start-plan-state.test.ts @@ -39,19 +39,6 @@ function plan(agent: AgentKind, state: ChannelPlanExpectedState): Record { expect(errors(value, "hermes", "active")).toContain("slack credential binding must be present"); }); - it("rejects policy and credential residue after removal", () => { + it("rejects policy shadow state and credential residue after removal", () => { const value = plan("openclaw", "removed"); value.networkPolicy = { presets: [CHANNEL_ID], entries: [{ channelId: CHANNEL_ID }] }; value.credentialBindings = [{ channelId: CHANNEL_ID }]; expect(errors(value, "openclaw", "removed")).toEqual([ - "slack policy preset must be removed", - "slack policy entry must be removed", + "messaging.plan.networkPolicy must not persist", "slack credential binding must be removed", ]); }); diff --git a/test/e2e/support/e2e-live-target-gating.test.ts b/test/e2e/support/e2e-live-target-gating.test.ts index 5fda5bd9037..10486574efd 100644 --- a/test/e2e/support/e2e-live-target-gating.test.ts +++ b/test/e2e/support/e2e-live-target-gating.test.ts @@ -238,7 +238,7 @@ describe("live E2E target gating", () => { ], [ "openshell-gateway-upgrade.test.ts", - "openshell-gateway-upgrade: upgrades old working OpenClaw claw and restores survivor state", + "openshell-gateway-upgrade: preserves live OpenShell state or fails closed without it", ], ] as const)("applies the Linux gate to %s at real Vitest collection", (file, testName) => { const result = listLiveTests({ diff --git a/test/generation/sync-agent-variant-docs.test.ts b/test/generation/sync-agent-variant-docs.test.ts index afc2854736c..14770066efe 100644 --- a/test/generation/sync-agent-variant-docs.test.ts +++ b/test/generation/sync-agent-variant-docs.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; import { renderAgentVariantPage } from "../../scripts/sync-agent-variant-docs.mts"; @@ -304,6 +305,28 @@ Run $$nemoclaw list. }); } + it("replaces a multiline commands frontmatter value completely", () => { + const source = FRONTMATTER.replace( + 'keywords: ["nemoclaw cli commands", "nemoclaw command reference", "nemo-deepagents commands", "dcode commands"]', + `keywords: + [ + "nemoclaw cli commands", + "nemoclaw command reference", + "nemo-deepagents commands", + "dcode commands", + ]`, + ); + const rendered = renderHermesCommandsVariant(`${source}\nRun $$nemoclaw list.\n`); + const frontmatter = rendered.match(/^---\n([\s\S]*?)\n---\n/)?.[1] ?? ""; + + expect(frontmatter).not.toBe(""); + expect(() => parse(frontmatter)).not.toThrow(); + expect(frontmatter).toContain( + 'keywords: ["nemohermes cli commands", "hermes command reference", "nemohermes command reference"]', + ); + expect(frontmatter).not.toContain('"nemoclaw cli commands"'); + }); + it("rewrites only NemoClaw CLI invocations for the NemoHermes reference", () => { const rendered = renderHermesCommandsVariant(`${FRONTMATTER} ### \`nemoclaw list\` diff --git a/test/helpers/growth-guardrail-diff.ts b/test/helpers/growth-guardrail-diff.ts index e658b7b4f15..199521a1455 100644 --- a/test/helpers/growth-guardrail-diff.ts +++ b/test/helpers/growth-guardrail-diff.ts @@ -61,11 +61,16 @@ function readWorktreeFile(file: string): string | null { return existsSync(absolute) ? readFileSync(absolute, "utf8") : null; } -function readFiles( +function readFilesCached( paths: readonly string[], + cache: Map, read: (file: string) => string | null, ): ReadonlyMap { - return new Map([...new Set(paths)].map((file) => [file, read(file)])); + const uniquePaths = [...new Set(paths)]; + uniquePaths + .filter((file) => !cache.has(file)) + .forEach((file) => cache.set(file, read(file))); + return new Map(uniquePaths.map((file) => [file, cache.get(file) ?? null])); } function selectLocalComparisonBase( @@ -130,14 +135,16 @@ function loadLocalDiff(): GrowthGuardrailDiff { for (const filename of untracked.split("\0").filter(Boolean)) { if (!known.has(filename)) files.push({ filename, status: "added" }); } + const baseCache = new Map(); + const headCache = new Map(); return { files, async readBase(paths) { - return readFiles(paths, (file) => readGitFile(comparisonBase, file)); + return readFilesCached(paths, baseCache, (file) => readGitFile(comparisonBase, file)); }, async readHead(paths) { - return readFiles(paths, readWorktreeFile); + return readFilesCached(paths, headCache, readWorktreeFile); }, }; } @@ -180,14 +187,16 @@ function loadPullRequestDiff(): GrowthGuardrailDiff { cwd: REPO_ROOT, encoding: "utf8", }); + const baseCache = new Map(); + const headCache = new Map(); return { files: parseChangedFiles(changed), async readBase(paths) { - return readFiles(paths, (file) => readGitFile(baseSha, file)); + return readFilesCached(paths, baseCache, (file) => readGitFile(baseSha, file)); }, async readHead(paths) { - return readFiles(paths, (file) => readGitFile(headSha, file)); + return readFilesCached(paths, headCache, (file) => readGitFile(headSha, file)); }, }; } @@ -199,5 +208,6 @@ export function loadGrowthGuardrailDiff(): Promise { export const testOnly = { parseAncestorProbe, parseChangedFiles, + readFilesCached, selectLocalComparisonBase, }; diff --git a/test/helpers/hermes-portable-uninstall-fixture.ts b/test/helpers/hermes-portable-uninstall-fixture.ts index 3ba2ec47483..16974c07331 100644 --- a/test/helpers/hermes-portable-uninstall-fixture.ts +++ b/test/helpers/hermes-portable-uninstall-fixture.ts @@ -32,7 +32,6 @@ import { } from "../../src/lib/onboard/experimental/hermes-portable-podman-authority"; import { hermesPortableContainerInternals } from "../../src/lib/onboard/experimental/hermes-portable-container"; import { resolveHermesPortableStartupContract } from "../../src/lib/onboard/experimental/hermes-portable-contract"; -import { hermesPortableCreatePolicySemanticDigest } from "../../src/lib/onboard/experimental/hermes-portable-policy-authority"; import { captureHermesPortablePolicySource, publishHermesPortableDurablePolicySource, @@ -173,17 +172,15 @@ function publishLifecycleReceipt( const policyPath = path.join(stateDir, "portable-uninstall-policy.yaml"); fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); const transactionId = randomUUID(); - const policyBytes = fs.readFileSync(policyPath); const policy = publishHermesPortableDurablePolicySource({ sandboxName: SANDBOX_NAME, transactionId, stateDir, - intendedSemanticSha256: hermesPortableCreatePolicySemanticDigest(policyBytes), source: captureHermesPortablePolicySource(policyPath), hooks: { assertLifecycleLock: () => undefined }, }); const pending: HermesPortablePendingReceipt = { - schemaVersion: 5, + schemaVersion: 7, agent: "hermes", phase: "pending", transactionId, @@ -210,11 +207,11 @@ function publishLifecycleReceipt( const first = publishHermesPortableLifecycleReceipt(pending, stateDir, { assertLifecycleLock: () => undefined, }); + const { policy: _policy, ...transaction } = pending; const configuring: HermesPortableConfiguredReceipt = { - ...pending, + ...transaction, phase: "configuring", previousPhaseSha256: first.sha256, - verifiedLivePolicySemanticSha256: policy.intendedSemanticSha256, container: { containerId: SANDBOX_CONTAINER_ID, sandboxId: SANDBOX_ID, diff --git a/test/helpers/hermes-shields-provider-consumer-harness.ts b/test/helpers/hermes-shields-provider-consumer-harness.ts index de3a797f599..cca19feb6fe 100644 --- a/test/helpers/hermes-shields-provider-consumer-harness.ts +++ b/test/helpers/hermes-shields-provider-consumer-harness.ts @@ -8,7 +8,7 @@ import path from "node:path"; import { type MockInstance, vi } from "vitest"; import type { SandboxEntry } from "../../src/lib/state/registry"; -import { managedPolicyMutationAuthority } from "./shields-flow-harness"; +import { livePolicyMutationContext } from "./shields-flow-harness"; const INDEX_MODULE = "./index.js"; export const HERMES_PROVIDER_CAPABILITY_PATH = @@ -47,7 +47,6 @@ export const hermesProviderConsumerSandbox: SandboxEntry = { name: "current-hermes", agent: "hermes", openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", lifecycleGeneration: "generation-1", workload: { schemaVersion: 1, @@ -87,31 +86,38 @@ export type HermesShieldsProviderConsumerHarness = { cleanup: () => void; }; -export function writeBoundForwardPolicy( - stateDir: string, - sandboxName: string, - processToken: string, - content = "version: 1\nnetwork_policies:\n permissive: {}\n", +export function writeBoundPolicySnapshot( + policyPath: string, + content = "version: 1\nnetwork_policies:\n restrictive: {}\n", ) { - const policyPath = path.join( - stateDir, - `shields-forward-policy-${sandboxName}-${processToken}.yaml`, - ); fs.writeFileSync(policyPath, content, { mode: 0o600 }); fs.chmodSync(policyPath, 0o600); const metadata = fs.statSync(policyPath); return { - schemaVersion: 1, + schemaVersion: 1 as const, path: policyPath, sha256: createHash("sha256").update(content).digest("hex"), size: Buffer.byteLength(content), mode: 0o600, uid: metadata.uid, gid: metadata.gid, - nlink: 1, + nlink: 1 as const, }; } +export function writeBoundForwardPolicy( + stateDir: string, + sandboxName: string, + processToken: string, + content = "version: 1\nnetwork_policies:\n permissive: {}\n", +) { + const policyPath = path.join( + stateDir, + `shields-forward-policy-${sandboxName}-${processToken}.yaml`, + ); + return writeBoundPolicySnapshot(policyPath, content); +} + export function writeTimerAuthorizationProof(loadSource: NodeRequire, sandboxName: string): void { const timerControl = loadSource( "./timer-control.js", @@ -287,16 +293,10 @@ export function createHermesShieldsProviderConsumerHarness( String(file), String(name), ]), - vi - .spyOn(policy, "inspectPolicyMutationAuthority") - .mockReturnValue(managedPolicyMutationAuthority), - vi - .spyOn(policy, "inspectPolicyRecoveryAuthority") - .mockReturnValue(managedPolicyMutationAuthority), - vi - .spyOn(policy, "recheckPolicyMutationAuthority") - .mockReturnValue(managedPolicyMutationAuthority), - vi.spyOn(policy, "finalizePolicyMutationReceipt").mockImplementation(() => undefined), + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(livePolicyMutationContext), + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(livePolicyMutationContext), + vi.spyOn(policy, "recheckPolicyMutationContext").mockReturnValue(livePolicyMutationContext), + vi.spyOn(policy, "verifyAppliedPolicyDocument").mockImplementation(() => undefined), registrySpy, vi .spyOn(privilegedExec, "privilegedSandboxExecArgv") diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index 47f5058b2b7..74a1bf748d9 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -7,7 +7,7 @@ import os from "node:os"; import path from "node:path"; import { expect, type MockInstance, vi } from "vitest"; -import { managedPolicyMutationAuthority } from "./shields-flow-harness"; +import { livePolicyMutationContext } from "./shields-flow-harness"; type RequireSource = NodeJS.Require; @@ -281,22 +281,21 @@ export function createHermesUnsafeConfigHarness( ); vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue(permissivePolicyPath); - vi - .spyOn(policy, "inspectPolicyMutationAuthority") - .mockReturnValue(managedPolicyMutationAuthority); - vi - .spyOn(policy, "inspectPolicyRecoveryAuthority") - .mockReturnValue(managedPolicyMutationAuthority); - vi - .spyOn(policy, "recheckPolicyMutationAuthority") - .mockReturnValue(managedPolicyMutationAuthority); - vi.spyOn(policy, "finalizePolicyMutationReceipt").mockImplementation(() => undefined); + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue( + livePolicyMutationContext, + ); + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue( + livePolicyMutationContext, + ); + vi.spyOn(policy, "recheckPolicyMutationContext").mockReturnValue( + livePolicyMutationContext, + ); + vi.spyOn(policy, "verifyAppliedPolicyDocument").mockImplementation(() => undefined); vi.spyOn(agentConfig, "resolveAgentConfig").mockReturnValue(hermesTarget); vi.spyOn(registry, "getSandbox").mockImplementation((name: unknown) => ({ name: String(name), agent: "hermes", openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", lifecycleGeneration: "legacy-generation", workload: { kind: "managed-image" }, })); diff --git a/test/helpers/managed-policy-receipt-fixture.ts b/test/helpers/live-policy-fixture.ts similarity index 76% rename from test/helpers/managed-policy-receipt-fixture.ts rename to test/helpers/live-policy-fixture.ts index cc81b77d3be..cad422cea14 100644 --- a/test/helpers/managed-policy-receipt-fixture.ts +++ b/test/helpers/live-policy-fixture.ts @@ -25,35 +25,20 @@ export function managedSandboxEntry( const gatewayName = options.gatewayName ?? "nemoclaw"; const gatewayPort = options.gatewayPort ?? 8080; const lifecycleGeneration = options.lifecycleGeneration ?? LIFECYCLE_GENERATION; - const policyHash = options.policyHash ?? POLICY_HASH; - const policyVersion = options.policyVersion ?? POLICY_VERSION; return { name, agent, - policies: [], openshellDriver: "docker", gatewayName, gatewayPort, lifecycleGeneration, lifecycleLiveIdentityFingerprint: SANDBOX_IDENTITY, - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt: { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName, - gatewayPort, - sandboxName: name, - lifecycleGeneration, - sandboxIdentityFingerprint: SANDBOX_IDENTITY, - policyHash, - policyVersion, - }, }; } -export function managedPolicyInspection() { +export function livePolicyInspection() { return { - authority: "owner-unknown" as const, + policySource: "sandbox" as const, effectivePolicy: {}, policyIdentity: { hash: POLICY_HASH, @@ -66,7 +51,7 @@ export function managedRegistrationSource(name: string, agent = "openclaw"): str return `registry.registerSandbox(${JSON.stringify(managedSandboxEntry(name, agent))});`; } -export function managedPolicyMetadata(sandboxName: string): string { +export function livePolicyMetadata(sandboxName: string): string { return JSON.stringify({ scope: "sandbox", sandbox: sandboxName, diff --git a/test/helpers/managed-image-buildless-e2e.ts b/test/helpers/managed-image-buildless-e2e.ts index 831d19792ba..6a77cb6b912 100644 --- a/test/helpers/managed-image-buildless-e2e.ts +++ b/test/helpers/managed-image-buildless-e2e.ts @@ -517,7 +517,7 @@ runner.runCaptureEx = (command) => { }; const registry = require(${source("src/lib/state/registry.ts")}); -const sourceEntry = recreate ? fixtureMocks.managedSandboxPolicyReceiptFixture({ +const sourceEntry = recreate ? fixtureMocks.sandboxLifecycleFixture({ name: sandboxName, agent: "hermes", gpuEnabled: false, diff --git a/test/helpers/onboard-final-flow-phases.ts b/test/helpers/onboard-final-flow-phases.ts index 522b4630a19..73e44028ae0 100644 --- a/test/helpers/onboard-final-flow-phases.ts +++ b/test/helpers/onboard-final-flow-phases.ts @@ -37,7 +37,6 @@ export type RecorderOverrides = { sandboxName?: string | null; provider?: string | null; model?: string | null; - policyPresets?: string[] | null; }, ) => Promise; recordStepComplete?: (stepName: string, updates?: SessionUpdates) => Promise; @@ -66,14 +65,6 @@ export type RecorderOverrides = { Agent | null, WebSearchConfig >["deps"]["setupPoliciesWithSelection"]; - persistAppliedPolicyPresets?: PoliciesStateOptions< - Agent | null, - WebSearchConfig - >["deps"]["persistAppliedPolicyPresets"]; - revalidatePolicyRequirements?: ( - context: OnboardFlowContext, - operation: string, - ) => void; }; function cloneSession(session: Session): Session { @@ -92,7 +83,6 @@ export function sessionAt(state: OnboardMachineState): Session { sandboxName: "my-sandbox", provider: "nim", model: "nvidia/test", - policyAuthority: "nemoclaw-managed", machine: { version: MACHINE_SNAPSHOT_VERSION, state, @@ -201,7 +191,6 @@ export function createPhases( VerifyDeploymentResult >({ branchState, - revalidatePolicyRequirements: recorders.revalidatePolicyRequirements, agentSetupDeps: { handleAgentSetup: vi.fn(async () => { order.push("agent-setup"); @@ -229,8 +218,7 @@ export function createPhases( toSessionUpdates: (updates) => updates as SessionUpdates, }, policiesDeps: { - loadSession: - recorders.loadSession ?? (() => createSession({ policyAuthority: "nemoclaw-managed" })), + loadSession: recorders.loadSession ?? (() => createSession()), getActiveSandbox: recorders.getActiveSandbox ?? (() => null), mergePolicyMessagingChannels: recorders.mergePolicyMessagingChannels ?? ((selected) => selected), @@ -238,7 +226,7 @@ export function createPhases( verifyCompatibleEndpointSandboxSmoke: vi.fn(), preparePolicyPresetResumeSelection: () => ({ policyPresets: ["balanced"], - recordedPolicyPresetsNeedReconcile: false, + livePolicyPresetsNeedUpdate: false, disabledMessagingPolicyPresetApplied: false, suppressedAgentRequiredPresetsLive: false, }), @@ -252,15 +240,12 @@ export function createPhases( order.push("policies"); return ["balanced"]; }), - updateSession: - recorders.updateSession ?? vi.fn((mutator) => mutator(createSession()) ?? createSession()), recordStepComplete: recorders.recordStepComplete ?? vi.fn(async (_stepName: string, updates: SessionUpdates = {}) => sessionWithUpdates(updates), ), toSessionUpdates: (updates) => updates as SessionUpdates, - persistAppliedPolicyPresets: recorders.persistAppliedPolicyPresets ?? vi.fn(), }, finalization: { stagedLegacyKeys: [], diff --git a/test/helpers/onboard-script-mocks-policy-authority.test.ts b/test/helpers/onboard-script-mocks-openshell-capture.test.ts similarity index 97% rename from test/helpers/onboard-script-mocks-policy-authority.test.ts rename to test/helpers/onboard-script-mocks-openshell-capture.test.ts index 303514487b8..1c43a99ea81 100644 --- a/test/helpers/onboard-script-mocks-policy-authority.test.ts +++ b/test/helpers/onboard-script-mocks-openshell-capture.test.ts @@ -26,13 +26,7 @@ const exactCreateQuery = [ "--limit", "2", ] as const; -const exactCreateCommand = [ - "openshell", - "sandbox", - "create", - "--label", - selector, -] as const; +const exactCreateCommand = ["openshell", "sandbox", "create", "--label", selector] as const; afterEach(() => { vi.restoreAllMocks(); @@ -150,7 +144,7 @@ describe("mockStructuredOpenShellCaptureFromRunner", () => { } }); - it("synthesizes exact gateway-scoped JSON authority queries (#9833)", () => { + it("synthesizes exact gateway-scoped OpenShell reads (#9833)", () => { expect( client.captureOpenshellCommand("/opt/openshell", ["gateway", "info", "-g", "nemoclaw-test"], { includeStreams: true, diff --git a/test/helpers/onboard-script-mocks.cjs b/test/helpers/onboard-script-mocks.cjs index 3dbbf88023b..3d0ccc32983 100644 --- a/test/helpers/onboard-script-mocks.cjs +++ b/test/helpers/onboard-script-mocks.cjs @@ -691,17 +691,17 @@ function installVerifiedSandboxCreateFixture(registry, options) { entry: structuredClone(reservationEntry), }; }; - const recordPendingSandboxPolicyVerification = (reservation, checkpoint) => { + const recordPendingSandboxCreateIdentity = (reservation, checkpoint) => { pendingCheckpoint = structuredClone(checkpoint); pendingEntry = { ...structuredClone(reservation.entry), lifecycleGeneration: checkpoint.lifecycleGeneration, lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint, - pendingPolicyVerification: structuredClone(checkpoint), + pendingCreateIdentity: structuredClone(checkpoint), }; return structuredClone(pendingEntry); }; - const requireCurrentPendingSandboxPolicyVerification = (reservation, checkpoint) => { + const requireCurrentPendingSandboxCreateIdentity = (reservation, checkpoint) => { if ( reservation.authority.sessionId !== sessionId || pendingCheckpoint === null || @@ -716,8 +716,8 @@ function installVerifiedSandboxCreateFixture(registry, options) { const registryFixture = { ...registry, qualifyPendingSandboxCreateReservation, - recordPendingSandboxPolicyVerification, - requireCurrentPendingSandboxPolicyVerification, + recordPendingSandboxCreateIdentity, + requireCurrentPendingSandboxCreateIdentity, getSandbox: (name) => name === sandboxName ? structuredClone(publishedEntry || pendingEntry || sourceEntry) @@ -762,65 +762,19 @@ function installVerifiedSandboxCreateFixture(registry, options) { require.cache[registryPath].exports = registry; } - const receiptPath = require.resolve( - path.resolve(__dirname, "../../src/lib/onboard/sandbox-create/policy-creation-receipt.ts"), + const policyRequirementsPath = require.resolve( + path.resolve(__dirname, "../../src/lib/onboard/sandbox-create/live-policy-requirements.ts"), ); - const receipt = require(receiptPath); - const apfPolicyRegistration = (input) => { - if (options.apfInterceptorRequested !== true) { - throw new Error("integration fixture received unexpected APF policy verification"); - } - options.onVerifyCreatedPolicy?.(input); - return { - policyAuthority: "externally-managed", - observedPolicyAuthority: "owner-unknown", - policyCreationReceipt: null, - policyIdentity: { - hash: "fixture-policy", - activeVersion: 1, - }, - }; - }; - Object.defineProperties(receipt, { - verifyCreatedApfInterceptorPolicyRegistration: { - configurable: true, - enumerable: true, - writable: true, - value: apfPolicyRegistration, - }, - verifyCreatedSandboxPolicyRegistration: { + const policyRequirements = require(policyRequirementsPath); + Object.defineProperties(policyRequirements, { + verifyLiveCreatedSandboxPolicyRequirements: { configurable: true, enumerable: true, writable: true, - value: (input) => { - if (input.plannedAuthority !== "nemoclaw-managed") { - throw new Error("integration fixture supports only managed sandbox creation"); - } - return { - policyAuthority: "nemoclaw-managed", - observedPolicyAuthority: "owner-unknown", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName: input.gatewayName, - gatewayPort: input.gatewayPort, - sandboxName: input.sandboxName, - lifecycleGeneration: input.lifecycleGeneration, - sandboxIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, - policyHash: "fixture-policy", - policyVersion: 1, - }, - }; - }, - }, - revalidateCreatedSandboxPolicyRegistration: { - configurable: true, - enumerable: true, - writable: true, - value: (input) => input.registration, + value: (input) => options.onVerifyCreatedPolicy?.(input), }, }); - require.cache[receiptPath].exports = receipt; + require.cache[policyRequirementsPath].exports = policyRequirements; const prepareCreateIntent = () => { const onboardSession = require( path.resolve(__dirname, "../../src/lib/state/onboard-session.ts"), @@ -836,7 +790,7 @@ function installVerifiedSandboxCreateFixture(registry, options) { : registryFixture.getSandbox(sandboxName); const recoverPendingCreate = currentEntry?.pendingRouteReservation === true && - currentEntry.pendingPolicyVerification !== undefined; + currentEntry.pendingCreateIdentity !== undefined; let transaction = currentTransaction && (currentTransaction.phase !== "created" || recoverPendingCreate) ? currentTransaction @@ -921,8 +875,7 @@ function sandboxCreateArgsWithVerifiedReservation(args, fixture) { return createArgs; } -function managedSandboxPolicyReceiptFixture(entry, options = {}) { - const sandboxName = options.sandboxName || entry.name; +function sandboxLifecycleFixture(entry, options = {}) { const gatewayName = options.gatewayName || "nemoclaw"; const gatewayPort = options.gatewayPort || 8080; const lifecycleGeneration = options.lifecycleGeneration || "123e4567-e89b-42d3-a456-426614174983"; @@ -931,26 +884,12 @@ function managedSandboxPolicyReceiptFixture(entry, options = {}) { .createHash("sha256") .update(sandboxId) .digest("hex"); - const policyHash = options.policyHash || "fixture-policy"; - const policyVersion = options.policyVersion || 1; return { ...entry, gatewayName, gatewayPort, lifecycleGeneration, lifecycleLiveIdentityFingerprint: sandboxIdentityFingerprint, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt: { - schemaVersion: 1, - origin: "sandbox-create", - gatewayName, - gatewayPort, - sandboxName, - lifecycleGeneration, - sandboxIdentityFingerprint, - policyHash, - policyVersion, - }, }; } @@ -1374,7 +1313,7 @@ module.exports = { createCreatedSandboxFixture, mockStructuredOpenShellCaptureFromRunner, installVerifiedSandboxCreateFixture, - managedSandboxPolicyReceiptFixture, + sandboxLifecycleFixture, mockOnboardRunCapture, mockStandaloneGatewayTeardownAuthority, normalizeCommand, diff --git a/test/helpers/openclaw-real-device-self-approval-proof.ts b/test/helpers/openclaw-real-device-self-approval-proof.ts index 2987041139c..ffd060949b2 100644 --- a/test/helpers/openclaw-real-device-self-approval-proof.ts +++ b/test/helpers/openclaw-real-device-self-approval-proof.ts @@ -1509,10 +1509,7 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { fs.mkdirSync(portableControllerBin); fs.writeFileSync( path.join(portableControllerBin, "openclaw"), - [ - "#!/bin/sh", - 'exec "$NEMOCLAW_PROOF_NODE" "$NEMOCLAW_PROOF_OPENCLAW" "$@"', - ].join("\n"), + ["#!/bin/sh", 'exec "$NEMOCLAW_PROOF_NODE" "$NEMOCLAW_PROOF_OPENCLAW" "$@"'].join("\n"), { mode: 0o700 }, ); const controllerEnv: NodeJS.ProcessEnv = { @@ -1539,7 +1536,6 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { name: "portable-controller-proof", agent: "openclaw", agentVersion: options.version, - policyPresetsFinalized: true, lifecycleGeneration: "portable-controller-generation", lifecycleLiveIdentityFingerprint: "portable-controller-live-identity", gatewayName: resolveGatewayName(port), @@ -1616,11 +1612,7 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { ), runPortablePairingProducer: (sandboxName, gatewayName) => { producerCalls += 1; - runPortableOpenClawPairingRequestProducer( - sandboxName, - gatewayName, - controllerExecDeps, - ); + runPortableOpenClawPairingRequestProducer(sandboxName, gatewayName, controllerExecDeps); }, runPortablePairingApproval: (_sandboxName, _gatewayName, _expectedIdentity) => { approvalCalls += 1; diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index c1097ac7e13..5a3d13c6172 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -9,6 +9,7 @@ import { agentDefs, agentOnboard, agentRuntime, + createHarnessTempDir, createRebuildFlowSession, destroy, dockerImage, @@ -18,6 +19,7 @@ import { gatewayState, gatewayTeardownAuthority, installTerminalStepFailureMock, + listHarnessRebuildBackups, loadRebuildSandbox, mcpBridge, messaging, @@ -27,6 +29,7 @@ import { onboardSession, openshellRuntime, policies, + policyGet, processRecovery, purgeRebuildModule, type RebuildFlowSession, @@ -41,6 +44,7 @@ import { rebuildShields, registry, registryPersistence, + registerHarnessRebuildBackup, resolve, sandboxList, sandboxSession, @@ -96,7 +100,6 @@ export type RebuildFlowOverrides = { sandboxEntryReads?: Array | null>; sessionSandboxName?: string; sandboxInventory?: OpenShellSandboxInventory; - backupPolicyPresets?: string[]; gatewayPresets?: string[]; verificationUnavailableAfterPresetRemoval?: boolean; preDeleteSandboxEntry?: Record; @@ -126,10 +129,14 @@ export type RebuildFlowOverrides = { entries: Array>; detachedProviderEntries: Array>; scrubbedAdapterEntries: Array>; + policyHandoff?: string; + revalidateBeforeDelete?: () => Promise; + assertDeleteEdgeUnchanged?: () => void; }; }; export type RebuildFlowHarness = { + backupPath: string; rebuildSandbox: RebuildSandbox; applyPresetSpy: MockInstance; applyPresetContentSpy: MockInstance; @@ -176,6 +183,9 @@ export type RebuildFlowHarness = { export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): RebuildFlowHarness { purgeRebuildModule(); + vi.spyOn(policyGet, "getSandboxPolicy").mockReturnValue({ + yaml: "version: 1\nnetwork_policies:\n host_preserved: {}\n", + }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -476,31 +486,51 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): window.relocked = true; return true; }); + const backupPath = createHarnessTempDir("nemoclaw-rebuild-backup-"); + const backupManifest = { + agentType: overrides.agentName ?? "openclaw", + backupPath, + timestamp: "2026-06-01T00:00:00.000Z", + }; + registerHarnessRebuildBackup( + backupManifest as ReturnType[number], + ); const backupSandboxStateSpy = vi.spyOn(sandboxState, "backupSandboxState").mockReturnValue({ success: true, backedUpDirs: ["workspace"], backedUpFiles: ["user.md"], failedDirs: [], failedFiles: [], - manifest: { - agentType: overrides.agentName ?? "openclaw", - backupPath: "/tmp/nemoclaw-rebuild-backup", - timestamp: "2026-06-01T00:00:00.000Z", - policyPresets: overrides.backupPolicyPresets ?? ["npm", "bad", "throw"], - }, + manifest: backupManifest, }); + let latestValidatedRecoveryManifest: Record | null = null; vi.spyOn(sandboxState, "validateRebuildRecoveryManifest").mockImplementation( (...args: unknown[]) => { const manifest = args[2] as Record; - return overrides.recoveryManifestValidation?.(manifest) ?? { ok: true as const, manifest }; + const result = overrides.recoveryManifestValidation?.(manifest) ?? { + ok: true as const, + manifest, + }; + if (result.ok) { + latestValidatedRecoveryManifest = result.manifest; + registerHarnessRebuildBackup( + result.manifest as ReturnType[number], + ); + } + return result; }, ); - vi.spyOn(sandboxState, "getLatestBackup").mockImplementation( - () => - (overrides.preDeleteLatestManifest === undefined - ? makePreparedRecoveryManifest() - : overrides.preDeleteLatestManifest) as ReturnType, - ); + vi.spyOn(sandboxState, "getLatestBackup").mockImplementation(() => { + const manifest = + overrides.preDeleteLatestManifest === undefined + ? latestValidatedRecoveryManifest + : overrides.preDeleteLatestManifest; + if (manifest) { + registerHarnessRebuildBackup(manifest as ReturnType[number]); + } + return manifest as ReturnType; + }); + vi.spyOn(sandboxState, "listBackups").mockImplementation(listHarnessRebuildBackups); vi.spyOn(sandboxState, "hasPositiveManagedImageEvidence").mockReturnValue( overrides.managedImageEvidence ?? true, ); @@ -742,6 +772,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): warnSpy.mockClear(); return { + backupPath, rebuildSandbox: loadRebuildSandbox(), applyPresetSpy, applyPresetContentSpy, diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index b68923d638a..565d61b2be8 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -4,7 +4,6 @@ import fs from "node:fs"; import path from "node:path"; import { vi } from "vitest"; -import { makePreparedRecoveryManifest } from "../../src/lib/actions/sandbox/rebuild-flow-test-fixtures"; import type { RebuildRecreateOnboardOpts } from "../../src/lib/actions/sandbox/rebuild-gpu-opt-out"; import { agentDefs, @@ -19,6 +18,7 @@ import { gatewayTeardownAuthority, hermesProviderAuth, installTerminalStepFailureMock, + listHarnessRebuildBackups, loadRebuildSandbox, mcpBridge, messaging, @@ -28,6 +28,7 @@ import { onboardSession, openshellRuntime, policies, + policyGet, processRecovery, purgeRebuildModule, type RebuildFlowHarness, @@ -41,6 +42,7 @@ import { rebuildUsageNotice, registry, registryPersistence, + registerHarnessRebuildBackup, resolve, sandboxList, sandboxSession, @@ -48,13 +50,17 @@ import { sandboxVersion, shields, sourceSandboxGateway, + tempFiles, } from "./rebuild-flow-harness"; export { + createHarnessTempDir, installRebuildFlowTestHooks, originalSandboxName, + policyGet, portableAgentLifecycle, snapshotEnv, + tempFiles, } from "./rebuild-flow-harness"; export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): RebuildFlowHarness { @@ -63,6 +69,11 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const backupPath = createHarnessTempDir("nemoclaw-rebuild-backup-"); + let latestValidatedRecoveryManifest: Record | null = null; + vi.spyOn(policyGet, "getSandboxPolicy").mockReturnValue({ + yaml: "version: 1\nnetwork_policies:\n host_preserved: {}\n", + }); const session = createRebuildFlowSession(onboardSession.MACHINE_SNAPSHOT_VERSION); const rebuildShieldsWindow = { relocked: false, wasLocked: false }; @@ -412,47 +423,67 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): .spyOn(sandboxState, "backupSandboxState") .mockImplementation(() => { overrides.beforeBackup?.(); + const manifest = { + agentType: + typeof overrides.sandboxEntry?.agent === "string" + ? overrides.sandboxEntry.agent + : "openclaw", + dir: "/sandbox/.openclaw", + backupPath, + timestamp: "2026-06-01T00:00:00.000Z", + ...(overrides.backupPreservedEnv + ? { preservedEnv: structuredClone(overrides.backupPreservedEnv) } + : {}), + ...(modelsCustomOpenClawImage + ? { + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls: structuredClone( + currentSandboxEntry.openclawImagePluginInstalls, + ), + } + : {}), + }; + registerHarnessRebuildBackup(manifest as ReturnType[number]); return { success: true, backedUpDirs: ["workspace"], backedUpFiles: ["user.md"], failedDirs: [], failedFiles: [], - manifest: { - agentType: - typeof overrides.sandboxEntry?.agent === "string" - ? overrides.sandboxEntry.agent - : "openclaw", - dir: "/sandbox/.openclaw", - backupPath: "/tmp/nemoclaw-rebuild-backup", - timestamp: "2026-06-01T00:00:00.000Z", - policyPresets: overrides.backupPolicyPresets ?? ["npm", "bad", "throw"], - ...(overrides.backupPreservedEnv - ? { preservedEnv: structuredClone(overrides.backupPreservedEnv) } - : {}), - ...(modelsCustomOpenClawImage - ? { - reconcileOpenClawImagePluginProvenance: true, - openclawImagePluginInstalls: structuredClone( - currentSandboxEntry.openclawImagePluginInstalls, - ), - } - : {}), - }, + manifest, }; }); vi.spyOn(sandboxState, "validateRebuildRecoveryManifest").mockImplementation( (...args: unknown[]) => { const manifest = args[2] as Record; - return overrides.recoveryManifestValidation?.(manifest) ?? { ok: true, manifest }; + const persistedPath = path.join(String(manifest.backupPath), "rebuild-manifest.json"); + const persistedManifest = fs.existsSync(persistedPath) + ? (JSON.parse(fs.readFileSync(persistedPath, "utf8")) as Record) + : manifest; + const result = overrides.recoveryManifestValidation?.(manifest) ?? { + ok: true, + manifest: persistedManifest, + }; + if (result.ok) { + latestValidatedRecoveryManifest = result.manifest; + registerHarnessRebuildBackup( + result.manifest as ReturnType[number], + ); + } + return result; }, ); - vi.spyOn(sandboxState, "getLatestBackup").mockImplementation( - () => - (overrides.preDeleteLatestManifest === undefined - ? makePreparedRecoveryManifest() - : overrides.preDeleteLatestManifest) as ReturnType, - ); + vi.spyOn(sandboxState, "getLatestBackup").mockImplementation(() => { + const manifest = + overrides.preDeleteLatestManifest === undefined + ? (latestValidatedRecoveryManifest ?? listHarnessRebuildBackups().at(-1) ?? null) + : overrides.preDeleteLatestManifest; + if (manifest) { + registerHarnessRebuildBackup(manifest as ReturnType[number]); + } + return manifest as ReturnType; + }); + vi.spyOn(sandboxState, "listBackups").mockImplementation(listHarnessRebuildBackups); vi.spyOn(sandboxState, "hasPositiveManagedImageEvidence").mockReturnValue( overrides.managedImageEvidence ?? true, ); @@ -665,6 +696,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): warnSpy.mockClear(); return { + backupPath, rebuildSandbox: loadRebuildSandbox(), applyPresetSpy, backupSandboxStateSpy, diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 071594096bc..3c045b64f09 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, vi } from "vitest"; +import { cleanupPreparedRecoveryManifests } from "../../src/lib/actions/sandbox/rebuild-flow-test-fixtures"; import { type RebuildSandbox, snapshotEnv } from "./rebuild-flow-test-support"; export * from "./rebuild-flow-test-support"; @@ -46,6 +47,7 @@ export const onboardCredentialEnv = requireDist("../../onboard/credential-env.js export const onboardSession = requireDist("../../state/onboard-session.js"); export const openshellRuntime = requireDist("../../adapters/openshell/runtime.js"); export const policies = requireDist("../../policy/index.js"); +export const policyGet = requireDist("./policy-get.js"); export const portableAgentLifecycle = requireDist( "../../onboard/experimental/portable-agent-lifecycle.js", ); @@ -68,6 +70,7 @@ export const sandboxSession = requireDist("../../state/sandbox-session.js"); export const sandboxState = requireDist("../../state/sandbox.js"); export const sandboxVersion = requireDist("../../sandbox/version.js"); export const shields = requireDist("../../shields/index.js"); +export const tempFiles = requireDist("../../onboard/temp-files.js"); export function purgeRebuildModule(): void { delete require.cache[requireDist.resolve(rebuildModulePath)]; @@ -85,6 +88,20 @@ export function sourceSandboxGateway(argv: string[], verb: string): string | nul } const harnessTempDirs: string[] = []; +type HarnessRebuildBackup = ReturnType[number]; +const harnessRebuildBackups: HarnessRebuildBackup[] = []; + +export function registerHarnessRebuildBackup(backup: HarnessRebuildBackup): void { + const existing = harnessRebuildBackups.findIndex( + (entry) => entry.backupPath === backup.backupPath, + ); + if (existing >= 0) harnessRebuildBackups.splice(existing, 1); + harnessRebuildBackups.push(structuredClone(backup)); +} + +export function listHarnessRebuildBackups(): HarnessRebuildBackup[] { + return structuredClone(harnessRebuildBackups); +} export function createHarnessTempDir(prefix: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -102,6 +119,7 @@ export function installRebuildFlowTestHooks(options: RebuildFlowTestHookOptions "NEMOCLAW_SANDBOX_NAME", ]); beforeEach(() => { + harnessRebuildBackups.splice(0); delete process.env.NEMOCLAW_SANDBOX_NAME; if (options.acceptThirdPartySoftware) { process.env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE = "1"; @@ -115,6 +133,7 @@ export function installRebuildFlowTestHooks(options: RebuildFlowTestHookOptions for (const dir of harnessTempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } + cleanupPreparedRecoveryManifests(); restoreRebuildFlowEnv(); }); } diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index cab63d41c99..fe1d5a9917e 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -96,6 +96,9 @@ export type RebuildFlowOverrides = { entries: Array>; detachedProviderEntries: Array>; scrubbedAdapterEntries?: Array>; + policyHandoff?: string; + revalidateBeforeDelete?: () => Promise; + assertDeleteEdgeUnchanged?: () => void; }; runOpenshell?: (args: string[]) => | { @@ -115,7 +118,6 @@ export type RebuildFlowOverrides = { stderr?: string; error?: Error; }; - backupPolicyPresets?: string[]; backupPreservedEnv?: PreservedEnvFile[]; ensureValidatedBraveSearchCredential?: () => Promise; ensureValidatedWebSearchCredential?: () => Promise; @@ -132,6 +134,7 @@ export type RebuildFlowOverrides = { clearShieldsState?: () => void; }; export type RebuildFlowHarness = { + backupPath: string; rebuildSandbox: RebuildSandbox; applyPresetSpy: MockInstance; backupSandboxStateSpy: MockInstance; diff --git a/test/helpers/runtime-state-mutation-control-harness.ts b/test/helpers/runtime-state-mutation-control-harness.ts index c1c873f2094..9d701388a49 100644 --- a/test/helpers/runtime-state-mutation-control-harness.ts +++ b/test/helpers/runtime-state-mutation-control-harness.ts @@ -40,6 +40,7 @@ real_verify_activation_receipt = control._verify_activation_receipt real_health_status = control._health_status real_release_activation_hold = control._release_activation_hold real_parse_proc_uids = control._parse_proc_uids +real_capture_process = control._capture_process real_recapture_reference = control._recapture_reference real_signal_exact_process = control._signal_exact_process real_wait_for_reference_running = control._wait_for_reference_running @@ -198,6 +199,7 @@ gateway = process( 177, ) auxiliary = process(78, "S", 10, "708", 1001, (b"tail", b"-F"), 178) + stdout_drain = process( 75, "S", @@ -243,6 +245,36 @@ activation = control.ActivationProof( ) results = {} +had_pidfd_open = hasattr(os, "pidfd_open") +original_pidfd_open = getattr(os, "pidfd_open", None) +had_pidfd_send_signal = hasattr(signal, "pidfd_send_signal") +original_pidfd_send_signal = getattr(signal, "pidfd_send_signal", None) +pidfd_signals = [] +os.pidfd_open = lambda _pid, _flags: os.open(os.devnull, os.O_RDONLY) +signal.pidfd_send_signal = lambda _fd, requested: pidfd_signals.append(requested) +control._capture_process = lambda _pid: process( + gateway.pid, + "S", + gateway.parent_pid, + "replacement-start", + gateway.uids[0], + gateway.command, + gateway.proc_inode + 1, +) +results["pid_reuse_signal"] = code( + lambda: real_signal_exact_process(gateway, signal.SIGTERM) +) +results["pid_reuse_signal_calls"] = list(pidfd_signals) +control._capture_process = real_capture_process +if had_pidfd_open: + os.pidfd_open = original_pidfd_open +else: + del os.pidfd_open +if had_pidfd_send_signal: + signal.pidfd_send_signal = original_pidfd_send_signal +else: + del signal.pidfd_send_signal + with tempfile.TemporaryDirectory() as atomic_root: atomic_root_fd = os.open(atomic_root, os.O_RDONLY | os.O_DIRECTORY) atomic_creation_modes = [] diff --git a/test/helpers/shields-flow-harness.ts b/test/helpers/shields-flow-harness.ts index fc88567262c..f90a46fe897 100644 --- a/test/helpers/shields-flow-harness.ts +++ b/test/helpers/shields-flow-harness.ts @@ -7,56 +7,29 @@ import path from "node:path"; import { expect, type MockInstance, vi } from "vitest"; import YAML from "yaml"; import { buildMcpBridgePolicyYaml } from "../../src/lib/actions/sandbox/mcp-bridge-policy-render"; -import type { SandboxPolicyAuthorityInspection } from "../../src/lib/adapters/openshell/policy-authority"; +import type { SandboxPolicyInspection } from "../../src/lib/adapters/openshell/policy-state"; import type { AgentConfigTarget } from "../../src/lib/sandbox/agent-config"; import type { SandboxEntry } from "../../src/lib/state/registry"; const shieldsModulePath = "./index.js"; -export const externalPolicyAuthorityInspection = { - authority: "externally-managed" as const, - effectivePolicy: { version: 1, network_policies: {} }, - policyIdentity: { hash: "external-policy-hash", activeVersion: 1 }, -}; - -const managedPolicyCreationReceipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: "nemoclaw", - gatewayPort: 50_065, - sandboxName: "openclaw", - lifecycleGeneration: "11111111-1111-4111-8111-111111111111", - sandboxIdentityFingerprint: "a".repeat(64), - policyHash: "managed-policy-hash", - policyVersion: 1, -}; - -export const managedPolicyMutationAuthority = { - authority: "nemoclaw-managed" as const, - authorityRecordedNow: false, +export const livePolicyMutationContext = { gatewayName: "nemoclaw", inspection: { - authority: "nemoclaw-managed" as const, + policySource: "sandbox" as const, effectivePolicy: { version: 1, network_policies: {} }, policyIdentity: { hash: "managed-policy-hash", activeVersion: 1 }, }, - policyCreationReceipt: managedPolicyCreationReceipt, }; -export function bindManagedPolicyMutationAuthority( +export function bindLivePolicyMutationContext( policy: typeof import("../../src/lib/policy"), ): MockInstance[] { return [ - vi - .spyOn(policy, "inspectPolicyMutationAuthority") - .mockReturnValue(managedPolicyMutationAuthority), - vi - .spyOn(policy, "inspectPolicyRecoveryAuthority") - .mockReturnValue(managedPolicyMutationAuthority), - vi - .spyOn(policy, "recheckPolicyMutationAuthority") - .mockReturnValue(managedPolicyMutationAuthority), - vi.spyOn(policy, "finalizePolicyMutationReceipt").mockImplementation(() => undefined), + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(livePolicyMutationContext), + vi.spyOn(policy, "inspectPolicyMutationContext").mockReturnValue(livePolicyMutationContext), + vi.spyOn(policy, "recheckPolicyMutationContext").mockReturnValue(livePolicyMutationContext), + vi.spyOn(policy, "verifyAppliedPolicyDocument").mockImplementation(() => undefined), ]; } @@ -70,8 +43,8 @@ export type ShieldsFlowHarness = { getShieldsPosture: typeof import("../../src/lib/shields/index.js").getShieldsPosture; getOpenClawPosture: () => "locked" | "mutable"; logSpy: MockInstance; - policyAuthoritySpy: MockInstance; - policyReceiptFinalizeSpy: MockInstance; + policyStateSpy: MockInstance; + policyVerificationSpy: MockInstance; policyRecoveryAuthoritySpy: MockInstance; policySetBodies: string[]; runCaptureSpy: MockInstance; @@ -106,7 +79,7 @@ export type ShieldsFlowHarnessOptions = { detail: string; }>; processStartIdentity?: string; - policyAuthorityInspection?: SandboxPolicyAuthorityInspection; + policyInspection?: SandboxPolicyInspection; timerAuthorizationOutcome?: "authorized" | "dies-before-proof"; timerDiesAfterUnlock?: boolean; fork?: (...args: unknown[]) => { @@ -135,7 +108,7 @@ export function managedMcpPolicy(server: string, address = "8.8.8.8") { const entries = Object.entries(YAML.parse(content).network_policies as Record); expect(entries, `rendered MCP policies for ${server}`).toHaveLength(1); const [key, networkPolicy] = entries[0]!; - return { content, key, networkPolicy, providerName, server }; + return { address, content, key, networkPolicy, providerName, server }; } export function managedMcpSandbox( @@ -144,14 +117,9 @@ export function managedMcpSandbox( return { name: "openclaw", openshellDriver: "docker", - customPolicies: policies.map(({ content, server }) => ({ - name: `mcp-bridge-${server}`, - content, - sourcePath: "generated:nemoclaw-mcp-bridge", - })), mcp: { bridges: Object.fromEntries( - policies.map(({ providerName, server }) => [ + policies.map(({ address, providerName, server }) => [ server, { server, @@ -159,6 +127,7 @@ export function managedMcpSandbox( adapter: "hermes-config", url: `https://${server}.example.com/mcp`, env: ["MCP_SECRET"], + allowedIps: [address], providerName, policyName: `mcp-bridge-${server}`, addedAt: "2026-07-30T00:00:00.000Z", @@ -213,7 +182,7 @@ export function createShieldsFlowHarness( delete require.cache[requireDist.resolve("./transition-lock.js")]; delete require.cache[requireDist.resolve("./permissive-runtime.js")]; delete require.cache[requireDist.resolve("../actions/sandbox/mcp-bridge-policy.js")]; - delete require.cache[requireDist.resolve("../adapters/openshell/policy-authority.js")]; + delete require.cache[requireDist.resolve("../adapters/openshell/policy-state.js")]; delete require.cache[requireDist.resolve("../sandbox/privileged-exec.js")]; delete require.cache[requireDist.resolve("../cli/branding.js")]; const timerControl = requireDist( @@ -275,9 +244,9 @@ export function createShieldsFlowHarness( const policy = requireDist("../policy/index.js"); const agentConfig = requireDist("../sandbox/agent-config.js"); const registry = requireDist("../state/registry.js"); - const policyAuthority = requireDist( - "../adapters/openshell/policy-authority.js", - ) as typeof import("../../src/lib/adapters/openshell/policy-authority.js"); + const policyState = requireDist( + "../adapters/openshell/policy-state.js", + ) as typeof import("../../src/lib/adapters/openshell/policy-state.js"); const privilegedExec = requireDist("../sandbox/privileged-exec.js"); const dockerExec = requireDist("../adapters/docker/exec.js"); const audit = requireDist("./audit.js"); @@ -377,44 +346,35 @@ export function createShieldsFlowHarness( vi.spyOn(agentConfig, "resolveAgentConfig").mockReturnValue(resolvedAgentConfig); vi.spyOn(registry, "getSandbox").mockReturnValue( options.sandboxEntry - ? { policyAuthority: "nemoclaw-managed", ...options.sandboxEntry } + ? { ...options.sandboxEntry } : { name: options.sandboxName ?? "openclaw", agent: resolvedAgentConfig.agentName, openshellDriver: "docker", - policyAuthority: "nemoclaw-managed", }, ); vi.spyOn(registry, "updateSandbox").mockReturnValue(true); - const policyAuthorityInspection = options.policyAuthorityInspection ?? { - authority: "nemoclaw-managed" as const, + const policyInspection = options.policyInspection ?? { + policySource: "sandbox" as const, effectivePolicy: YAML.parse( options.livePolicyYaml ?? "version: 1\nnetwork_policies:\n test: {}\n", ) as Record, policyIdentity: { hash: "managed-policy-hash", activeVersion: 1 }, }; - vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue( - policyAuthorityInspection, - ); + vi.spyOn(policyState, "inspectSandboxPolicy").mockReturnValue(policyInspection); const policyMutationAuthority = { - authority: policyAuthorityInspection.authority, - authorityRecordedNow: false, gatewayName: options.sandboxEntry?.gatewayName ?? "nemoclaw", - inspection: policyAuthorityInspection, - policyCreationReceipt: - policyAuthorityInspection.authority === "nemoclaw-managed" - ? managedPolicyCreationReceipt - : null, + inspection: policyInspection, }; - const policyAuthoritySpy = vi - .spyOn(policy, "inspectPolicyMutationAuthority") + const policyStateSpy = vi + .spyOn(policy, "inspectPolicyMutationContext") .mockReturnValue(policyMutationAuthority); const policyRecoveryAuthoritySpy = vi - .spyOn(policy, "inspectPolicyRecoveryAuthority") + .spyOn(policy, "inspectPolicyMutationContext") .mockReturnValue(policyMutationAuthority); - vi.spyOn(policy, "recheckPolicyMutationAuthority").mockReturnValue(policyMutationAuthority); - const policyReceiptFinalizeSpy = vi - .spyOn(policy, "finalizePolicyMutationReceipt") + vi.spyOn(policy, "recheckPolicyMutationContext").mockReturnValue(policyMutationAuthority); + const policyVerificationSpy = vi + .spyOn(policy, "verifyAppliedPolicyDocument") .mockImplementation(() => undefined); vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [{ name: options.sandboxName ?? "openclaw", agent: resolvedAgentConfig.agentName }], @@ -686,8 +646,8 @@ export function createShieldsFlowHarness( getShieldsPosture: shields.getShieldsPosture, getOpenClawPosture: () => openClawPosture, logSpy, - policyAuthoritySpy, - policyReceiptFinalizeSpy, + policyStateSpy, + policyVerificationSpy, policyRecoveryAuthoritySpy, policySetBodies, runCaptureSpy, diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index d7be9462f0c..e2e1cf82c27 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -51,8 +51,7 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ testsToRun: runTests("test/repository/github-actions-workflow-names.test.ts"), }, { - pattern: - /(?:^|\/)test\/helpers\/(?:onboard-fixture-contract\.json|onboard-script-mocks\.cjs)$/, + pattern: /(?:^|\/)test\/helpers\/(?:onboard-fixture-contract\.json|onboard-script-mocks\.cjs)$/, testsToRun: runTests( "test/helpers/onboard-created-sandbox-fixture.test.ts", "test/onboarding/onboard-custom-dockerfile.test.ts", @@ -215,6 +214,7 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)\.github\/workflows\/managed-images\.yaml$/, testsToRun: runTests( "test/inference/managed/managed-image-publication-workflow.test.ts", + "test/inference/managed/pi-candidate-pr-publication-workflow.test.ts", "test/e2e-runtime/pull-public-exact-digest.test.ts", ), }, diff --git a/test/inference/inference-set-config-read-exit.test.ts b/test/inference/inference-set-config-read-exit.test.ts index 816d5796e2a..39fdbdb5d25 100644 --- a/test/inference/inference-set-config-read-exit.test.ts +++ b/test/inference/inference-set-config-read-exit.test.ts @@ -45,7 +45,6 @@ describe("inference set sandbox configuration read failures", () => { gpuEnabled: false, model: "nvidia/llama-3.3-nemotron-super-49b-v1", name: SANDBOX, - policies: [], provider: "nvidia-prod", }, }, diff --git a/test/inference/managed/issue-5667-hosted-inference-model-namespace.test.ts b/test/inference/managed/issue-5667-hosted-inference-model-namespace.test.ts index 0de172c3838..d729a1f91f9 100644 --- a/test/inference/managed/issue-5667-hosted-inference-model-namespace.test.ts +++ b/test/inference/managed/issue-5667-hosted-inference-model-namespace.test.ts @@ -40,7 +40,6 @@ const { collectSandboxStatusSnapshot } = name: string; provider: string; model: string; - policies: string[]; agent: string; }; reconcile: () => Promise<{ state: string; output: string }>; @@ -275,7 +274,6 @@ const { setupNim } = require(${onboardPath}); name: "dcode-test", provider: payload.result.provider, model: payload.result.model, - policies: [], agent: "langchain-deepagents-code", }), reconcile: async () => ({ state: "missing", output: "" }), diff --git a/test/inference/managed/managed-image-publication-workflow.test.ts b/test/inference/managed/managed-image-publication-workflow.test.ts index 5d05e7f88b1..7507977ef1a 100644 --- a/test/inference/managed/managed-image-publication-workflow.test.ts +++ b/test/inference/managed/managed-image-publication-workflow.test.ts @@ -84,6 +84,7 @@ function managedPrActivation(workflow: Workflow): Job { function managedPrOpenClawMcpDiscovery(workflow: Workflow): Job { return required(workflow.jobs?.["pr-openclaw-mcp-discovery"], "missing exact PR MCP gate"); } + describe("complete managed-image publication workflow", () => { it("rejects managed package paths redirected outside node_modules", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-plugin-")); @@ -416,9 +417,7 @@ describe("complete managed-image publication workflow", () => { expect(step(prBuilder, "Checkout").with?.["persist-credentials"]).toBe(false); expect(step(prBuilder, "Checkout").with?.ref).toBe("${{ github.event.pull_request.head.sha }}"); expect(releaseIdentity.id).toBe("release"); - expect(releaseIdentity.run).toContain( - "git describe --tags --match 'v*' \"$CANDIDATE_SHA\"", - ); + expect(releaseIdentity.run).toContain("git describe --tags --match 'v*' \"$CANDIDATE_SHA\""); expect(releaseIdentity.run).toContain("value=%s"); expect(step(prBuilder, "Set up Docker Buildx").id).toBe("buildx"); const matrixByAgent = new Map(matrix.map((entry) => [entry.agent, entry])); diff --git a/test/inference/managed/pi-candidate-pr-publication-workflow.test.ts b/test/inference/managed/pi-candidate-pr-publication-workflow.test.ts new file mode 100644 index 00000000000..4c3fae0eb75 --- /dev/null +++ b/test/inference/managed/pi-candidate-pr-publication-workflow.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { readWorkflow, required, step } from "../../helpers/managed-image-publication-workflow"; + +describe("Pi candidate pull-request publication", () => { + it("publishes same-repository candidates by digest without entering the release cohort", () => { + const workflow = readWorkflow("managed-images.yaml"); + const candidate = required( + workflow.jobs?.["pi-candidate"], + "managed-image workflow is missing its same-repository PR Pi candidate publisher", + ); + const production = required( + workflow.jobs?.["pi-candidate-publish"], + "managed-image workflow is missing its main and tag Pi candidate publisher", + ); + const sourceRevision = + "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}"; + const steps = candidate.steps ?? []; + const login = step(candidate, "Log in to GHCR"); + const publish = step(candidate, "Publish the Pi candidate image by digest"); + const logout = step(candidate, "Remove Pi publication credentials"); + const validate = step(candidate, "Validate the published Pi candidate digest"); + const exercise = step(candidate, "Exercise the Pi candidate through its declared entrypoint"); + const record = step(candidate, "Record the exact Pi candidate contract"); + const upload = step(candidate, "Upload the exact Pi candidate contract"); + + expect(workflow.on?.pull_request?.paths).toEqual( + expect.arrayContaining([ + "ci/pi-agent-qualification-v1-*.json", + "src/lib/agent/candidate-authority.ts", + ]), + ); + expect(candidate.if).toBe( + "github.repository == 'NVIDIA/NemoClaw' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository", + ); + expect(candidate.permissions).toEqual({ contents: "read", packages: "write" }); + expect(candidate.env?.SOURCE_REVISION).toBe(sourceRevision); + expect(candidate.env?.LOCAL_BASE_REFERENCE).toBe( + `nemoclaw-managed-candidate/pi-base:${sourceRevision}`, + ); + expect(step(candidate, "Checkout").with).toMatchObject({ + ref: sourceRevision, + "persist-credentials": false, + }); + expect(step(candidate, "Build the Pi candidate managed image").with).toMatchObject({ + tags: "nemoclaw-managed-candidate/pi:${{ env.SOURCE_REVISION }}", + push: false, + load: true, + }); + expect(login.if).toBeUndefined(); + expect(publish.if).toBeUndefined(); + expect(publish.with).toMatchObject({ + outputs: + "type=image,name=ghcr.io/nvidia/nemoclaw/pi-sandbox,push-by-digest=true,name-canonical=true,push=true", + platforms: "${{ matrix.platform }}", + provenance: false, + sbom: false, + }); + expect(publish.with?.tags).toBeUndefined(); + expect(publish.with?.labels).toContain( + "org.opencontainers.image.revision=${{ env.SOURCE_REVISION }}", + ); + expect(logout).toMatchObject({ if: "always()", run: "docker logout ghcr.io" }); + expect(steps.indexOf(logout)).toBeGreaterThan(steps.indexOf(publish)); + expect(steps.indexOf(logout)).toBeLessThan(steps.indexOf(validate)); + expect(validate.if).toBeUndefined(); + expect(exercise.env).toEqual({ DIGEST: "${{ steps.publish.outputs.digest }}" }); + expect(exercise.run).toContain('reference="${REPOSITORY}@${DIGEST}"'); + expect(exercise.run).not.toContain("EVENT_NAME"); + expect(record.if).toBeUndefined(); + expect(record.run).toContain('--arg revision "$SOURCE_REVISION"'); + expect(record.run).not.toContain('--arg revision "$GITHUB_SHA"'); + expect(upload.if).toBeUndefined(); + expect(upload.with).toMatchObject({ + name: "managed-candidate-contract-${{ github.run_id }}-${{ github.run_attempt }}-pi-${{ matrix.arch }}", + "if-no-files-found": "error", + "retention-days": 7, + }); + expect(JSON.stringify(candidate)).not.toContain("managed-pr-contract-"); + expect(JSON.stringify(candidate).match(/secrets\.GITHUB_TOKEN/gu)).toHaveLength(1); + expect(production.if).toBe( + "github.repository == 'NVIDIA/NemoClaw' && github.event_name != 'pull_request'", + ); + expect(production.permissions).toEqual({ contents: "read", packages: "write" }); + }); +}); diff --git a/test/installer-integration/install-hermes-portable-active.test.ts b/test/installer-integration/install-hermes-portable-active.test.ts index 324b96be79c..7dedcdf040c 100644 --- a/test/installer-integration/install-hermes-portable-active.test.ts +++ b/test/installer-integration/install-hermes-portable-active.test.ts @@ -20,7 +20,7 @@ import { runHermesPortableOnboardingTransaction } from "../../src/lib/onboard/ex import { getHermesPortableSandboxRuntimeRegistryFields } from "../../src/lib/onboard/sandbox-registry-metadata"; import { resolveSandboxGpuConfig } from "../../src/lib/onboard/sandbox-gpu-mode"; import { completeHermesPortableSandboxRegistration } from "../../src/lib/onboard/sandbox-create/orchestration"; -import { pendingSandboxPolicyVerificationForBoundary } from "../../src/lib/onboard/sandbox-create/policy-creation-receipt"; +import { pendingSandboxCreateIdentityForBoundary } from "../../src/lib/onboard/sandbox-create/identity-boundary"; import { materializeHermesPortableCreatePlan } from "../../src/lib/onboard/sandbox-create-plan-materialization"; import { resolveSandboxCreateIntent } from "../../src/lib/onboard/sandbox-create-intent"; import { createPortableOnboardEnvironmentScope } from "../../src/lib/onboard/session-bootstrap"; @@ -113,7 +113,7 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = }, ); - it("activates one schema-5 receipt from a private checkout and validates both installer sources (#9211)", async () => { + it("activates one schema-7 receipt from a private checkout and validates both installer sources (#9211)", async () => { const fixtureRoot = createPrivateFixtureRoot(); const stateDir = path.join(fixtureRoot, "state"); const homeDir = path.join(fixtureRoot, "home"); @@ -243,12 +243,10 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = gpuRoutePlan: "none", sandboxGpuLogMessage: null, agentName: "hermes", - policyTier: null, }); const createPlan = materializeHermesPortableCreatePlan({ intent, fromRef: activeBuildContext.sourceDockerfilePath, - policyAuthority: "nemoclaw-managed", }); const startupArgv = [ "env", @@ -279,23 +277,7 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = startup: { agent: loadAgent("hermes"), sandboxName, startupArgv }, inferenceRouteReservation: { sessionId: session.sessionId, selection }, }; - const policyCreationReceipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName, - gatewayPort: 8080, - sandboxName, - lifecycleGeneration, - sandboxIdentityFingerprint: HERMES_PORTABLE_TEST_LIVE_IDENTITY, - policyHash: "sha256:portable-installer-policy", - policyVersion: 1, - }; - const checkpoint = pendingSandboxPolicyVerificationForBoundary({ - registration: { - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt, - observedPolicyAuthority: "owner-unknown" as const, - }, + const checkpoint = pendingSandboxCreateIdentityForBoundary({ sandboxName, gatewayName, gatewayPort: 8080, @@ -311,11 +293,11 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = expect(buildContextPath).toContain(path.join(stateDir, "hermes-portable-build-context")); expect(argv[argv.indexOf("--from") + 1]).toBe(path.join(buildContextPath, "Dockerfile")); expect(argv[argv.indexOf("--policy") + 1]).not.toBe(basePolicyPath); - registry.recordPendingSandboxPolicyVerification(createReservation, checkpoint); + registry.recordPendingSandboxCreateIdentity(createReservation, checkpoint); return { ready: true }; }, revalidatePendingCreateRegistry: () => - registry.requireCurrentPendingSandboxPolicyVerification(createReservation, checkpoint), + registry.requireCurrentPendingSandboxCreateIdentity(createReservation, checkpoint), registerSandbox: async ( _created, receipt, @@ -325,7 +307,7 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = ) => { expect(revalidate()).toBe(liveIdentityFingerprint); expect(reservation.authority).toEqual(createReservation.authority); - registry.requireCurrentPendingSandboxPolicyVerification(createReservation, checkpoint); + registry.requireCurrentPendingSandboxCreateIdentity(createReservation, checkpoint); return completeHermesPortableSandboxRegistration({ sandboxName, completeRegistration: async () => { @@ -339,7 +321,6 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = agent: loadAgent("hermes"), agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, @@ -349,8 +330,6 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = lifecycleLiveIdentityFingerprint: liveIdentityFingerprint, gatewayName, gatewayPort: 8080, - policyAuthority: "nemoclaw-managed", - policyCreationReceipt, inferenceRouteReservation: createReservation, verifiedCreate: { reservation: createReservation, checkpoint }, }); @@ -366,7 +345,7 @@ describe("Hermes portable installer admission", testTimeoutOptions(60_000), () = ); expect(completed).toMatchObject({ created: true, - active: { receipt: { schemaVersion: 5, phase: "active", sandboxName } }, + active: { receipt: { schemaVersion: 7, phase: "active", sandboxName } }, }); const registered = registry.getSandbox(sandboxName) as SandboxEntry; expect(registered).toMatchObject({ diff --git a/test/mcp/mcp-add-crash-consistency.test.ts b/test/mcp/mcp-add-crash-consistency.test.ts index ba990bb7549..f31cfb26bdb 100644 --- a/test/mcp/mcp-add-crash-consistency.test.ts +++ b/test/mcp/mcp-add-crash-consistency.test.ts @@ -88,6 +88,17 @@ const registry = require("./src/lib/state/registry.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); +const runner = require("./src/lib/runner.js"); +runner.runCapture = (args) => + Array.isArray(args) && args[0] === "policy" && args[1] === "get" + ? marked("policy") + ? "version: 1\nnetwork_policies:\n mcp_bridge_fake: {}\n" + : "version: 1\nnetwork_policies: {}\n" + : ""; +runner.run = (args) => { + if (Array.isArray(args) && args[0] === "policy" && args[1] === "set") mark("policy"); + return { status: 0, stdout: "", stderr: "" }; +}; const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); const ownershipLocks = require("./src/lib/state/mcp-lifecycle-lock/credential-ownership.js"); @@ -967,7 +978,6 @@ describe("MCP add crash consistency", () => { expect(rejected.status, `${rejected.stdout}\n${rejected.stderr}`).toBe(2); expect(rejected.stderr).toContain("Failed to activate generated MCP policy"); - expect(rejected.stderr).toContain("effective state: drift"); expect(`${rejected.stdout}\n${rejected.stderr}`).not.toContain("host-only-secret"); expect(fs.existsSync(path.join(home, "provider.marker"))).toBe(false); expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(false); @@ -977,15 +987,9 @@ describe("MCP add crash consistency", () => { const registry = JSON.parse( fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), ) as { - sandboxes: { "crash-test": { customPolicies?: Array<{ name: string }> } }; + sandboxes: { "crash-test": Record }; }; - expect(registry.sandboxes["crash-test"].customPolicies).toEqual([ - expect.objectContaining({ - name: "mcp-bridge-fake", - content: expect.any(String), - sourcePath: "generated:nemoclaw-mcp-bridge", - }), - ]); + expect(registry.sandboxes["crash-test"]).not.toHaveProperty("customPolicies"); } finally { fs.rmSync(home, { recursive: true, force: true }); } @@ -998,7 +1002,6 @@ describe("MCP add crash consistency", () => { expect(rejected.status, `${rejected.stdout}\n${rejected.stderr}`).toBe(2); expect(rejected.stderr).toContain("Failed to activate generated MCP policy"); - expect(rejected.stderr).toContain("effective state: absent"); expect(fs.existsSync(path.join(home, "policy.marker"))).toBe(false); expect(fs.existsSync(path.join(home, "provider.marker"))).toBe(false); expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(false); @@ -1007,9 +1010,9 @@ describe("MCP add crash consistency", () => { const registry = JSON.parse( fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), ) as { - sandboxes: { "crash-test": { customPolicies?: Array<{ name: string }> } }; + sandboxes: { "crash-test": Record }; }; - expect(registry.sandboxes["crash-test"].customPolicies).toBeUndefined(); + expect(registry.sandboxes["crash-test"]).not.toHaveProperty("customPolicies"); } finally { fs.rmSync(home, { recursive: true, force: true }); } @@ -1222,7 +1225,7 @@ describe("MCP add crash consistency", () => { expect(status.addState).toBe("prepared"); expect(status.policy).toEqual({ name: "mcp-bridge-fake", - registryPresent: false, + registryPresent: true, gatewayPresent: null, }); diff --git a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts index b98fe63ea9c..92e693793de 100644 --- a/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts +++ b/test/mcp/mcp-bridge-destroy-marker-recovery.test.ts @@ -312,18 +312,12 @@ registry.registerSandbox({ destroyPreparedAt: "2026-06-27T01:00:00.000Z", }, }); -registry.addCustomPolicy("stuck-sandbox", { - name: entry.policyName, - content: "network_policies: {}", - sourcePath: "generated:nemoclaw-mcp-bridge", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); bridge.removeMcpBridge("stuck-sandbox", "github", { force: true }).then( () => { const after = registry.getSandbox("stuck-sandbox"); process.stdout.write("<>" + JSON.stringify({ mcp: after && after.mcp, - customPolicies: after && after.customPolicies || [], events, commands, providerExists, @@ -350,7 +344,6 @@ bridge.removeMcpBridge("stuck-sandbox", "github", { force: true }).then( result.stdout.slice(result.stdout.indexOf(jsonMarker) + jsonMarker.length), ) as { mcp: SandboxMcpSnapshot | undefined; - customPolicies: unknown[]; events: string[]; commands: string[]; providerExists: boolean; @@ -360,7 +353,6 @@ bridge.removeMcpBridge("stuck-sandbox", "github", { force: true }).then( expect(parsed.attached).toBe(false); expect(parsed.providerExists).toBe(false); expect(parsed.policyState).toBe("absent"); - expect(parsed.customPolicies).toEqual([]); expect(parsed.mcp?.bridges).toEqual({}); expect(parsed.mcp?.managedServerNames).toEqual(["github"]); expect(parsed.mcp?.destroyPreparedAt).toBeUndefined(); diff --git a/test/mcp/mcp-destroy-lifecycle.test.ts b/test/mcp/mcp-destroy-lifecycle.test.ts index 1b86f0fc95f..8bd10be0537 100644 --- a/test/mcp/mcp-destroy-lifecycle.test.ts +++ b/test/mcp/mcp-destroy-lifecycle.test.ts @@ -6,7 +6,6 @@ import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentMcpAdapter } from "../../src/lib/agent/defs"; import type { McpBridgeEntry } from "../../src/lib/state/registry"; import { findObservedCredentialRevision } from "../helpers/mcp-provider-revision"; import { mockManagedEndpointlessProviderProfileRun } from "../helpers/onboard-script-mocks.cjs"; @@ -33,6 +32,7 @@ const testState = vi.hoisted(() => { executeSandboxExecCommand: vi.fn(), failProviderDelete: null as string | null, failProviderDetach: null as string | null, + getSandboxPolicy: vi.fn(), getLiveSandboxPolicyEntryDigest: vi.fn(), getPresetContentGatewayState: vi.fn(), home, @@ -73,7 +73,8 @@ vi.mock("../../src/lib/gateway-runtime-action", () => ({ recoverNamedGatewayRuntime: testState.recoverNamedGatewayRuntime, })); -vi.mock("../../src/lib/policy", () => ({ +vi.mock("../../src/lib/policy", async (importOriginal) => ({ + ...(await importOriginal()), applyPresetContent: testState.applyPresetContent, getLiveSandboxPolicyEntryDigest: testState.getLiveSandboxPolicyEntryDigest, getPresetContentGatewayState: testState.getPresetContentGatewayState, @@ -86,6 +87,10 @@ vi.mock("../../src/lib/actions/sandbox/process-recovery", () => ({ executeSandboxExecCommand: testState.executeSandboxExecCommand, })); +vi.mock("../../src/lib/actions/sandbox/policy-get", () => ({ + getSandboxPolicy: testState.getSandboxPolicy, +})); + vi.mock("../../src/lib/actions/sandbox/rebuild-flow-helpers", async (importOriginal) => ({ ...(await importOriginal()), warnUnpreservedUserManagedFiles: testState.warnUnpreservedUserManagedFiles, @@ -97,7 +102,6 @@ vi.mock("../../src/lib/inference/nim", () => ({ })); import * as bridge from "../../src/lib/actions/sandbox/mcp-bridge"; -import { isAgentMcpAdapter } from "../../src/lib/actions/sandbox/mcp-bridge-contracts"; import { runRebuildDestroyPhase } from "../../src/lib/actions/sandbox/rebuild-destroy-phase"; import type { RebuildRecreateJournal } from "../../src/lib/actions/sandbox/rebuild-recreate-journal"; import * as registry from "../../src/lib/state/registry"; @@ -135,6 +139,7 @@ const bridgeEntries: Record<"github" | "slack", McpBridgeEntry> = { adapter: "mcporter", url: "https://8.8.8.8/github", env: ["GITHUB_TOKEN"], + allowedIps: ["8.8.8.8"], providerName: "alpha-mcp-github", providerId: "11111111-2222-4333-8444-555555555555", policyName: "mcp-bridge-github", @@ -146,32 +151,13 @@ const bridgeEntries: Record<"github" | "slack", McpBridgeEntry> = { adapter: "mcporter", url: "https://8.8.8.8/slack", env: ["SLACK_TOKEN"], + allowedIps: ["8.8.8.8"], providerName: "alpha-mcp-slack", providerId: "66666666-7777-4888-8999-000000000000", policyName: "mcp-bridge-slack", addedAt: "2026-06-27T00:00:00.000Z", }, }; -function ownedPolicy( - server: "github" | "slack", - options: { - adapter?: AgentMcpAdapter; - entry?: McpBridgeEntry; - resolvedAddresses?: readonly string[]; - } = {}, -) { - const entry = options.entry ?? bridgeEntries[server]; - const adapter = options.adapter ?? entry.adapter; - expect(isAgentMcpAdapter(adapter), "MCP policy fixture requires an explicit adapter").toBe(true); - const resolvedAddresses = options.resolvedAddresses ?? [new URL(entry.url).hostname]; - return { - name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml(entry.server, entry.url, adapter as AgentMcpAdapter, { - addresses: [...resolvedAddresses], - }, entry.providerName ?? ""), - sourcePath: "generated:nemoclaw-mcp-bridge", - }; -} function restoreEnv(name: string, value: string | undefined): void { switch (value) { case undefined: @@ -196,7 +182,6 @@ function registerAlphaGithubBridge(): void { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); } beforeEach(() => { fs.rmSync(testState.home, { recursive: true, force: true }); @@ -251,6 +236,18 @@ beforeEach(() => { testState.removedPolicyKeys.add(policyName.replaceAll("-", "_")); return true; }); + testState.getSandboxPolicy.mockImplementation(() => { + const entries = ["mcp_bridge_github", "mcp_bridge_slack"].filter( + (key) => !testState.removedPolicyKeys.has(key), + ); + return { + raw: "", + yaml: + entries.length === 0 + ? "version: 1\nnetwork_policies: {}\n" + : `version: 1\nnetwork_policies:\n${entries.map((key) => ` ${key}: {}`).join("\n")}\n`, + }; + }); testState.runOpenshell.mockReturnValue({ status: 0, stdout: "", stderr: "" }); testState.resolveHostAddresses.mockImplementation(async (host: string) => [{ address: host }]); testState.runOpenshellProviderCommand.mockImplementation((args: string[]) => { @@ -260,7 +257,14 @@ beforeEach(() => { return { status: 0, stdout: "ready", stderr: "" }; } switch (true) { - case args[0] === "provider" && args[1] === "profile": return mockManagedEndpointlessProviderProfileRun(args) ?? { status: 0, stdout: "Imported provider profile", stderr: "" }; + case args[0] === "provider" && args[1] === "profile": + return ( + mockManagedEndpointlessProviderProfileRun(args) ?? { + status: 0, + stdout: "Imported provider profile", + stderr: "", + } + ); case args[0] === "provider" && args[1] === "get": { const provider = testState.providers.get(args[2]); return provider @@ -306,8 +310,12 @@ beforeEach(() => { case args[0] === "sandbox" && args[1] === "provider" && args[2] === "attach": testState.attachedProviders.add(args[4]); return { status: 0, stdout: "Attached provider", stderr: "" }; - case args[0] === "provider" && args[1] === "update" && args.length === 3 && testState.providers.has(args[2]): - testState.providers.get(args[2])!.resourceVersion = (testState.providers.get(args[2])!.resourceVersion ?? 1) + 1; + case args[0] === "provider" && + args[1] === "update" && + args.length === 3 && + testState.providers.has(args[2]): + testState.providers.get(args[2])!.resourceVersion = + (testState.providers.get(args[2])!.resourceVersion ?? 1) + 1; return { status: 0, stdout: "Updated provider", stderr: "" }; case args[0] === "provider" && args[1] === "delete" && @@ -390,7 +398,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { agent: "openclaw", mcp: { bridges: { github: pending } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); testState.getPresetContentGatewayState.mockImplementation(() => { throw new Error("absent rebuild queried live policy"); }); @@ -400,7 +407,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(preparation.entries).toEqual([]); expect(sandbox?.mcp).toBeUndefined(); - expect(sandbox?.customPolicies).toBeUndefined(); }); it.each([ @@ -418,7 +424,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { [marker]: "2026-07-02T22:49:42.000Z", }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const message = await captureMessage(() => bridge[method]("alpha")); const sandbox = registry.getSandbox("alpha"); @@ -485,7 +490,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: pending } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const before = registry.getSandbox("alpha"); const message = await captureMessage(() => @@ -511,10 +515,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: missingAdapter } }, }); - registry.addCustomPolicy( - "alpha", - ownedPolicy("github", { adapter: "mcporter", entry: missingAdapter }), - ); const before = registry.getSandbox("alpha"); const message = await captureMessage(() => @@ -544,7 +544,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: crossAgentEntry } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github", { entry: crossAgentEntry })); const before = registry.getSandbox("alpha"); const message = await captureMessage(() => @@ -610,8 +609,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { }, }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); - registry.addCustomPolicy("alpha", ownedPolicy("slack", { entry: collidingSlack })); const before = registry.getSandbox("alpha"); const message = await captureMessage(() => @@ -631,24 +628,20 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { }, ); - it("rejects live policy drift during exec-unavailable recovery without MCP mutations (#7062)", async () => { + it("accepts externally edited live policy during exec-unavailable recovery (#7062)", async () => { registry.registerSandbox({ name: "alpha", agent: "openclaw", gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const before = registry.getSandbox("alpha"); testState.getPresetContentGatewayState.mockReturnValue("drift"); - const message = await captureMessage(() => - bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"), - ); + const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); - expect(message).toMatch(/policy.*drifted.*host-side rebuild recovery/i); + expect(preparation.entries).toEqual([bridgeEntries.github]); expect(registry.getSandbox("alpha")).toEqual(before); - expect(testState.calls).toEqual([]); expect(testState.adapterCalls).toEqual([]); expect(testState.applyPresetContent).not.toHaveBeenCalled(); expect(testState.removePreset).not.toHaveBeenCalled(); @@ -682,31 +675,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(testState.removePreset).not.toHaveBeenCalled(); }); - it("does not reconcile an incomplete policy registration during read-only recovery (#7062)", async () => { - registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: { github: bridgeEntries.github } }, - }); - registry.addCustomPolicy("alpha", { - ...ownedPolicy("github"), - pendingContent: "network_policies: {}\n", - }); - const before = registry.getSandbox("alpha"); - - const message = await captureMessage(() => - bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"), - ); - - expect(message).toMatch(/incomplete registry transition.*read-only/i); - expect(registry.getSandbox("alpha")).toEqual(before); - expect(testState.calls).toEqual([]); - expect(testState.adapterCalls).toEqual([]); - expect(testState.applyPresetContent).not.toHaveBeenCalled(); - expect(testState.removePreset).not.toHaveBeenCalled(); - }); - it("skips provider inspection for empty managed MCP recovery state (#9388)", async () => { registry.registerSandbox({ name: "alpha", @@ -730,8 +698,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github, slack: bridgeEntries.slack } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); - registry.addCustomPolicy("alpha", ownedPolicy("slack")); const before = registry.getSandbox("alpha"); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); @@ -755,35 +721,29 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { "provider get alpha-mcp-slack", ]); expect(testState.recoverNamedGatewayRuntime).toHaveBeenCalledTimes(2); - expect(testState.getPresetContentGatewayState).toHaveBeenCalledTimes(4); + expect(testState.getPresetContentGatewayState).not.toHaveBeenCalled(); expect(testState.adapterCalls).toEqual([]); expect(testState.applyPresetContent).not.toHaveBeenCalled(); expect(testState.removePreset).not.toHaveBeenCalled(); }); - it("fails the delete-edge proof when live MCP policy drifts after host preflight (#7062)", async () => { + it("does not treat host policy edits as bridge drift at the delete edge (#7062)", async () => { registry.registerSandbox({ name: "alpha", agent: "openclaw", gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); const before = registry.getSandbox("alpha"); testState.getPresetContentGatewayState.mockReturnValue("drift"); const message = await captureMessage(async () => preparation.revalidateBeforeDelete?.()); - expect(message).toMatch(/policy.*drifted.*host-side rebuild recovery/i); + expect(message).toBe(""); expect(registry.getSandbox("alpha")).toEqual(before); expect(testState.adapterCalls).toEqual([]); - expect(testState.calls).toEqual([ - "provider get alpha-mcp-github", - "sandbox provider list alpha", - "provider get alpha-mcp-github", - "provider get alpha-mcp-slack", - ]); + expect(testState.runOpenshellProviderCommand).toHaveBeenCalled(); }); it("rejects a credential-key collision added after host-side rebuild preflight (#9388)", async () => { @@ -793,7 +753,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); testState.providers.set("example-api", { credential: "GITHUB_TOKEN", @@ -824,7 +783,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); const before = registry.getSandbox("alpha"); testState.providers.set("alpha-mcp-github", { @@ -853,7 +811,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); const before = registry.getSandbox("alpha"); testState.providers.set("alpha-mcp-github", { @@ -895,16 +852,12 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: dnsEntry } }, }); - registry.addCustomPolicy( - "alpha", - ownedPolicy("github", { entry: dnsEntry, resolvedAddresses: ["8.8.8.8"] }), - ); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); const before = registry.getSandbox("alpha"); const message = await captureMessage(async () => preparation.revalidateBeforeDelete?.()); - expect(message).toMatch(/not canonical for its recorded bridge definition/i); + expect(message).toMatch(/changed after host-side rebuild preflight/i); expect(registry.getSandbox("alpha")).toEqual(before); expect(testState.resolveHostAddresses).toHaveBeenNthCalledWith(1, "mcp.example.com"); expect(testState.resolveHostAddresses).toHaveBeenNthCalledWith(2, "mcp.example.com"); @@ -918,7 +871,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); const before = registry.getSandbox("alpha"); registry.updateSandbox("alpha", { @@ -943,7 +895,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); const before = registry.getSandbox("alpha"); registry.updateSandbox("alpha", { @@ -968,7 +919,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); registry.updateSandbox("alpha", { gatewayPort: 19080 }); @@ -986,7 +936,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForExecUnavailableRebuild("alpha"); registry.updateSandbox("alpha", { agent: "hermes" }); @@ -1007,7 +956,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { nimContainer: "nim-alpha", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const before = registry.getSandbox("alpha"); testState.executeSandboxExecCommand.mockReturnValue(null); testState.runOpenshell.mockImplementation((args: string[]) => @@ -1055,7 +1003,7 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(testState.stopNimContainer).not.toHaveBeenCalled(); expect(testState.stopNimContainerByName).toHaveBeenCalledWith("nim-alpha"); expect(testState.runOpenshellProviderCommand).toHaveBeenCalledTimes(8); - expect(testState.getPresetContentGatewayState).toHaveBeenCalledTimes(2); + expect(testState.getPresetContentGatewayState).not.toHaveBeenCalled(); expect(testState.recoverNamedGatewayRuntime).toHaveBeenCalledTimes(2); expect(testState.executeSandboxExecCommand.mock.invocationCallOrder[0]).toBeLessThan( testState.runOpenshellProviderCommand.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, @@ -1084,7 +1032,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { nimContainer: "nim-alpha", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const beforeRegistry = registry.getSandbox("alpha"); const beforeProviders = [...testState.providers.entries()]; const beforeAttachments = [...testState.attachedProviders]; @@ -1128,15 +1075,34 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(onDeleted).not.toHaveBeenCalled(); }); - it("rejects policy drift before prepareMcpBridgesForRebuild mutates adapter or provider state", async () => { + it("removes the generated key during rebuild even when its live content was edited", async () => { registerAlphaGithubBridge(); testState.getPresetContentGatewayState.mockReturnValue("drift"); - const message = await captureMessage(() => bridge.prepareMcpBridgesForRebuild("alpha")); + const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); - expect(message).toMatch(/policy.*drift/i); - expect(testState.calls).toEqual([]); - expect(testState.adapterCalls).toEqual([]); + expect(preparation.entries).toEqual([bridgeEntries.github]); + expect(preparation.policyHandoff).toContain("mcp_bridge_github"); + expect(testState.removePreset).toHaveBeenCalledWith( + "alpha", + "mcp-bridge-github", + expect.objectContaining({ presetContent: expect.any(String) }), + ); + await expect(preparation.revalidateBeforeDelete?.()).resolves.toBeUndefined(); + }); + + it("rejects a host policy edit that lands after the bounded rebuild handoff", async () => { + registerAlphaGithubBridge(); + + const preparation = await bridge.prepareMcpBridgesForRebuild("alpha"); + testState.getSandboxPolicy.mockReturnValue({ + raw: "", + yaml: "version: 1\nnetwork_policies:\n concurrent_host_edit: {}\n", + }); + + await expect(preparation.revalidateBeforeDelete?.()).rejects.toThrow( + /OpenShell policy changed while preparing MCP teardown/u, + ); }); it("rejects a credential-key collision before rebuild changes MCP state (#9388)", async () => { @@ -1146,7 +1112,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); testState.providers.set("example-api", { credential: "GITHUB_TOKEN", id: "99999999-8888-4777-8666-555555555555", @@ -1171,31 +1136,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(testState.removePreset).not.toHaveBeenCalled(); }); - it("rejects an unowned same-name policy record during absent-sandbox rebuild", async () => { - registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - gatewayName: "nemoclaw", - mcp: { bridges: { github: bridgeEntries.github } }, - }); - registry.addCustomPolicy("alpha", { - ...ownedPolicy("github"), - content: "operator-owned-content", - sourcePath: "/operator/policy.yaml", - }); - testState.getPresetContentGatewayState.mockImplementation(() => { - throw new Error("absent rebuild queried live policy"); - }); - - const message = await captureMessage(() => - bridge.prepareMcpBridgesForAbsentSandboxRebuild("alpha"), - ); - - expect(message).toMatch(/unowned same-name registry record/); - expect(testState.calls).toEqual([]); - expect(testState.adapterCalls).toEqual([]); - }); - it("finalizes an externally absent sandbox without attempting sandbox adapter exec", async () => { registry.registerSandbox({ name: "alpha", @@ -1205,7 +1145,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { managedServerNames: ["github", "retired"], }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForAbsentSandboxDestroy("alpha"); await bridge.finalizeMcpBridgesAfterSandboxDelete("alpha", preparation); @@ -1216,7 +1155,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(testState.calls.some((call) => call.includes("sandbox provider"))).toBe(false); expect([...testState.providers.keys()]).not.toContain("alpha-mcp-github"); expect(sandbox?.mcp).toBeUndefined(); - expect(sandbox?.customPolicies).toBeUndefined(); }); it("restores policy, attachment, and adapter without rotating an exported host secret", async () => { @@ -1229,7 +1167,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { managedServerNames: ["github", "retired"], }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); await bridge.restoreMcpBridgesAfterDestroyAbort("alpha", preparation); @@ -1239,10 +1176,14 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect([...testState.providers.keys()]).toContain("alpha-mcp-github"); expect(testState.calls).toContain("sandbox provider attach alpha alpha-mcp-github"); expect(testState.providers.get("alpha-mcp-github")?.resourceVersion).toBe(2); - expect(testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call))).toBe(false); + expect( + testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call)), + ).toBe(false); expect(testState.policyApplyCalls).toBe(2); expect(testState.adapterCalls).toContain("command -v mcporter"); - expect(testState.adapterCalls.some((call) => call.includes("openshell:resolve:env:GITHUB_TOKEN"))).toBe(true); + expect( + testState.adapterCalls.some((call) => call.includes("openshell:resolve:env:GITHUB_TOKEN")), + ).toBe(true); expect(sandbox?.mcp?.bridges).toHaveProperty("github"); expect(sandbox?.mcp?.managedServerNames).toEqual(["github", "retired"]); expect(sandbox?.mcp?.destroyPreparedAt).toBeUndefined(); @@ -1258,7 +1199,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { managedServerNames: ["github", "retired"], }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); testState.applyPresetContent.mockReturnValue(false); @@ -1281,8 +1221,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { agent: "openclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); - registry.addCustomPolicy("alpha", { name: "operator", content: "version: 1\n" }); const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); const afterPrepare = registry.getSandbox("alpha"); @@ -1292,9 +1230,7 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(afterPrepare?.mcp?.bridges).toHaveProperty("github"); expect(afterPrepare?.mcp?.destroyPreparedAt).toBeTruthy(); expect(afterPrepare?.mcp?.destroyPendingAt).toBeUndefined(); - expect(afterPrepare?.customPolicies?.map((policy) => policy.name)).toEqual(["operator"]); expect(afterFinalize?.mcp).toBeUndefined(); - expect(afterFinalize?.customPolicies?.map((policy) => policy.name)).toEqual(["operator"]); expect([...testState.providers.keys()]).not.toContain("alpha-mcp-github"); expect( testState.calls.some((call) => call === "sandbox provider detach alpha alpha-mcp-github"), @@ -1313,16 +1249,17 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { agent: "openclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); await bridge.restoreMcpBridgesAfterRebuild("alpha", [bridgeEntries.github]); expect(process.env.GITHUB_TOKEN).toBe("ambient-value-that-must-not-rotate"); expect(testState.providers.get("alpha-mcp-github")?.resourceVersion).toBe(2); - expect(testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call))).toBe(false); + expect( + testState.calls.some((call) => /^provider (create|update) .*--credential/.test(call)), + ).toBe(false); expect([...testState.attachedProviders]).toContain("alpha-mcp-github"); expect(testState.adapterRegistered).toBe(true); - expect(testState.policyApplyCalls).toBe(2); + expect(testState.policyApplyCalls).toBe(0); }); it.each([ @@ -1337,8 +1274,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: bridgeEntries }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); - registry.addCustomPolicy("alpha", ownedPolicy("slack")); // The prior process died after the first detach, so retry cannot prove // the opaque credential revision needed to scrub and later restore it. testState.attachedProviders.delete("alpha-mcp-github"); @@ -1366,8 +1301,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { gatewayName: "nemoclaw", mcp: { bridges: bridgeEntries }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); - registry.addCustomPolicy("alpha", ownedPolicy("slack")); // Preparation proves both credential revisions before detaching either // provider, then sandbox deletion is modeled as failed by invoking abort. @@ -1400,8 +1333,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { managedServerNames: ["github", "retired", "slack"], }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); - registry.addCustomPolicy("alpha", ownedPolicy("slack")); const preparation = await bridge.prepareMcpBridgesForDestroy("alpha"); testState.failProviderDelete = "alpha-mcp-slack"; @@ -1419,10 +1350,8 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { expect(afterFailure?.mcp?.destroyPreparedAt).toBeUndefined(); expect(afterFailure?.mcp?.managedServerNames).toEqual(["github", "retired", "slack"]); expect(Object.keys(afterFailure?.mcp?.bridges ?? {})).toEqual(["github", "slack"]); - expect(afterFailure?.customPolicies).toBeUndefined(); expect(retry.destroyAlreadyPending).toBe(true); expect(afterRetry?.mcp).toBeUndefined(); - expect(afterRetry?.customPolicies).toBeUndefined(); expect([...testState.providers.keys()]).toEqual([]); expect( testState.calls.filter((call) => call === "sandbox provider detach alpha alpha-mcp-github"), @@ -1435,7 +1364,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { agent: "openclaw", mcp: { bridges: { github: bridgeEntries.github } }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); await bridge.prepareMcpBridgesForDestroy("alpha"); const callsAfterFirstPrepare = testState.calls.length; @@ -1474,7 +1402,6 @@ describe("authenticated MCP sandbox destroy lifecycle", () => { destroyPendingAt: "2026-06-27T01:00:00.000Z", }, }); - registry.addCustomPolicy("alpha", ownedPolicy("github")); const preparation = { entries: [bridgeEntries.github], detachedProviderEntries: [], diff --git a/test/mcp/mcp-policy-key-ownership.test.ts b/test/mcp/mcp-policy-key-ownership.test.ts deleted file mode 100644 index 0e714e1ad94..00000000000 --- a/test/mcp/mcp-policy-key-ownership.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -// 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"; - -import { - managedPolicyMetadata, - managedRegistrationSource, - managedSandboxEntry, - SANDBOX_ID, -} from "../helpers/managed-policy-receipt-fixture"; - -const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.106"); -const MATCHING_OPENSHELL_VERSION_CLAUSE = `if [ "$1" = "--version" ]; then printf '%s\\n' 'openshell 0.0.106'; exit 0; fi`; -const MANAGED_POLICY_AUTHORITY_CLAUSE = `if [ "$1 $2" = "sandbox get" ]; then - printf 'Name: alpha\nId: ${SANDBOX_ID}\nPhase: Ready\n' - exit 0 -fi -if [ "$1 $2" = "policy get" ]; then - case " $* " in - *" --output json "*) - printf '%s\n' ${JSON.stringify(managedPolicyMetadata("alpha"))} - exit 0 - ;; - esac -fi`; - -const PRESET = `network_policies: - example: - name: generated-policy - endpoints: [] -`; - -function runApply( - expectedExistingNetworkPolicyContent: string | null, - liveName: string | null = "operator-owned", -) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-owner-")); - const binDir = path.join(home, ".local", "bin"); - const callsPath = path.join(home, "calls.log"); - const appliedPolicyPath = path.join(home, "applied-policy.yaml"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "openshell"), - `#!/bin/sh -${MATCHING_OPENSHELL_VERSION_CLAUSE} -${MANAGED_POLICY_AUTHORITY_CLAUSE} -printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} -if [ "$1 $2" = "policy get" ]; then - if [ -f ${JSON.stringify(appliedPolicyPath)} ]; then - cat ${JSON.stringify(appliedPolicyPath)} - exit 0 - fi - printf 'Version: 1\nHash: test\n---\nversion: 1\n${ - liveName === null - ? "network_policies: {}" - : `network_policies:\n example:\n name: ${liveName}\n endpoints: []` - }\n' -fi -if [ "$1 $2" = "policy set" ]; then - while [ "$#" -gt 0 ]; do - if [ "$1" = "--policy" ]; then - cp "$2" ${JSON.stringify(appliedPolicyPath)} - break - fi - shift - done -fi -exit 0 -`, - { mode: 0o755 }, - ); - const script = ` -const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); -${managedRegistrationSource("alpha")} -const result = policies.applyPresetContent( - "alpha", - "mcp-bridge-example", - ${JSON.stringify(PRESET)}, - { - custom: { sourcePath: "generated:nemoclaw-mcp-bridge" }, - expectedExistingNetworkPolicyContent: ${JSON.stringify(expectedExistingNetworkPolicyContent)}, - }, -); -process.stdout.write("\\n__RESULT__" + JSON.stringify(result)); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), - PATH: `${binDir}:/usr/bin:/bin`, - }, - }); - const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8") : ""; - fs.rmSync(home, { recursive: true, force: true }); - return { calls, result }; -} - -function runContentMatch(liveName: string) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-match-")); - const binDir = path.join(home, ".local", "bin"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "openshell"), - `#!/bin/sh -${MATCHING_OPENSHELL_VERSION_CLAUSE} -${MANAGED_POLICY_AUTHORITY_CLAUSE} -printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n example:\n name: ${liveName}\n endpoints: []\n' -`, - { mode: 0o755 }, - ); - const script = ` -const policies = require("./src/lib/policy/index.js"); -process.stdout.write(String(policies.presetContentMatchesGateway("alpha", ${JSON.stringify(PRESET)}))); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), - PATH: `${binDir}:/usr/bin:/bin`, - }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} - -function runFailedPolicyMutation(operation: "apply" | "remove") { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-failure-")); - const binDir = path.join(home, ".local", "bin"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "openshell"), - `#!/bin/sh -${MATCHING_OPENSHELL_VERSION_CLAUSE} -${MANAGED_POLICY_AUTHORITY_CLAUSE} -if [ "$1 $2" = "policy get" ]; then - printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n example:\n name: generated-policy\n endpoints: []\n' - exit 0 -fi -if [ "$1 $2" = "policy set" ]; then - exit 19 -fi -exit 0 -`, - { mode: 0o755 }, - ); - const script = ` -const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); -${managedRegistrationSource("alpha")} -${ - operation === "remove" - ? `registry.addCustomPolicy("alpha", { - name: "mcp-bridge-example", - content: ${JSON.stringify(PRESET)}, - sourcePath: "generated:nemoclaw-mcp-bridge", -});` - : "" -} -const result = ${ - operation === "apply" - ? `policies.applyPresetContent( - "alpha", - "mcp-bridge-example", - ${JSON.stringify(PRESET)}, - { - custom: { sourcePath: "generated:nemoclaw-mcp-bridge" }, - expectedExistingNetworkPolicyContent: ${JSON.stringify(PRESET)}, - nonFatal: true, - }, -)` - : `policies.removePreset("alpha", "mcp-bridge-example", { nonFatal: true })` - }; -process.stdout.write("\\n__RESULT__" + JSON.stringify({ - result, - policies: registry.getCustomPolicies("alpha").map((policy) => policy.name), -})); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), - PATH: `${binDir}:/usr/bin:/bin`, - }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} - -function runSuccessfulPolicyRemoval(skipRegistryUpdate: boolean) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-remove-success-")); - const binDir = path.join(home, ".local", "bin"); - const appliedPolicyPath = path.join(home, "applied-policy.yaml"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "openshell"), - `#!/bin/sh -${MATCHING_OPENSHELL_VERSION_CLAUSE} -${MANAGED_POLICY_AUTHORITY_CLAUSE} -if [ "$1 $2" = "policy get" ]; then - if [ -f ${JSON.stringify(appliedPolicyPath)} ]; then - cat ${JSON.stringify(appliedPolicyPath)} - exit 0 - fi - printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n example:\n name: generated-policy\n endpoints: []\n' -fi -if [ "$1 $2" = "policy set" ]; then - while [ "$#" -gt 0 ]; do - if [ "$1" = "--policy" ]; then - cp "$2" ${JSON.stringify(appliedPolicyPath)} - break - fi - shift - done -fi -exit 0 -`, - { mode: 0o755 }, - ); - const script = ` -const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); -${managedRegistrationSource("alpha")} -registry.addCustomPolicy("alpha", { - name: "mcp-bridge-example", - content: ${JSON.stringify(PRESET)}, - sourcePath: "generated:nemoclaw-mcp-bridge", -}); -const result = policies.removePreset("alpha", "mcp-bridge-example", { - nonFatal: true, - skipRegistryUpdate: ${JSON.stringify(skipRegistryUpdate)}, -}); -process.stdout.write("\\n__RESULT__" + JSON.stringify({ - result, - policies: registry.getCustomPolicies("alpha").map((policy) => policy.name), -})); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), - PATH: `${binDir}:/usr/bin:/bin`, - }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} - -describe("MCP-generated network policy ownership", () => { - it("refuses to replace a same-key policy the bridge does not own", () => { - const { calls, result } = runApply(null); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("__RESULT__false"); - expect(result.stderr).toContain("does not match the exact state owned"); - expect(calls).not.toContain("policy set"); - }); - - it("allows a registered bridge to refresh its owned key", () => { - const { calls, result } = runApply(PRESET, "generated-policy"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("__RESULT__true"); - expect(calls).toContain("policy set"); - }); - - it("refuses a same-key value changed after the caller's ownership proof", () => { - const { calls, result } = runApply(PRESET, "concurrent-writer"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("__RESULT__false"); - expect(result.stderr).toContain("does not match the exact state owned"); - expect(calls).not.toContain("policy set"); - }); - - it("refuses an owned key removed after the caller's ownership proof", () => { - const { calls, result } = runApply(PRESET, null); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("__RESULT__false"); - expect(result.stderr).toContain("does not match the exact state owned"); - expect(calls).not.toContain("policy set"); - }); - - it("detects same-key live policy drift instead of reporting presence", () => { - expect(runContentMatch("operator-widened").stdout).toBe("false"); - expect(runContentMatch("generated-policy").stdout).toBe("true"); - }); - - it("returns control to MCP rollback when policy apply fails", () => { - const result = runFailedPolicyMutation("apply"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain('__RESULT__{"result":false,"policies":[]}'); - expect(result.stderr).toContain("Could not confirm the policy update"); - expect(result.stderr).toContain("read the current policy back before retrying"); - }); - - it("preserves MCP policy ownership state when policy removal fails", () => { - const result = runFailedPolicyMutation("remove"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain('__RESULT__{"result":false,"policies":["mcp-bridge-example"]}'); - expect(result.stderr).toContain("Could not confirm the policy update"); - expect(result.stderr).toContain("read the current policy back before retrying"); - }); - - it.each([ - [false, []], - [true, ["mcp-bridge-example"]], - ] as const)( - "supports ownership-preserving policy removal (skipRegistryUpdate=%s)", - (skipRegistryUpdate, expectedPolicies) => { - const result = runSuccessfulPolicyRemoval(skipRegistryUpdate); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain( - `__RESULT__${JSON.stringify({ result: true, policies: expectedPolicies })}`, - ); - }, - ); - - it("does not delete an operator-owned same-key policy when add rolls back", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-lifecycle-")); - const binDir = path.join(home, ".local", "bin"); - const callsPath = path.join(home, "calls.log"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "openshell"), - `#!/bin/sh -${MATCHING_OPENSHELL_VERSION_CLAUSE} -${MANAGED_POLICY_AUTHORITY_CLAUSE} -printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} -if [ "$1 $2 $3" = "status --output json" ]; then - printf '%s\n' 'ready' - exit 0 -fi -if [ "$1 $2 $3 $4 $5 $6" = "provider profile export openai --output json" ]; then - printf '%s\n' '{"id":"openai","credentials":[],"endpoints":[],"binaries":[],"inference_capable":true}' - exit 0 -fi -if [ "$1 $2 $3 $4 $5 $6" = "provider profile export nemoclaw-mcp-v1 --output json" ]; then - printf '%s\n' '{"id":"nemoclaw-mcp-v1","credentials":[],"endpoints":[],"binaries":[],"inference_capable":false}' - exit 0 -fi -if [ "$1 $2 $3" = "sandbox provider list" ]; then - printf '%s\n' 'No providers attached to sandbox alpha.' - exit 0 -fi -if [ "$1 $2" = "provider get" ]; then - printf 'Provider not found\n' >&2 - exit 1 -fi -if [ "$1 $2" = "policy get" ]; then - printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n mcp_bridge_example:\n name: operator-owned\n endpoints: []\n' -fi -exit 0 -`, - { mode: 0o755 }, - ); - const script = ` -process.env.HOME = ${JSON.stringify(home)}; -process.env.COLLISION_TOKEN = "host-only-secret"; -const registry = require("./src/lib/state/registry.js"); -const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); -gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ - recovered: true, - attempted: false, - before: { state: "healthy_named" }, - after: { state: "healthy_named" }, -}); -processRecovery.executeSandboxCommand = () => ({ - status: 0, - stdout: "absent\\n", - stderr: "", -}); -processRecovery.executeSandboxExecCommand = () => ({ - status: 0, - stdout: "", - stderr: "", -}); -${managedRegistrationSource("alpha")} -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -bridge.addMcpBridge("alpha", { - server: "example", - url: "https://8.8.8.8/mcp", - env: [{ name: "COLLISION_TOKEN" }], -}).then( - () => process.exit(2), - (error) => { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - message: error.message, - customPolicies: registry.getCustomPolicies("alpha"), - })); - }, -); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), - PATH: `${binDir}:/usr/bin:/bin`, - }, - }); - const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8") : ""; - fs.rmSync(home, { recursive: true, force: true }); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain( - "could not prove generated policy key 'mcp_bridge_example' absent", - ); - expect(result.stdout).toContain('"customPolicies":[]'); - expect(calls).not.toContain("provider create"); - expect(calls).not.toContain("provider delete"); - expect(calls).not.toContain("policy set"); - }); - - it("reserves policy ownership before the live gateway mutation", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-registry-failure-")); - const binDir = path.join(home, ".local", "bin"); - const callsPath = path.join(home, "calls.log"); - const providerStatePath = path.join(home, "provider.state"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "openshell"), - `#!/bin/sh -${MATCHING_OPENSHELL_VERSION_CLAUSE} -${MANAGED_POLICY_AUTHORITY_CLAUSE} -printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} -if [ "$1 $2 $3" = "status --output json" ]; then - printf '%s\n' 'ready' - exit 0 -fi -if [ "$1 $2 $3 $4 $5 $6" = "provider profile export openai --output json" ]; then - printf '%s\n' '{"id":"openai","credentials":[],"endpoints":[],"binaries":[],"inference_capable":true}' - exit 0 -fi -if [ "$1 $2 $3 $4 $5 $6" = "provider profile export nemoclaw-mcp-v1 --output json" ]; then - printf '%s\n' '{"id":"nemoclaw-mcp-v1","credentials":[],"endpoints":[],"binaries":[],"inference_capable":false}' - exit 0 -fi -if [ "$1 $2 $3" = "sandbox provider list" ]; then - printf '%s\n' 'No providers attached to sandbox alpha.' - exit 0 -fi -if [ "$1 $2" = "provider get" ]; then - if [ -f ${JSON.stringify(providerStatePath)} ]; then - printf 'Id: 11111111-2222-4333-8444-555555555555\nType: nemoclaw-mcp-v1\nResource version: 1\nCredential keys: RESERVATION_TOKEN\n' - exit 0 - fi - printf 'Provider not found\n' >&2 - exit 1 -fi -if [ "$1 $2" = "provider create" ]; then - : > ${JSON.stringify(providerStatePath)} - printf '%s\n' 'Created provider.' -fi -if [ "$1 $2" = "provider delete" ]; then - rm -f -- ${JSON.stringify(providerStatePath)} -fi -if [ "$1 $2" = "policy get" ]; then - printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies: {}\n' -fi -exit 0 -`, - { mode: 0o755 }, - ); - const script = ` -process.env.HOME = ${JSON.stringify(home)}; -process.env.RESERVATION_TOKEN = "host-only-secret"; -const registry = require("./src/lib/state/registry.js"); -const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); -gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ - recovered: true, - attempted: false, - before: { state: "healthy_named" }, - after: { state: "healthy_named" }, -}); -processRecovery.executeSandboxCommand = () => ({ - status: 0, - stdout: "absent\\n", - stderr: "", -}); -processRecovery.executeSandboxExecCommand = () => ({ - status: 0, - stdout: "", - stderr: "", -}); -${managedRegistrationSource("alpha")} -registry.addCustomPolicy = () => { throw new Error("injected registry write failure"); }; -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -bridge.addMcpBridge("alpha", { - server: "reservation", - url: "https://8.8.8.8/mcp", - env: [{ name: "RESERVATION_TOKEN" }], -}).then( - () => process.exit(2), - (error) => process.stdout.write("\\n__RESULT__" + error.message), -); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), - PATH: `${binDir}:/usr/bin:/bin`, - }, - }); - const calls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8") : ""; - fs.rmSync(home, { recursive: true, force: true }); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("injected registry write failure"); - expect(calls).not.toContain("provider create"); - expect(calls).not.toContain("provider delete"); - expect(calls).not.toContain("policy set"); - }); - - it("refuses to overwrite a drifted owned policy during restart", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-drift-")); - const script = ` -process.env.HOME = ${JSON.stringify(home)}; -const registry = require("./src/lib/state/registry.js"); -const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); -const { mockManagedEndpointlessProviderProfileRun } = require("./test/helpers/onboard-script-mocks.cjs"); -const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); -const policies = require("./src/lib/policy/index.js"); -const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); -let applyCalled = false; -const providerCalls = []; - -gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ - recovered: true, - attempted: false, - before: { state: "healthy_named" }, - after: { state: "healthy_named" }, -}); -providerCommands.runOpenshellProviderCommand = (args) => { - const profileResult = mockManagedEndpointlessProviderProfileRun(args); - if (profileResult) return profileResult; - if (args.join(" ") === "status --output json") { - return { - status: 0, - stdout: "ready", - stderr: "", - }; - } - providerCalls.push(args.join(" ")); - if (args[0] === "provider" && args[1] === "get") { - return { - status: 0, - stdout: "Type: nemoclaw-mcp-v1\\nCredential keys: DRIFT_TOKEN\\n", - stderr: "", - }; - } - return { status: 0, stdout: "", stderr: "" }; -}; -policies.getPresetContentGatewayState = () => "drift"; -policies.applyPresetContent = () => { - applyCalled = true; - return true; -}; -processRecovery.executeSandboxExecCommand = () => ({ - status: 0, - stdout: "", - stderr: "", -}); -processRecovery.executeSandboxCommand = () => ({ - status: 0, - stdout: "registered\\n", - stderr: "", -}); - -const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); -const entry = { - server: "example", - agent: "openclaw", - adapter: "mcporter", - url: "https://8.8.8.8/mcp", - env: ["DRIFT_TOKEN"], - providerName: "alpha-mcp-example", - policyName: "mcp-bridge-example", - addedAt: "2026-06-01T00:00:00.000Z", -}; -registry.registerSandbox({ - ...${JSON.stringify(managedSandboxEntry("alpha"))}, - mcp: { bridges: { example: entry } }, -}); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, { - addresses: ["8.8.8.8"], - }, entry.providerName), - sourcePath: "generated:nemoclaw-mcp-bridge", -}); - -bridge.restartMcpBridge("alpha", "example").then( - () => process.exit(9), - (error) => { - process.stdout.write(JSON.stringify({ - message: error.message, - applyCalled, - providerCalls, - })); - process.exit(0); - }, -); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, HOME: home, NEMOCLAW_OPENSHELL_BIN: MATCHING_OPENSHELL }, - timeout: 30_000, - }); - fs.rmSync(home, { recursive: true, force: true }); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - applyCalled: boolean; - providerCalls: string[]; - }; - expect(payload.message).toMatch(/policy.*drift/i); - expect(payload.applyCalled).toBe(false); - expect(payload.providerCalls).toEqual([]); - }); -}); diff --git a/test/mcp/mcp-policy-transition.test.ts b/test/mcp/mcp-policy-transition.test.ts deleted file mode 100644 index 71292f15e0e..00000000000 --- a/test/mcp/mcp-policy-transition.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -// 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"; - -function runPolicyTransition( - mode: "crash-retry" | "post-set-crash" | "foreign-after-crash" | "rejected", -) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-transition-")); - const script = String.raw` -process.env.HOME = ${JSON.stringify(home)}; -const mode = ${JSON.stringify(mode)}; -const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); -const generated = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); - -const entry = { - server: "example", - agent: "openclaw", - adapter: "mcporter", - url: "https://mcp.example.test/mcp", - env: ["MCP_TOKEN"], - providerName: "alpha-mcp-example", - providerId: "11111111-2222-4333-8444-555555555555", - policyName: "mcp-bridge-example", - addedAt: "2026-06-01T00:00:00.000Z", -}; -const oldContent = generated.buildMcpBridgePolicyYaml( - entry.server, - entry.url, - entry.adapter, - { addresses: ["1.1.1.1"] }, - entry.providerName, -); -const desiredContent = generated.buildMcpBridgePolicyYaml( - entry.server, - entry.url, - entry.adapter, - { addresses: ["8.8.8.8"] }, - entry.providerName, -); -let liveContent = oldContent; -let applyCalls = 0; - -registry.registerSandbox({ - name: "alpha", - agent: "openclaw", - mcp: { bridges: { example: entry } }, -}); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: oldContent, - sourcePath: "generated:nemoclaw-mcp-bridge", -}); -policies.getPresetContentGatewayState = (_sandbox, candidate) => - candidate === liveContent ? "match" : "drift"; -policies.applyPresetContent = () => { - applyCalls += 1; - if (mode === "rejected") return false; - if (applyCalls === 1) { - if (mode === "post-set-crash") liveContent = desiredContent; - if (mode === "foreign-after-crash") liveContent = "foreign-policy-content"; - throw new Error("simulated process death after reservation"); - } - liveContent = desiredContent; - return true; -}; - -let firstError = ""; -try { - generated.applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }); -} catch (error) { - firstError = error instanceof Error ? error.message : String(error); -} - -const afterFirst = registry.getCustomPolicies("alpha")[0]; -const presenceAfterFirst = generated.getPolicyPresence("alpha", entry); -const afterPresence = registry.getCustomPolicies("alpha")[0]; - -let retryError = ""; -if (mode !== "rejected") { - try { - generated.applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }); - } catch (error) { - retryError = error instanceof Error ? error.message : String(error); - } -} -const afterRetry = registry.getCustomPolicies("alpha")[0]; - -process.stdout.write(JSON.stringify({ - firstError, - retryError, - applyCalls, - presenceAfterFirst, - pendingPreservedByStatus: afterPresence?.pendingContent === desiredContent, - afterFirst: { - contentIsOld: afterFirst?.content === oldContent, - pendingIsDesired: afterFirst?.pendingContent === desiredContent, - }, - afterRetry: { - contentIsOld: afterRetry?.content === oldContent, - contentIsDesired: afterRetry?.content === desiredContent, - hasPending: Object.hasOwn(afterRetry ?? {}, "pendingContent"), - }, -})); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, HOME: home }, - timeout: 30_000, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} - -function runUnownedRegistryCollision(operation: "assert" | "apply") { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-unowned-")); - const script = ` -process.env.HOME = ${JSON.stringify(home)}; -const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); -const generated = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); -const entry = { - server: "example", - agent: "openclaw", - adapter: "mcporter", - url: "https://mcp.example.test/mcp", - env: ["MCP_TOKEN"], - providerName: "alpha-mcp-example", - policyName: "mcp-bridge-example", - addedAt: "2026-06-01T00:00:00.000Z", -}; -registry.registerSandbox({ name: "alpha", agent: "openclaw" }); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: "operator-owned-content", - sourcePath: "/operator/policy.yaml", -}); -let applyCalled = false; -policies.getPresetContentGatewayState = () => "absent"; -policies.applyPresetContent = () => { applyCalled = true; return true; }; -let message = ""; -try { - if (${JSON.stringify(operation)} === "assert") { - generated.assertGeneratedPolicyMutationSafe("alpha", entry); - } else { - generated.applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }); - } -} catch (error) { - message = error instanceof Error ? error.message : String(error); -} -process.stdout.write(JSON.stringify({ - message, - applyCalled, - policies: registry.getCustomPolicies("alpha"), -})); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, HOME: home }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} - -function runGeneratedPolicyRemoval(postRemovalState: "absent" | "match") { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-policy-remove-")); - const script = ` -process.env.HOME = ${JSON.stringify(home)}; -const registry = require("./src/lib/state/registry.js"); -const policies = require("./src/lib/policy/index.js"); -const generated = require("./src/lib/actions/sandbox/mcp-bridge-policy.js"); -const entry = { - server: "example", - agent: "openclaw", - adapter: "mcporter", - url: "https://mcp.example.test/mcp", - env: ["MCP_TOKEN"], - providerName: "alpha-mcp-example", - policyName: "mcp-bridge-example", - addedAt: "2026-06-01T00:00:00.000Z", -}; -const content = generated.buildMcpBridgePolicyYaml( - entry.server, - entry.url, - entry.adapter, - { addresses: ["8.8.8.8"] }, - entry.providerName, -); -registry.registerSandbox({ name: "alpha", agent: "openclaw" }); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content, - sourcePath: "generated:nemoclaw-mcp-bridge", -}); -let state = "match"; -let skipRegistryUpdate = false; -policies.getPresetContentGatewayState = () => state; -policies.removePreset = (_sandbox, _policyName, options) => { - skipRegistryUpdate = options?.skipRegistryUpdate === true; - if (!skipRegistryUpdate) registry.removeCustomPolicyByName("alpha", entry.policyName); - state = ${JSON.stringify(postRemovalState)}; - return true; -}; -let message = ""; -try { - generated.removeGeneratedPolicy("alpha", entry); -} catch (error) { - message = error instanceof Error ? error.message : String(error); -} -process.stdout.write(JSON.stringify({ - message, - skipRegistryUpdate, - policies: registry.getCustomPolicies("alpha"), -})); -`; - const result = spawnSync(process.execPath, ["-e", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, HOME: home }, - }); - fs.rmSync(home, { recursive: true, force: true }); - return result; -} - -describe("generated MCP policy transitions", () => { - it.each([ - "assert", - "apply", - ] as const)("preserves an unowned same-name registry record during %s", (operation) => { - const result = runUnownedRegistryCollision(operation); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - applyCalled: boolean; - policies: Array<{ content: string; sourcePath: string }>; - }; - expect(payload.message).toMatch(/unowned same-name registry record/); - expect(payload.applyCalled).toBe(false); - expect(payload.policies).toEqual([ - expect.objectContaining({ - content: "operator-owned-content", - sourcePath: "/operator/policy.yaml", - }), - ]); - }); - - it("preserves the confirmed and desired policy across an interrupted refresh", () => { - const result = runPolicyTransition("crash-retry"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - firstError: string; - retryError: string; - applyCalls: number; - presenceAfterFirst: boolean; - pendingPreservedByStatus: boolean; - afterFirst: { contentIsOld: boolean; pendingIsDesired: boolean }; - afterRetry: { contentIsDesired: boolean; hasPending: boolean }; - }; - expect(payload).toMatchObject({ - firstError: "simulated process death after reservation", - retryError: "", - applyCalls: 2, - presenceAfterFirst: true, - pendingPreservedByStatus: true, - afterFirst: { contentIsOld: true, pendingIsDesired: true }, - afterRetry: { contentIsDesired: true, hasPending: false }, - }); - }); - - it("restores confirmed ownership when a changed policy is rejected", () => { - const result = runPolicyTransition("rejected"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - firstError: string; - applyCalls: number; - afterFirst: { contentIsOld: boolean; pendingIsDesired: boolean }; - afterRetry: { contentIsOld: boolean; hasPending: boolean }; - }; - expect(payload.firstError).toContain("Failed to activate generated MCP policy"); - expect(payload).toMatchObject({ - applyCalls: 1, - afterFirst: { contentIsOld: true, pendingIsDesired: false }, - afterRetry: { contentIsOld: true, hasPending: false }, - }); - }); - - it("finalizes desired ownership after policy load wins the crash boundary", () => { - const result = runPolicyTransition("post-set-crash"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - retryError: string; - applyCalls: number; - presenceAfterFirst: boolean; - pendingPreservedByStatus: boolean; - afterFirst: { contentIsOld: boolean; pendingIsDesired: boolean }; - afterRetry: { contentIsDesired: boolean; hasPending: boolean }; - }; - expect(payload).toMatchObject({ - retryError: "", - applyCalls: 2, - presenceAfterFirst: true, - pendingPreservedByStatus: true, - afterFirst: { contentIsOld: true, pendingIsDesired: true }, - afterRetry: { contentIsDesired: true, hasPending: false }, - }); - }); - - it("keeps both versions and fails closed when live policy matches neither", () => { - const result = runPolicyTransition("foreign-after-crash"); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - retryError: string; - applyCalls: number; - afterRetry: { contentIsOld: boolean; hasPending: boolean }; - }; - expect(payload.retryError).toMatch(/drifted|could not be inspected/); - expect(payload).toMatchObject({ - applyCalls: 1, - afterRetry: { contentIsOld: true, hasPending: true }, - }); - }); - - it.each([ - ["absent", false], - ["match", true], - ] as const)("requires exact post-removal state %s before dropping ownership", (postRemovalState, preservesOwnership) => { - const result = runGeneratedPolicyRemoval(postRemovalState); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - const payload = JSON.parse(result.stdout) as { - message: string; - skipRegistryUpdate: boolean; - policies: Array<{ content: string; sourcePath: string }>; - }; - expect(payload.skipRegistryUpdate).toBe(true); - expect(payload.message).toMatch(preservesOwnership ? /effective state: match/ : /^$/); - expect(payload.policies.map((policy) => policy.sourcePath)).toEqual( - preservesOwnership ? ["generated:nemoclaw-mcp-bridge"] : [], - ); - }); -}); diff --git a/test/mcp/mcp-provider-ownership.test.ts b/test/mcp/mcp-provider-ownership.test.ts index 5c206ef2d31..02c0acda9e5 100644 --- a/test/mcp/mcp-provider-ownership.test.ts +++ b/test/mcp/mcp-provider-ownership.test.ts @@ -88,11 +88,6 @@ registry.registerSandbox({ agent: "legacy-disabled", mcp: { bridges: { fake: entry } }, }); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: "network_policies: {}\\n", - sourcePath: "generated:nemoclaw-mcp-bridge", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); bridge.removeMcpBridge("alpha", "fake").then( () => process.exit(9), @@ -190,11 +185,6 @@ registry.registerSandbox({ agent: "legacy-disabled", mcp: { bridges: { fake: entry } }, }); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: "network_policies: {}\n", - sourcePath: "generated:nemoclaw-mcp-bridge", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); bridge.removeMcpBridge("alpha", "fake").then( () => process.stdout.write(JSON.stringify({ diff --git a/test/mcp/mcp-restart-policy-order.test.ts b/test/mcp/mcp-restart-policy-order.test.ts index 4301e5ad4fe..90f0fcace30 100644 --- a/test/mcp/mcp-restart-policy-order.test.ts +++ b/test/mcp/mcp-restart-policy-order.test.ts @@ -123,15 +123,6 @@ registry.registerSandbox({ gatewayName: "nemoclaw", mcp: { bridges: entries }, }); -for (const entry of Object.values(entries)) { - registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: generated.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, { - addresses: [new URL(entry.url).hostname], - }, entry.providerName), - sourcePath: "generated:nemoclaw-mcp-bridge", - }); -} const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); const restart = require("./src/lib/actions/sandbox/mcp-bridge-restart.js"); @@ -273,13 +264,6 @@ registry.registerSandbox({ mcp: { bridges: { example: entry } }, }); registry.addExtraProvider("foreign-registered"); -registry.addCustomPolicy("alpha", { - name: entry.policyName, - content: generated.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, { - addresses: ["8.8.8.8"], - }, entry.providerName), - sourcePath: "generated:nemoclaw-mcp-bridge", -}); const bridge = require("./src/lib/actions/sandbox/mcp-bridge.js"); bridge.restartMcpBridge("alpha", "example").then( diff --git a/test/mcp/mcp-tool-discovery-image-contract.test.ts b/test/mcp/mcp-tool-discovery-image-contract.test.ts index 826c3559d02..eb25f0fd084 100644 --- a/test/mcp/mcp-tool-discovery-image-contract.test.ts +++ b/test/mcp/mcp-tool-discovery-image-contract.test.ts @@ -160,8 +160,7 @@ describe("MCP tool discovery image contract", () => { .map((seedName) => fs.statSync(path.join(seedDirectory, seedName)).size) .every((size) => size <= 2_000_000), ).toBe(true); - manifest.archives.forEach( - (archive: { archive: string; integrity: string; size: number }) => { + manifest.archives.forEach((archive: { archive: string; integrity: string; size: number }) => { const archiveParts = seedNames.filter( (seedName) => seedName === archive.archive || seedName.startsWith(`${archive.archive}.part-`), @@ -189,8 +188,7 @@ describe("MCP tool discovery image contract", () => { expect(seed).toHaveLength(archive.size); expect(integrity).toBe(archive.integrity); expect(matches.length).toBeGreaterThan(0); - }, - ); + }); }); it("does not commit MCP runtime registry archives", () => { @@ -206,7 +204,7 @@ describe("MCP tool discovery image contract", () => { // source-shape-contract: security -- Exact reviewed runtime digests reject substituted executable and license artifacts before managed image construction. it.each([ { - expectedHash: "53771f9433668eae932034b80666d7dbbfa010caf69b719af1735851cbae405f", + expectedHash: "b9643c5e226fedcebbf584d513a66ff1d04ce9acc322828fae4c567c51b427c7", relativePath: "managed-startup-image-runtime.bundle", }, { @@ -403,25 +401,26 @@ describe("MCP tool discovery image contract", () => { }, ); - it.each( - dockerfiles, - )("%s copies and probes the bundled runtime at its canonical path (#6901)", (relativePath) => { - const dockerfile = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + it.each(dockerfiles)( + "%s copies and probes the bundled runtime at its canonical path (#6901)", + (relativePath) => { + const dockerfile = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); - expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs", - ); - expect(dockerfile).toContain( - "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", - ); - expect(dockerfile).toContain( - `COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ ${runtimeRoot}/`, - ); - expect(dockerfile).not.toContain("mcp-runtime-npm-cache-seed/"); - expect(dockerfile).not.toContain("install-reviewed-runtime.sh"); - expect(dockerfile).toContain(`node ${runtimeRoot}/mcp-tool-discovery.mjs`); - expect(dockerfile).not.toContain(`${runtimeRoot}/mcp-tool-discovery.ts`); - }); + expect(dockerfile).toContain( + "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle /opt/mcp-tool-discovery-runtime/dist/mcp-tool-discovery.mjs", + ); + expect(dockerfile).toContain( + "COPY tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle /out/managed-startup-image-runtime.cjs", + ); + expect(dockerfile).toContain( + `COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ ${runtimeRoot}/`, + ); + expect(dockerfile).not.toContain("mcp-runtime-npm-cache-seed/"); + expect(dockerfile).not.toContain("install-reviewed-runtime.sh"); + expect(dockerfile).toContain(`node ${runtimeRoot}/mcp-tool-discovery.mjs`); + expect(dockerfile).not.toContain(`${runtimeRoot}/mcp-tool-discovery.ts`); + }, + ); it.skipIf(process.platform === "win32")( "accepts a complete locked tree after npm's exact internal exit-handler failure", diff --git a/test/networking/dashboard-remote-bind-lifecycle.test.ts b/test/networking/dashboard-remote-bind-lifecycle.test.ts index 6f7854b7ace..dd58634a4ac 100644 --- a/test/networking/dashboard-remote-bind-lifecycle.test.ts +++ b/test/networking/dashboard-remote-bind-lifecycle.test.ts @@ -222,7 +222,6 @@ describe("remote dashboard bind production lifecycle", () => { agent: { name: "openclaw" } as never, agentVersionKnown: true, imageTag: null, - appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, diff --git a/test/networking/registry-host-local-inference.test.ts b/test/networking/registry-host-local-inference.test.ts index 8b5fe6283d6..0a40b7b973d 100644 --- a/test/networking/registry-host-local-inference.test.ts +++ b/test/networking/registry-host-local-inference.test.ts @@ -58,40 +58,22 @@ function prepareVerifiedCreate( authority, registry.getSandbox(name), ); - const policyCreationReceipt = { - schemaVersion: 1 as const, - origin: "sandbox-create" as const, - gatewayName: route.gatewayName, - gatewayPort: route.gatewayPort, - sandboxName: name, - lifecycleGeneration: LIFECYCLE_GENERATION, - sandboxIdentityFingerprint: SANDBOX_IDENTITY_FINGERPRINT, - policyHash: "sha256:host-local-fixture", - policyVersion: 1, - }; const checkpoint = { schemaVersion: 1 as const, state: "verified-create" as const, - policyAuthority: "nemoclaw-managed" as const, - observedPolicyAuthority: "owner-unknown" as const, gatewayName: route.gatewayName, gatewayPort: route.gatewayPort, sandboxName: name, lifecycleGeneration: LIFECYCLE_GENERATION, sandboxIdentityFingerprint: SANDBOX_IDENTITY_FINGERPRINT, route: "none" as const, - policyHash: policyCreationReceipt.policyHash, - policyVersion: policyCreationReceipt.policyVersion, - policyCreationReceipt, }; - registry.recordPendingSandboxPolicyVerification(reservation, checkpoint); + registry.recordPendingSandboxCreateIdentity(reservation, checkpoint); return { checkpoint, registration: { lifecycleGeneration: LIFECYCLE_GENERATION, lifecycleLiveIdentityFingerprint: SANDBOX_IDENTITY_FINGERPRINT, - policyAuthority: "nemoclaw-managed" as const, - policyCreationReceipt, }, reservation, }; @@ -212,7 +194,7 @@ describe("registry host-local inference authority", () => { { verifiedCreate: verified }, ), ).toThrow( - label === "gateway port" ? /policy creation receipt does not match/u : /reservation changed/u, + label === "gateway port" ? /Cannot publish a sandbox registration/u : /reservation changed/u, ); }); @@ -305,7 +287,6 @@ describe("registry host-local inference authority", () => { hostLocalInferenceProvenance: undefined, }), ).toBe(false); - expect(registry.updateSandbox("llama-stable", { policies: ["baseline"] })).toBe(true); expect(registry.getSandbox("llama-stable")).toMatchObject(route); }); diff --git a/test/onboard-external-policy-authority-composition.test.ts b/test/onboard-external-policy-authority-composition.test.ts deleted file mode 100644 index ee7eb05ab0f..00000000000 --- a/test/onboard-external-policy-authority-composition.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -// 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, vi } from "vitest"; -import YAML from "yaml"; -import { loadAgent } from "../src/lib/agent/defs"; -import { prepareInitialSandboxCreatePolicy } from "../src/lib/onboard/initial-policy"; -import { runFinalOnboardFlowSlice } from "../src/lib/onboard/machine/final-flow-phases"; -import { - createOnboardPolicyAuthorityBindings, - requiredOnboardPolicyPresets, -} from "../src/lib/onboard/policy-authority/preflight"; -import { prepareSandboxCreateLaunch } from "../src/lib/onboard/sandbox-create-launch"; -import { - materializeSandboxCreatePlan, - resolveSandboxCreateIntent, -} from "../src/lib/onboard/sandbox-create-plan"; -import { runSandboxCreateWithPolicyAuthorityChecks } from "../src/lib/onboard/sandbox-create/orchestration"; -import type { SandboxEntry } from "../src/lib/state/registry"; -import { createSession } from "../src/lib/state/onboard-session"; -import { context, createPhases, createRuntimeHarness } from "./helpers/onboard-final-flow-phases"; - -const REPO_ROOT = path.join(import.meta.dirname, ".."); - -describe("external policy authority onboarding composition", () => { - it("completes a fresh flow without policy mutation or attribution (#9833)", async () => { - const sandboxName = "fresh-external"; - const gatewayName = "nemoclaw"; - const provider = "ollama-local"; - const model = "llama3.1"; - const policyTier = "balanced"; - const selectedMessagingChannels = ["slack"]; - const webSearchConfig = { provider: "tavily" as const, fetchEnabled: true }; - const agent = loadAgent("openclaw"); - const basePolicyPath = path.join( - REPO_ROOT, - "nemoclaw-blueprint", - "policies", - "openclaw-sandbox.yaml", - ); - const additionalPresets = requiredOnboardPolicyPresets({ - additionalPresets: [], - provider, - webSearchConfig, - agentName: agent.name, - observabilityEnabled: false, - }); - expect(additionalPresets).toEqual(["local-inference", "tavily"]); - const prepareRequiredPolicy = () => - prepareInitialSandboxCreatePolicy(basePolicyPath, selectedMessagingChannels, { - directGpu: false, - additionalPresets, - agentName: agent.name, - // Mirrors preflightPolicyRequirements. - sandboxName, - policyTier, - }); - const effectivePolicySource = prepareRequiredPolicy(); - const effectivePolicy = YAML.parse( - fs.readFileSync(effectivePolicySource.policyPath, "utf8"), - ) as { - network_policies: Record; - }; - expect(effectivePolicySource.appliedPresets).toEqual( - expect.arrayContaining(["slack", "local-inference", "tavily"]), - ); - expect(effectivePolicySource.cleanup?.()).toBe(true); - expect(Object.keys(effectivePolicy.network_policies)).toEqual( - expect.arrayContaining(["slack", "local_inference", "tavily"]), - ); - effectivePolicy.network_policies.operator_audit = { - name: "operator_audit", - endpoints: [{ host: "operator.example.test", port: 443, protocol: "rest" }], - }; - - const externalInspection = { - authority: "externally-managed" as const, - effectivePolicy, - policyIdentity: { hash: "sha256:external", activeVersion: 1 }, - }; - const inspectActiveGlobalPolicy = vi.fn(() => ({ - state: "active" as const, - inspection: externalInspection, - })); - const inspectSandboxPolicyAuthority = vi.fn(() => externalInspection); - let liveExists = false; - let existingEntry: SandboxEntry | null = null; - let durableSession = createSession({ policyPresets: ["balanced"] }); - const updateSession = vi.fn((mutator: (session: typeof durableSession) => void) => { - mutator(durableSession); - return durableSession; - }); - const getAgentPolicyPath = vi.fn(() => basePolicyPath); - const bindings = createOnboardPolicyAuthorityBindings( - { - GATEWAY_NAME: gatewayName, - ROOT: REPO_ROOT, - agentDefs: { loadAgent }, - agentOnboard: { getAgentPolicyPath }, - inspectSandboxForCreate: () => ({ existingEntry, liveExists }), - onboardSession: { - loadSession: () => durableSession, - updateSession, - }, - }, - policyTier, - { inspectActiveGlobalPolicy, inspectSandboxPolicyAuthority }, - ); - - durableSession = await bindings.bindPolicyAuthority(gatewayName, durableSession); - expect(durableSession.policyAuthority).toBe("externally-managed"); - expect(durableSession.policyPresets).toBeNull(); - - const policyRequirements = { - gatewayName, - sandboxName, - agent, - selectedMessagingChannels, - hermesToolGateways: [], - gpuPassthrough: false, - provider, - webSearchConfig, - observabilityEnabled: false, - operation: `prepare sandbox '${sandboxName}'`, - }; - bindings.preflightPolicyRequirements(policyRequirements); - bindings.preflightPolicyRequirements({ ...policyRequirements, agent: null }); - expect(inspectActiveGlobalPolicy).toHaveBeenCalledTimes(3); - expect(inspectSandboxPolicyAuthority).not.toHaveBeenCalled(); - expect(getAgentPolicyPath).toHaveBeenLastCalledWith( - expect.objectContaining({ name: "openclaw" }), - ); - - const intent = resolveSandboxCreateIntent({ - basePolicyPath, - sandboxName, - inferenceProvider: provider, - channels: [ - { - name: "slack", - envKey: "SLACK_BOT_TOKEN", - appTokenEnvKey: "SLACK_APP_TOKEN", - label: "Slack", - description: "Slack", - help: "Slack", - }, - ], - enabledChannels: selectedMessagingChannels, - disabledChannelNames: new Set(), - messagingProviderRequests: [], - primaryMessagingCredentialEnvKeys: [], - reusableMessagingChannels: [], - reusableMessagingProviders: [], - hermesToolGateways: [], - sandboxGpuConfig: { sandboxGpuEnabled: false }, - gpuCreateArgs: [], - gpuRoutePlan: "none", - sandboxGpuLogMessage: null, - agentName: agent.name, - policyTier, - }); - const discloseInitialSandboxPolicy = vi.fn(); - const plan = materializeSandboxCreatePlan({ - intent, - fromRef: "example.invalid/openclaw@sha256:abc", - policyAuthority: "externally-managed", - messagingTokenDefs: [], - runProviderPreDeleteCleanup: vi.fn(), - upsertMessagingProviders: vi.fn(() => []), - getHermesToolGatewayProviderName: vi.fn(), - discloseInitialSandboxPolicy, - }); - const launch = prepareSandboxCreateLaunch({ - agent, - chatUiUrl: "", - createArgs: plan.createArgs, - extraPlaceholderKeys: [], - getDashboardForwardPort: () => "", - hermesDashboardState: { config: null, enabled: false }, - manageDashboard: false, - openshellShellCommand: (args) => args.join(" "), - buildEnv: () => ({ OPENSHELL_SANDBOX_POLICY: "/tmp/inherited-policy.yaml" }), - }); - - await expect( - runSandboxCreateWithPolicyAuthorityChecks({ - sandboxName, - revalidate: (_sandboxIsLive, operation) => - bindings.preflightPolicyRequirements({ ...policyRequirements, operation }), - create: async (verifyCreatedSandbox) => { - liveExists = true; - existingEntry = { - name: sandboxName, - policyAuthority: "externally-managed", - policyTier, - policies: [], - } as SandboxEntry; - await verifyCreatedSandbox(launch); - return launch; - }, - captureCreatedSandboxIdentity: () => "a".repeat(64), - persistCreatedSandboxIdentity: vi.fn(), - revalidateCreatedSandboxIdentity: vi.fn(), - verifyCreatedPolicy: () => "verified", - persistVerifiedPolicy: vi.fn(), - revalidateVerifiedPolicy: vi.fn(), - cleanupTemporarySources: vi.fn(), - }), - ).resolves.toBe(launch); - - expect(plan.createArgs).not.toContain("--policy"); - expect(plan.createArgs).not.toContain(plan.initialSandboxPolicy.policyPath); - expect(launch.sandboxEnv).not.toHaveProperty("OPENSHELL_SANDBOX_POLICY"); - expect(discloseInitialSandboxPolicy).not.toHaveBeenCalled(); - - Object.assign(durableSession, { - sandboxName, - provider, - model, - observabilityEnabled: false, - machine: { - version: 1, - state: "openclaw", - stateEnteredAt: "2026-08-24T00:00:00.000Z", - revision: 0, - }, - }); - const runtime = createRuntimeHarness(durableSession); - const setupPoliciesWithSelection = vi.fn(async () => ["balanced"]); - const persistAppliedPolicyPresets = vi.fn(); - const policyUpdateSession = vi.fn(() => durableSession); - const reportDeploymentReadiness = vi.fn(); - const revalidationOperations: string[] = []; - const recordStepComplete = vi.fn(async (_stepName: string, updates = {}) => { - Object.assign(durableSession, updates); - return durableSession; - }); - const phases = createPhases("openclaw", [], { - loadSession: () => durableSession, - updateSession: policyUpdateSession, - recordStepComplete, - getActiveSandbox: () => existingEntry, - setupPoliciesWithSelection, - persistAppliedPolicyPresets, - reportDeploymentReadiness, - revalidatePolicyRequirements: (flowContext, operation) => { - revalidationOperations.push(operation); - bindings.revalidatePolicyRequirements( - { ...flowContext, agent: null, session: durableSession }, - operation, - ); - }, - }); - - const finalResult = await runFinalOnboardFlowSlice({ - context: context({ - session: durableSession, - sandboxName, - provider, - model, - selectedMessagingChannels, - hermesToolGateways: [], - webSearchConfig, - }), - runtime: runtime.boundary.getRuntime(), - phases, - recordRepairEvent: vi.fn(async () => durableSession), - }); - - expect(runtime.getSession()).toMatchObject({ - status: "complete", - policyAuthority: "externally-managed", - policyPresets: null, - }); - expect(setupPoliciesWithSelection).not.toHaveBeenCalled(); - expect(persistAppliedPolicyPresets).not.toHaveBeenCalled(); - expect(policyUpdateSession).not.toHaveBeenCalled(); - expect(updateSession).toHaveBeenCalledTimes(1); - expect(inspectSandboxPolicyAuthority).toHaveBeenCalled(); - expect(revalidationOperations).toEqual( - expect.arrayContaining([ - `configure OpenClaw in sandbox '${sandboxName}'`, - `verify the externally managed policy for sandbox '${sandboxName}'`, - `set sandbox '${sandboxName}' as the default`, - `publish deployment status for sandbox '${sandboxName}'`, - `complete onboarding for sandbox '${sandboxName}'`, - ]), - ); - expect(reportDeploymentReadiness).toHaveBeenCalledWith(true); - expect(finalResult.session.status).toBe("complete"); - expect(plan.initialSandboxPolicy.cleanup?.() ?? true).toBe(true); - }); -}); diff --git a/test/onboarding/onboard-build-recreate-credential-reuse.test.ts b/test/onboarding/onboard-build-recreate-credential-reuse.test.ts index 57adc36e1ad..cb473e85d0b 100644 --- a/test/onboarding/onboard-build-recreate-credential-reuse.test.ts +++ b/test/onboarding/onboard-build-recreate-credential-reuse.test.ts @@ -99,7 +99,7 @@ const { setupNim, setupInference } = require(${onboardPath}); result.hermesToolGateways, { allowToolsIncompatible: result.allowToolsIncompatible, - revalidatePolicyRequirements: () => {}, + verifyLivePolicyRequirements: () => {}, skipHostInferenceSmoke: result.skipHostInferenceSmoke, reuseGatewayCredentialWithoutLocalKey: result.reuseGatewayCredentialWithoutLocalKey, }, diff --git a/test/onboarding/onboard-extra-provider-reconciliation.test.ts b/test/onboarding/onboard-extra-provider-reconciliation.test.ts index a4db0fd14b0..296f1a8d483 100644 --- a/test/onboarding/onboard-extra-provider-reconciliation.test.ts +++ b/test/onboarding/onboard-extra-provider-reconciliation.test.ts @@ -59,9 +59,6 @@ const createFixture = fixtureMocks.installVerifiedSandboxCreateFixture(registry, }); const runner = require(${runnerPath}); const preflight = require(${preflightPath}); -const policyAuthorityPreflight = require(${JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "policy-authority", "preflight.ts"), - )}); const credentials = require(${credentialsPath}); const sandboxBaseImage = require(${sandboxBaseImagePath}); const childProcess = require("node:child_process"); @@ -108,9 +105,6 @@ runner.runCapture = (command) => { }; require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); preflight.checkPortAvailable = async () => ({ ok: true }); -policyAuthorityPreflight.qualifySandboxPolicyAuthority = () => ({ - authority: "nemoclaw-managed", -}); credentials.prompt = async () => ""; sandboxBaseImage.resolveSandboxBaseImage = () => ({ ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", diff --git a/test/onboarding/onboard-fresh-create-identity.test.ts b/test/onboarding/onboard-fresh-create-identity.test.ts index 42cefb93c78..c0866479310 100644 --- a/test/onboarding/onboard-fresh-create-identity.test.ts +++ b/test/onboarding/onboard-fresh-create-identity.test.ts @@ -111,12 +111,12 @@ describe("fresh create identity", () => { expectedOutcome: "post-create-registration-recovery-retry" as const, }, { - title: "retains recovery state when final checks fail after registration (#9833)", + title: "accepts an external policy change after registration (#9833)", apfInterceptorRequested: true, provider: null, model: null, agent: null, - expectedOutcome: "post-create-finalization-refusal" as const, + expectedOutcome: "post-create-policy-change" as const, }, { title: "rejects staged messaging intent before any onboarding side effect (#9833)", @@ -242,9 +242,7 @@ const cancellationSelector = ${JSON.stringify( )}; const cancelAfterCreate = cancellationSelector !== null; const recoveryReentry = process.env.NEMOCLAW_RECOVERY_REENTRY || ""; -const identityMismatchRefusal = ${JSON.stringify( - expectedOutcome === "identity-mismatch-refusal", - )}; +const identityMismatchRefusal = ${JSON.stringify(expectedOutcome === "identity-mismatch-refusal")}; const stagedMessagingRefusal = ${JSON.stringify(expectedOutcome === "staged-messaging-refusal")}; const postCreateAuthorityRefusal = ${JSON.stringify( expectedOutcome === "post-create-authority-refusal", @@ -263,9 +261,7 @@ let recoveryJournalReadbackFailuresRemaining = ${JSON.stringify( ? 1 : 0, )}; -const postCreateFinalizationRefusal = ${JSON.stringify( - expectedOutcome === "post-create-finalization-refusal", - )}; +const postCreatePolicyChange = ${JSON.stringify(expectedOutcome === "post-create-policy-change")}; let cancelPrompt = false; const originalGetCredential = credentials.getCredential; credentials.getCredential = (...args) => { @@ -294,7 +290,7 @@ runner.run = (command, opts = {}) => { const cmd = _n(command); if (cmd.includes("gateway info")) return "Gateway endpoint: http://127.0.0.1:18080"; if (cmd.includes("policy get") && cmd.includes("--output json")) { - if (postCreateFinalizationRefusal && registeredSandbox) { + if (postCreatePolicyChange && registeredSandbox) { throw new Error("final onboarding policy check failed"); } return JSON.stringify({ scope: "sandbox", sandbox: "my-assistant", status: "effective", policy_source: "sandbox", hash: "fixture-policy", active_version: 1, policy: effectivePolicy }); @@ -380,7 +376,6 @@ runner.run = (command, opts = {}) => { toolDisclosure: "progressive", dcodeAutoApprovalMode: null, observabilityEnabled: false, - policyTier: null, }, }); } @@ -397,7 +392,7 @@ runner.run = (command, opts = {}) => { onVerifyCreatedPolicy: (input) => { policyVerificationCalls += 1; if (postCreateAuthorityRefusal) { - throw new Error("external policy authority changed"); + throw new Error("external policy requirements changed"); } effectivePolicy = require(${policyMergePath}).parseOpenShellPolicy( fs.readFileSync(input.policySourcePath, "utf8"), @@ -415,8 +410,8 @@ runner.run = (command, opts = {}) => { removeSandbox: (name) => { registryMutationCalls.push({ operation: "remove", name }); }, }); if (postCreateRunnerRefusal) { - const requireCurrentCheckpoint = registry.requireCurrentPendingSandboxPolicyVerification; - registry.requireCurrentPendingSandboxPolicyVerification = (...args) => { + const requireCurrentCheckpoint = registry.requireCurrentPendingSandboxCreateIdentity; + registry.requireCurrentPendingSandboxCreateIdentity = (...args) => { checkpointReadCalls += 1; if (checkpointReadCalls === 6) { throw new Error("post-verification create runner checkpoint failed"); @@ -541,7 +536,7 @@ const writePayload = (sandboxName, creationError, exitCode = 0) => { postCreateAuthorityRefusal || postCreateRunnerRefusal || postCreateRegistrationRefusal || - postCreateFinalizationRefusal + postCreatePolicyChange ? onboardModule.onboardSession.loadSession() : null, retainedRecoveryRecords: retainedRecovery.listRetainedSandboxRecoveryRecords(), @@ -721,10 +716,6 @@ if (${JSON.stringify( assert.equal(record.gatewayPort, 18080); assert.equal(record.sandboxIdentityFingerprint, identityFingerprint); assert.equal(record.lifecycleGeneration, payload.recoveryRegistryEntry.lifecycleGeneration); - assert.deepEqual(record.verifiedEffectivePolicyIdentity, { - hash: "fixture-policy", - activeVersion: 1, - }); }; const assertProviderBackedApfRefusal = () => { assert.match( @@ -805,16 +796,22 @@ if (${JSON.stringify( }; const assertManagedProviderCreation = () => { assertSuccessfulCreation(); - assert.equal(payload.registeredSandbox.policyAuthority, "nemoclaw-managed"); - assert.ok(payload.registeredSandbox.policyCreationReceipt); + assert.equal("policyAuthority" in payload.registeredSandbox, false); + assert.equal("policyCreationReceipt" in payload.registeredSandbox, false); assert.match(payload.createCommand, /--policy \S+/u); assert.match(payload.createCommand, /--provider nvidia-prod/u); }; const assertProviderlessApfCreation = () => { assertSuccessfulCreation(); - assert.equal(payload.registeredSandbox.policyAuthority, "externally-managed"); - assert.equal(payload.registeredSandbox.policyCreationReceipt, undefined); - assert.deepEqual(payload.registeredSandbox.appliedPolicies ?? [], []); + for (const field of [ + "appliedPolicies", + "policies", + "policyAuthority", + "policyCreationReceipt", + "policyTier", + ]) { + assert.equal(field in payload.registeredSandbox, false); + } assert.doesNotMatch(payload.createCommand, /(?:^|\s)--policy(?:=|\s)/u); assert.doesNotMatch(payload.createCommand, /(?:^|\s)--provider(?:\s|$)/u); assert.equal(payload.credentialReadCalls, 0); @@ -840,9 +837,7 @@ if (${JSON.stringify( assert.equal(payload.policyVerificationCalls, 0); assert.equal(payload.registeredSandbox, null); assert.equal(payload.credentialReadCalls, 0); - assert.deepEqual(payload.registryMutationCalls, [ - { operation: "update", name: "my-assistant" }, - ]); + assert.deepEqual(payload.registryMutationCalls, []); assert.deepEqual(providerEffectCommands, []); assert.equal( payload.commandNames.some((command: string) => @@ -884,7 +879,6 @@ if (${JSON.stringify( assert.equal(record.gatewayName, "nemoclaw-18080"); assert.equal(record.gatewayPort, 18080); assert.match(record.lifecycleGeneration, /^[0-9a-f-]{36}$/u); - assert.equal(record.verifiedEffectivePolicyIdentity, null); assert.equal(record.reason, "retained_after_sandbox_creation_failure"); }; const assertPostCreateRunnerRefusal = () => { @@ -991,22 +985,11 @@ if (${JSON.stringify( 1, ); }; - const assertPostCreateFinalizationRefusal = () => { - assert.equal(payload.sandboxName, null); - assert.equal(payload.sandboxCreated, true); - assert.equal(payload.deleted, false); - assert.equal(payload.registeredSandbox.name, "my-assistant"); - assert.match( - payload.creationError, - /OpenShell sandbox policy authority inspection failed/u, - ); - assert.equal(payload.savedSession.status, "recovery_required"); - assert.equal(payload.savedSession.resumable, false); - assert.equal( - payload.savedSession.cancellationRecovery.sandboxIdentityFingerprint, - identityFingerprint, - ); - assertRecoveryTuple(payload.retainedRecoveryRecords[0]); + const assertPostCreatePolicyChange = () => { + assertSuccessfulCreation(); + assert.equal(payload.savedSession.status, "in_progress"); + assert.notEqual(payload.savedSession.status, "recovery_required"); + assert.deepEqual(payload.retainedRecoveryRecords, []); }; const assertCancellationRecovery = () => { assert.equal(payload.exitCode, 1); @@ -1137,7 +1120,7 @@ if (${JSON.stringify( "post-create-registration-recovery-readback-failure": assertPostCreateRegistrationRecoveryReadbackFailure, "post-create-registration-recovery-retry": assertPostCreateRegistrationRecoveryRetry, - "post-create-finalization-refusal": assertPostCreateFinalizationRefusal, + "post-create-policy-change": assertPostCreatePolicyChange, "staged-messaging-refusal": assertStagedMessagingRefusal, "cancel-after-create-tier": assertCancellationRecovery, "cancel-after-create-tier-presets": assertCancellationRecovery, diff --git a/test/onboarding/onboard-fsm-live-slices.test.ts b/test/onboarding/onboard-fsm-live-slices.test.ts index 4a8ec615bc2..0506d34fbbf 100644 --- a/test/onboarding/onboard-fsm-live-slices.test.ts +++ b/test/onboarding/onboard-fsm-live-slices.test.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { beforeAll, describe, it } from "vitest"; const repoRoot = path.join(import.meta.dirname, "../.."); -const probeTimeoutMs = 10_000; +const probeTimeoutMs = 60_000; type SliceName = "initial" | "core" | "final"; type ProbeMode = @@ -475,9 +475,7 @@ if (scenario.mode === "stale-recovery-admission") { gatewayName: "nemoclaw", gatewayPort: 8080, lifecycleGeneration: "stale-admission-generation", - verifiedEffectivePolicyIdentity: null, createAttemptNonce: "c".repeat(62), - policyCreationReceipt: null, resources: { sharedInferenceProviders: [], sandboxScopedProviders: [], @@ -551,7 +549,7 @@ const { onboard } = require(${onboardPath}); (scenario.mode === "providerless-staged-messaging" && /supports providerless sandbox creation only/.test(String(error?.message))) ) { - const payload = JSON.stringify({ called }); + const payload = "__RESULT__" + JSON.stringify({ called }); if (scenario.mode === "dashboard-port-composition") { process.stdout.write(payload + "\\n", () => process.exit(0)); return; @@ -598,7 +596,10 @@ const { onboard } = require(${onboardPath}); try { assert.equal(result.status, 0, probeFailureMessage(result)); const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean); - const payload = JSON.parse(lines.at(-1) || "{}") as { called?: string[] }; + const resultLine = [...lines].reverse().find((line) => line.startsWith("__RESULT__")); + const payload = JSON.parse(resultLine?.slice("__RESULT__".length) || "{}") as { + called?: string[]; + }; assert.ok( Array.isArray(payload.called), `slice probe did not return called slices\n${probeFailureMessage(result)}`, @@ -720,11 +721,11 @@ describe("live onboard FSM slice boundaries", () => { }, ); - it("preserves an explicit null policy tier for authoritative rebuilds", () => { + it("does not carry a policy tier through authoritative rebuild state", () => { const called = runSliceProbe({ slice: "core", mode: "authoritative-core-gateway-policy-tier", }); - assert.equal(called.at(-1), "authoritative-policy-tier:null"); + assert.equal(called.at(-1), "authoritative-policy-tier:undefined"); }); }); diff --git a/test/onboarding/onboard-inference-failure-paths.test.ts b/test/onboarding/onboard-inference-failure-paths.test.ts index 4f00135bf69..24ceb9d5948 100644 --- a/test/onboarding/onboard-inference-failure-paths.test.ts +++ b/test/onboarding/onboard-inference-failure-paths.test.ts @@ -99,12 +99,6 @@ function expectNemoclawScopedRunner( } describe("setupInference dependency failures", () => { - it("fails closed before sandbox inference setup without policy authority revalidation", async () => { - await expect( - onboard.createSetupInference()("test-box", "gpt-test", "openai-api"), - ).rejects.toThrow("Sandbox inference setup requires policy authority revalidation."); - }); - afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -203,7 +197,7 @@ describe("setupInference dependency failures", () => { expect.any(String), { OPENAI_API_KEY: "openai-secret" }, "nemoclaw", - { revalidatePolicyRequirements: expect.any(Function) }, + { verifyLivePolicyRequirements: expect.any(Function) }, ); expect(promptValidationRecovery).not.toHaveBeenCalled(); expect(exitProcess).toHaveBeenCalledOnce(); @@ -995,7 +989,7 @@ describe("setupInference dependency failures", () => { "http://host.openshell.internal:4000/v1", { NVIDIA_INFERENCE_API_KEY: "test-secret" }, "nemoclaw", - { revalidatePolicyRequirements: expect.any(Function) }, + { verifyLivePolicyRequirements: expect.any(Function) }, ); expect(exitProcess).toHaveBeenCalledOnce(); expect(exitProcess).toHaveBeenCalledWith(29); diff --git a/test/onboarding/onboard-inference-reconciliation.test.ts b/test/onboarding/onboard-inference-reconciliation.test.ts index 08caf4ec8eb..87c74264391 100644 --- a/test/onboarding/onboard-inference-reconciliation.test.ts +++ b/test/onboarding/onboard-inference-reconciliation.test.ts @@ -149,57 +149,71 @@ describe("onboard helpers", () => { commands.at(-1)?.command || "", /inference set -g nemoclaw --no-verify --provider compatible-anthropic-endpoint --model anthropic\.claude-3-5-sonnet-20240620-v1:0/, ); - expect(updateSandbox).toHaveBeenCalledWith("test-box", { model: "anthropic.claude-3-5-sonnet-20240620-v1:0", provider: "compatible-anthropic-endpoint", endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", endpointSource: "onboard", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw", hostLocalInferenceReceipt: null }); + expect(updateSandbox).toHaveBeenCalledWith("test-box", { + model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + provider: "compatible-anthropic-endpoint", + endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + endpointSource: "onboard", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: null, + gatewayName: "nemoclaw", + hostLocalInferenceReceipt: null, + }); }); }); - it("resolves a sandbox name before reconciling Hermes Provider on resume", { - timeout: 60_000, - }, () => { - const repoRoot = path.join(import.meta.dirname, "../.."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-resume-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "hermes-resume-sandbox-name-check.js"); - const openshellPath = JSON.stringify(path.join(fakeBin, "openshell")); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); - const sessionPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"), - ); - const checkpointPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"), - ); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const nimPath = JSON.stringify(path.join(repoRoot, "src", "lib", "inference", "nim.ts")); - const gatewayStatePath = JSON.stringify( - path.join(repoRoot, "src", "lib", "state", "gateway.ts"), - ); - const dockerDriverPlatformPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "docker-driver-platform.ts"), - ); - const gatewayGpuPassthroughPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "gateway-gpu-passthrough.ts"), - ); - const onboardProbesPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "inference", "onboard-probes.ts"), - ); - const preflightGatewayAuthorityPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "machine", "preflight-gateway-authority.ts"), - ); - const preflightPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "preflight.ts"), - ); - const bridgeDnsPreflightPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "bridge-dns-preflight.ts"), - ); + it( + "resolves a sandbox name before reconciling Hermes Provider on resume", + { + timeout: 60_000, + }, + () => { + const repoRoot = path.join(import.meta.dirname, "../.."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-resume-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "hermes-resume-sandbox-name-check.js"); + const openshellPath = JSON.stringify(path.join(fakeBin, "openshell")); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + const registryPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "state", "registry.ts"), + ); + const sessionPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"), + ); + const checkpointPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "state", "onboard-checkpoint-migrate.ts"), + ); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "credentials", "store.ts"), + ); + const nimPath = JSON.stringify(path.join(repoRoot, "src", "lib", "inference", "nim.ts")); + const gatewayStatePath = JSON.stringify( + path.join(repoRoot, "src", "lib", "state", "gateway.ts"), + ); + const dockerDriverPlatformPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "docker-driver-platform.ts"), + ); + const gatewayGpuPassthroughPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "gateway-gpu-passthrough.ts"), + ); + const onboardProbesPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "inference", "onboard-probes.ts"), + ); + const preflightGatewayAuthorityPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "machine", "preflight-gateway-authority.ts"), + ); + const preflightPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "preflight.ts"), + ); + const bridgeDnsPreflightPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "bridge-dns-preflight.ts"), + ); - fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); - fs.writeFileSync(path.join(fakeBin, "brew"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + fs.mkdirSync(fakeBin, { recursive: true }); + writeOkOpenshell(fakeBin); + fs.writeFileSync(path.join(fakeBin, "brew"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); - const script = String.raw` + const script = String.raw` const runner = require(${runnerPath}); const registry = require(${registryPath}); const onboardSession = require(${sessionPath}); @@ -311,7 +325,6 @@ registry.getSandbox = (name) => provider: "hermes-provider", model: "moonshotai/kimi-k2.6", hermesToolGateways: [], - policies: ["nous-web"], } : null; registry.reserveSandboxInferenceRoute = (name, updates) => { @@ -390,7 +403,6 @@ const resumeSession = onboardSession.createSession({ credentialEnv: "NOUS_API_KEY", hermesAuthMethod: "api_key", hermesToolGateways: [], - policyPresets: ["nous-web"], metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: { preflight: complete(), @@ -432,62 +444,63 @@ const { onboard } = require(${onboardPath}); } })(); `; - fs.writeFileSync(scriptPath, script); + fs.writeFileSync(scriptPath, script); - const env: Record = { - ...stripMessagingEnv(process.env), - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), - }; - delete env.NEMOCLAW_NON_INTERACTIVE; - delete env.NEMOCLAW_SANDBOX_NAME; - delete env.NOUS_API_KEY; + const env: Record = { + ...stripMessagingEnv(process.env), + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), + }; + delete env.NEMOCLAW_NON_INTERACTIVE; + delete env.NEMOCLAW_SANDBOX_NAME; + delete env.NOUS_API_KEY; - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env, - }); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); - assert.equal(result.status, 0, result.stderr); - assert.doesNotMatch( - `${result.stderr}\n${result.stdout}`, - /Hermes Provider requires a sandbox name/, - ); - const payload = parseStdoutJson<{ - commands: CommandEntry[]; - prompts: string[]; - registryUpdates: Array<{ name: string; updates: Record }>; - inferenceSessionSandboxName: string | null; - }>(result.stdout); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch( + `${result.stderr}\n${result.stdout}`, + /Hermes Provider requires a sandbox name/, + ); + const payload = parseStdoutJson<{ + commands: CommandEntry[]; + prompts: string[]; + registryUpdates: Array<{ name: string; updates: Record }>; + inferenceSessionSandboxName: string | null; + }>(result.stdout); - assert.ok( - payload.prompts.some((question) => question.includes("Sandbox name")), - "resume should prompt for the missing sandbox name before Hermes inference reconciliation", - ); - assert.ok( - payload.commands.some((entry) => - /inference set -g nemoclaw --no-verify --provider hermes-provider/.test(entry.command), - ), - "resume should reach openshell inference set", - ); - assert.ok(!payload.commands.some((entry) => /provider (create|update)/.test(entry.command))); - assert.equal( - payload.inferenceSessionSandboxName, - "hermes-resume", - "resume inference persists the canonical sandbox identity before sandbox creation", - ); - assert.ok( - payload.registryUpdates.some( - (call) => - call.name === "hermes-resume" && - call.updates.provider === "hermes-provider" && - call.updates.model === "moonshotai/kimi-k2.6", - ), - "Hermes setup should reconcile inference against the resolved sandbox name", - ); - }); + assert.ok( + payload.prompts.some((question) => question.includes("Sandbox name")), + "resume should prompt for the missing sandbox name before Hermes inference reconciliation", + ); + assert.ok( + payload.commands.some((entry) => + /inference set -g nemoclaw --no-verify --provider hermes-provider/.test(entry.command), + ), + "resume should reach openshell inference set", + ); + assert.ok(!payload.commands.some((entry) => /provider (create|update)/.test(entry.command))); + assert.equal( + payload.inferenceSessionSandboxName, + "hermes-resume", + "resume inference persists the canonical sandbox identity before sandbox creation", + ); + assert.ok( + payload.registryUpdates.some( + (call) => + call.name === "hermes-resume" && + call.updates.provider === "hermes-provider" && + call.updates.model === "moonshotai/kimi-k2.6", + ), + "Hermes setup should reconcile inference against the resolved sandbox name", + ); + }, + ); it("reconciles a registered Hermes Provider when a fresh shell Nous key is selected", async () => { await withProcessEnv( @@ -857,66 +870,6 @@ console.log(JSON.stringify({ } }); - it("detects when recorded policy presets are already applied", () => { - const repoRoot = path.join(import.meta.dirname, "../.."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-ready-")); - const registryDir = path.join(tmpDir, ".nemoclaw"); - const registryFile = path.join(registryDir, "sandboxes.json"); - const scriptPath = path.join(tmpDir, "policy-ready-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - registryFile, - JSON.stringify( - { - sandboxes: { - "my-assistant": { - name: "my-assistant", - policies: ["pypi", "npm"], - }, - }, - defaultSandbox: "my-assistant", - }, - null, - 2, - ), - ); - - fs.writeFileSync( - scriptPath, - ` -const { arePolicyPresetsApplied } = require(${onboardPath}); -console.log(JSON.stringify({ - ready: arePolicyPresetsApplied("my-assistant", ["pypi", "npm"]), - missing: arePolicyPresetsApplied("my-assistant", ["pypi", "slack"]), - empty: arePolicyPresetsApplied("my-assistant", []), -})); -`, - ); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - }, - }); - - try { - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout.trim()); - expect(payload).toEqual({ - ready: true, - missing: false, - empty: false, - }); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - it("uses native Anthropic provider creation without embedding the secret in argv", async () => { await withProcessEnv({ ANTHROPIC_API_KEY: "sk-ant-TEST-NOT-A-REAL-VALUE" }, async () => { const harness = createDirectSetupInferenceHarness({ @@ -1187,7 +1140,7 @@ describe("re-onboard Ollama GPU release (#9110)", () => { events.push("ownership-lock-exit"); return value; }, - withSandboxMutationLock: async (_name: string, operation: () => Promise | T) => { + withSandboxMutationLock: async (_name: string, operation: () => Promise | T) => { events.push("lock-enter"); const value = await operation(); events.push("lock-exit"); diff --git a/test/onboarding/onboard-inference-smoke.test.ts b/test/onboarding/onboard-inference-smoke.test.ts index ae91806e0c9..9f65bf1013f 100644 --- a/test/onboarding/onboard-inference-smoke.test.ts +++ b/test/onboarding/onboard-inference-smoke.test.ts @@ -126,7 +126,7 @@ const setupInference = createSetupInference({ [], { preferredInferenceApi: "openai-completions", - revalidatePolicyRequirements: () => {}, + verifyLivePolicyRequirements: () => {}, }, ); console.log(JSON.stringify({ outcome: "resolved", calls })); diff --git a/test/onboarding/onboard-installer-restore-intent.test.ts b/test/onboarding/onboard-installer-restore-intent.test.ts index 9238038f71e..b3d309eda5f 100644 --- a/test/onboarding/onboard-installer-restore-intent.test.ts +++ b/test/onboarding/onboard-installer-restore-intent.test.ts @@ -100,7 +100,7 @@ runner.runCapture = (command) => { return ""; }; fixtureMocks.mockDockerSandboxLifecycleReleaseFromRunner(); -const sourceEntry = fixtureMocks.managedSandboxPolicyReceiptFixture({ +const sourceEntry = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", gpuEnabled: false, toolDisclosure: "progressive", @@ -313,7 +313,7 @@ const mutations = []; const sourceEntry = { name: "my-assistant", agent: "openclaw", - gpuEnabled: false, policyAuthority: "nemoclaw-managed", + gpuEnabled: false, imageTag: "nemoclaw/my-assistant:1", toolDisclosure: "progressive", }; @@ -459,7 +459,7 @@ runner.runCapture = (command) => { } return ""; }; -registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ +registry.getSandbox = () => fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", gpuEnabled: false, toolDisclosure: "progressive", diff --git a/test/onboarding/onboard-mcp-observability-redirect.test.ts b/test/onboarding/onboard-mcp-observability-redirect.test.ts index a2ccd5a391d..bf4a09df1fa 100644 --- a/test/onboarding/onboard-mcp-observability-redirect.test.ts +++ b/test/onboarding/onboard-mcp-observability-redirect.test.ts @@ -50,7 +50,7 @@ runner.runCapture = (command) => { const mocked = require(${mocksPath}).mockOnboardRunCapture(command, { defaultCurlOutput: "ok" }); return mocked === null ? "" : mocked; }; -registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ +registry.getSandbox = () => fixtureMocks.sandboxLifecycleFixture({ name: "alpha", agent: "langchain-deepagents-code", model: "model", @@ -58,7 +58,6 @@ registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ preferredInferenceApi: "openai-completions", toolDisclosure: "progressive", observabilityEnabled: true, - policyAuthority: "nemoclaw-managed", mcp: { version: 1, bridges: { diff --git a/test/onboarding/onboard-messaging.test.ts b/test/onboarding/onboard-messaging.test.ts index ff15d2a24f2..320aa3c3462 100644 --- a/test/onboarding/onboard-messaging.test.ts +++ b/test/onboarding/onboard-messaging.test.ts @@ -189,7 +189,6 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); assert.ok(telegramProvider, "expected my-assistant-telegram-bridge provider create command"); assert.match(telegramProvider.command, /--credential TELEGRAM_BOT_TOKEN/); - // Verify sandbox create includes --provider flags for all three const createCommand = payload.commands.find((e: CommandEntry) => e.command.includes("sandbox create"), ); @@ -214,8 +213,6 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); ["wss-backup.slack.com", "wss-primary.slack.com"].sort(), ); - // Messaging tokens must NOT appear in the sandbox create command - // (they flow exclusively through the openshell provider credential system). assert.doesNotMatch(createCommand.command, /test-discord-token-value/); assert.doesNotMatch(createCommand.command, /123456:ABC-test-telegram-token/); assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); @@ -238,7 +235,6 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); assert.doesNotMatch(JSON.stringify(messagingPlan), /test-discord-token-value/); assert.doesNotMatch(JSON.stringify(messagingPlan), /123456:ABC-test-telegram-token/); - // Verify blocked credentials are NOT in the sandbox spawn environment assert.ok(createCommand.env, "expected env to be captured from spawn call"); assert.equal( createCommand.env.DISCORD_BOT_TOKEN, @@ -276,7 +272,6 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); "SSH_AUTH_SOCK must not be in sandbox env", ); - // Belt-and-suspenders: raw token values must not appear anywhere in env const envString = JSON.stringify(createCommand.env); assert.ok( !envString.includes("test-discord-token-value"), @@ -434,7 +429,7 @@ const { createSandbox } = require(${onboardPath}); policyPath: createCommand?.policyPath || "", policyReadError: createCommand?.policyReadError || null, }, - registeredPolicies: registeredSandbox?.policies || [], + registeredPolicyStatePresent: "policies" in (registeredSandbox || {}), slackBinaryPaths: (slack.binaries || []).map((entry) => entry.path), slackEndpointHosts: (slack.endpoints || []).map((entry) => entry.host), })); @@ -464,7 +459,7 @@ const { createSandbox } = require(${onboardPath}); assert.match(payload.createCommand.command, /--provider my-assistant-slack-app/); assert.match(payload.createCommand.policyPath, /nemoclaw-initial-policy/); assert.equal(payload.createCommand.policyReadError, null); - assert.deepEqual(payload.registeredPolicies, ["slack"]); + assert.equal(payload.registeredPolicyStatePresent, false); assert.deepEqual(payload.slackBinaryPaths, [ "/usr/local/bin/hermes", "/usr/bin/python3*", @@ -792,11 +787,11 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_SANDBOX_GPU: "0", NEMOCLAW_MESSAGING_PLAN_B64: messagingPlanB64, TELEGRAM_BOT_TOKEN: "", }, }); - assert.equal(result.status, 0, result.stderr || result.error?.message); const payload = parseStdoutJson(result.stdout); @@ -1287,7 +1282,7 @@ runner.runCapture = (command) => { if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running\nmy-assistant 127.0.0.1 8642 12346 running"; return ""; }; -registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture( +registry.getSandbox = () => fixtureMocks.sandboxLifecycleFixture( { name: "my-assistant", toolDisclosure: "progressive" }, { sandboxId: existingSandbox.state.sandboxId }, ); diff --git a/test/onboarding/onboard-policy-application-wiring.test.ts b/test/onboarding/onboard-policy-application-wiring.test.ts index 1dc98f1d16f..648072ea223 100644 --- a/test/onboarding/onboard-policy-application-wiring.test.ts +++ b/test/onboarding/onboard-policy-application-wiring.test.ts @@ -27,7 +27,7 @@ function restoreRequireCache(prior: Map): void { } describe("onboarding policy application production wiring", () => { - it("wires resume policy application to the sandbox registry, readiness checks, and sandbox mutation lock (#7695)", async () => { + it("wires resume policy application to live policy, readiness checks, and sandbox mutation lock (#7695)", async () => { const priorCache = new Map( Object.entries(require.cache).filter( (entry): entry is [string, NodeModule] => entry[1] !== undefined, @@ -70,7 +70,8 @@ describe("onboarding policy application production wiring", () => { const registryPath = require.resolve("../../src/lib/state/registry.js"); const lockPath = require.resolve("../../src/lib/state/mcp-lifecycle-lock.js"); const readinessPath = require.resolve("../../src/lib/onboard/sandbox-readiness-tracing.js"); - const finalFlowPath = require.resolve("../../src/lib/onboard/machine/final-flow-composition.js"); + const finalFlowPath = + require.resolve("../../src/lib/onboard/machine/final-flow-composition.js"); try { const policy = require(policyPath) as Record; @@ -130,19 +131,15 @@ describe("onboarding policy application production wiring", () => { expect(capturedDeps.waitForSandboxReady).toBe(waitForSandboxReady); expect(capturedDeps.waitForSandboxControlPlaneReady).toBe(waitForSandboxControlPlaneReady); - capturedDeps.setPolicyTier("beta", "balanced"); - expect(updateSandbox).toHaveBeenCalledWith("beta", { policyTier: "balanced" }); - await expect( application.setupPoliciesWithSelection("alpha", { selectedPresets: ["npm"] }), ).resolves.toEqual(["npm"]); - expect(getSandbox).toHaveBeenCalledWith("alpha"); + expect(getSandbox).not.toHaveBeenCalled(); expect(waitForSandboxReady).toHaveBeenCalledTimes(2); expect(waitForSandboxControlPlaneReady).toHaveBeenCalledOnce(); expect(syncPresetSelection).toHaveBeenCalledWith("alpha", [], ["npm"]); expect(events).toEqual([ "lock entered", - "registry tier read", "sandbox ready", "policies synchronized", "sandbox ready", diff --git a/test/onboarding/onboard-pre-destructive-intent.test.ts b/test/onboarding/onboard-pre-destructive-intent.test.ts index 22c294c032e..b477dcb9dfd 100644 --- a/test/onboarding/onboard-pre-destructive-intent.test.ts +++ b/test/onboarding/onboard-pre-destructive-intent.test.ts @@ -70,7 +70,7 @@ const resolved = { policy: { basePolicyPath: "/unused/policy.yaml", activeMessagingChannels: [], - options: { directGpu: false, additionalPresets: [], policyTier: null, baselineExclusions: [] }, + options: { directGpu: false, additionalPresets: [], policyTier: null }, }, gpuCreateArgs: [], resourceCreateArgs: [], diff --git a/test/onboarding/onboard-prepared-build-context.test.ts b/test/onboarding/onboard-prepared-build-context.test.ts index 37622f2f3ce..9224fc8e4be 100644 --- a/test/onboarding/onboard-prepared-build-context.test.ts +++ b/test/onboarding/onboard-prepared-build-context.test.ts @@ -94,9 +94,6 @@ const createFixture = fixtureMocks.installVerifiedSandboxCreateFixture(registry, }); const runner = require(${runnerPath}); const preflight = require(${preflightPath}); -const policyAuthorityPreflight = require(${JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "policy-authority", "preflight.ts"), - )}); const credentials = require(${credentialsPath}); const buildContextStage = require(${buildContextStagePath}); const dockerfilePatchFlow = require(${dockerfilePatchFlowPath}); @@ -186,9 +183,6 @@ runner.runCapture = (command) => { registry.getDefault = () => null; registry.listExtraProviders = () => []; preflight.checkPortAvailable = async () => ({ ok: true }); -policyAuthorityPreflight.qualifySandboxPolicyAuthority = () => ({ - authority: "nemoclaw-managed", -}); credentials.prompt = async () => ""; childProcess.spawn = (...args) => { diff --git a/test/onboarding/onboard-remote-recreate-credential-reuse.test.ts b/test/onboarding/onboard-remote-recreate-credential-reuse.test.ts index bede29e7861..aabe97add76 100644 --- a/test/onboarding/onboard-remote-recreate-credential-reuse.test.ts +++ b/test/onboarding/onboard-remote-recreate-credential-reuse.test.ts @@ -126,7 +126,7 @@ const { setupNim, setupInference } = require(${onboardPath}); selected.hermesToolGateways, { preferredInferenceApi: selected.preferredInferenceApi, - revalidatePolicyRequirements: () => {}, + verifyLivePolicyRequirements: () => {}, skipHostInferenceSmoke: selected.skipHostInferenceSmoke, reuseGatewayCredentialWithoutLocalKey: process.env.NEMOCLAW_TEST_OMIT_REUSE_AUTHORIZATION === "1" diff --git a/test/onboarding/onboard-reservation-recreate.test.ts b/test/onboarding/onboard-reservation-recreate.test.ts index 9e2c7ff16de..222b5f06f7e 100644 --- a/test/onboarding/onboard-reservation-recreate.test.ts +++ b/test/onboarding/onboard-reservation-recreate.test.ts @@ -179,12 +179,6 @@ const createFixture = fixtureMocks.installVerifiedSandboxCreateFixture(registry, const preflight = require(${JSON.stringify(path.join(repoRoot, "src", "lib", "onboard", "preflight.ts"))}); preflight.checkPortAvailable = async () => ({ ok: true }); -const policyAuthorityPreflight = require(${JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "policy-authority", "preflight.ts"), - )}); -policyAuthorityPreflight.qualifySandboxPolicyAuthority = () => ({ - authority: "nemoclaw-managed", -}); childProcess.spawn = (...args) => { const command = _n([args[0], ...(Array.isArray(args[1]) ? args[1] : [])]); @@ -415,7 +409,6 @@ if (mode === "seed") { toolDisclosure: "progressive", dcodeAutoApprovalMode: null, observabilityEnabled: false, - policyTier: null, }, }); } @@ -434,16 +427,15 @@ if (mode === "resume" && scenario === "foreign-reservation") { registry.save(data); } if (mode === "resume" && scenario === "changed-checkpoint") { - const requireCurrent = registry.requireCurrentPendingSandboxPolicyVerification; + const requireCurrent = registry.requireCurrentPendingSandboxCreateIdentity; let reads = 0; - registry.requireCurrentPendingSandboxPolicyVerification = (reservation, checkpoint) => { + registry.requireCurrentPendingSandboxCreateIdentity = (reservation, checkpoint) => { const current = requireCurrent(reservation, checkpoint); reads += 1; if (reads === 1) { const data = registry.load(); - const changed = data.sandboxes["my-assistant"].pendingPolicyVerification; - changed.policyVersion += 1; - changed.policyCreationReceipt.policyVersion += 1; + const changed = data.sandboxes["my-assistant"].pendingCreateIdentity; + changed.route = changed.route === "native" ? "none" : "native"; registry.save(data); } return current; @@ -578,14 +570,14 @@ createArgs[16] = async () => { error: string; registryEntry: { pendingRouteReservation?: boolean; - pendingPolicyVerification?: unknown; + pendingCreateIdentity?: unknown; lifecycleLiveIdentityFingerprint?: string; }; journal: { phase: string; targetLiveIdentityFingerprint?: string }; }>(first.stdout); assert.match(retained.error, /automatic sandbox cleanup was not safe/u); assert.equal(retained.registryEntry.pendingRouteReservation, true); - assert.ok(retained.registryEntry.pendingPolicyVerification); + assert.ok(retained.registryEntry.pendingCreateIdentity); assert.match( retained.registryEntry.lifecycleLiveIdentityFingerprint ?? "", /^[0-9a-f]{64}$/u, @@ -606,8 +598,7 @@ createArgs[16] = async () => { error: string | null; registryEntry: { pendingRouteReservation?: boolean; - pendingPolicyVerification?: unknown; - policyAuthority?: string; + pendingCreateIdentity?: unknown; }; }>(second.stdout); const createEvents = fs @@ -627,11 +618,7 @@ createArgs[16] = async () => { ); assert.equal(recovered.sandboxName, resumes ? "my-assistant" : null); assert.equal(recovered.registryEntry.pendingRouteReservation, resumes ? undefined : true); - assert.equal(Boolean(recovered.registryEntry.pendingPolicyVerification), !resumes); - assert.equal( - recovered.registryEntry.policyAuthority, - resumes ? "nemoclaw-managed" : undefined, - ); + assert.equal(Boolean(recovered.registryEntry.pendingCreateIdentity), !resumes); assert.deepEqual(effectEvents, resumes ? ["seed", "resume"] : ["seed"]); }, ); diff --git a/test/onboarding/onboard-sandbox-recreation.test.ts b/test/onboarding/onboard-sandbox-recreation.test.ts index a406ebd856c..e8bdd24a219 100644 --- a/test/onboarding/onboard-sandbox-recreation.test.ts +++ b/test/onboarding/onboard-sandbox-recreation.test.ts @@ -66,7 +66,7 @@ runner.run = (command) => { if (cmd.includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; - registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ + registry.getSandbox = () => fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", toolDisclosure: "progressive", }, { sandboxId: createdSandbox.state.sandboxId }); @@ -114,7 +114,7 @@ const { createSandbox } = require(${onboardPath}); ); it.each(["balanced", "restricted"])( - "recreate-sandbox records the %s policy tier and late replacement identity", + "recreate-sandbox uses the requested %s tier without recording it", { timeout: 60_000, }, @@ -144,7 +144,7 @@ const { EventEmitter } = require("node:events"); const commands = []; let registeredSandbox = null; const createdSandbox = fixtureMocks.createCreatedSandboxFixture({ lifecycleState: "created" }); const sourceSandboxId = createdSandbox.state.sandboxId; - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ + const sourceSandbox = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", gpuEnabled: false, openshellDriver: "docker", @@ -252,9 +252,13 @@ const { createSandbox } = require(${onboardPath}); "should delete existing sandbox when --recreate-sandbox is set", ); assert.ok( - payload.commands.some((entry: CommandEntry) => entry.command.includes("sandbox create")) && - payload.registeredSandbox?.policyTier === policyTier, - "should create a sandbox and persist its tier before policy finalization", + payload.commands.some((entry: CommandEntry) => entry.command.includes("sandbox create")), + "should create a replacement sandbox", + ); + assert.ok(payload.registeredSandbox, "should register the replacement sandbox"); + assert.ok( + !("policyTier" in payload.registeredSandbox), + "the registry must not persist a policy tier", ); assert.ok( !payload.commands.some((entry: CommandEntry) => @@ -335,7 +339,7 @@ runner.run = (command) => { } return ""; }; - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ + const sourceSandbox = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", gpuEnabled: false, }, { sandboxId: createdSandbox.state.sandboxId }); @@ -514,7 +518,7 @@ runner.run = (command) => { } return ""; }; - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ + const sourceSandbox = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", gpuEnabled: false, }, { sandboxId: createdSandbox.state.sandboxId }); @@ -664,7 +668,7 @@ runner.run = (command) => { } return ""; }; - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ + const sourceSandbox = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", gpuEnabled: false, }, { sandboxId: createdSandbox.state.sandboxId }); @@ -776,148 +780,6 @@ const { createSandbox } = require(${onboardPath}); }, ); - it( - "recreating a sandbox preserves the user's policy preset selections", - { - timeout: 60_000, - }, - async () => { - const repoRoot = path.join(import.meta.dirname, "../.."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-recreate-preserves-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "recreate-preserves.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const registryPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "state", "registry.ts"), - ); - const sessionModulePath = JSON.stringify( - path.join(repoRoot, "src", "lib", "state", "onboard-session.ts"), - ); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); - - const script = String.raw` - const runner = require(${runnerPath}); - const fixtureMocks = require(${onboardScriptMocksPath}); - fixtureMocks.mockStandaloneGatewayTeardownAuthority(); - const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); -const registry = require(${registryPath}); -const onboardSession = require(${sessionModulePath}); -const childProcess = require("node:child_process"); -const { EventEmitter } = require("node:events"); - -const commands = []; -const createdSandbox = fixtureMocks.createCreatedSandboxFixture({ lifecycleState: "created" }); -runner.run = (command, opts = {}) => { - const cmd = _n(command); - const profileResult = require(${onboardScriptMocksPath}).mockEndpointlessProviderProfileRun(command, "nemoclaw-mcp-v1", false); - if (profileResult !== null) return profileResult; - if (cmd.includes("sandbox delete") && createdSandbox.state.lifecycleState === "created") createdSandbox.delete(); - commands.push({ command: cmd, env: opts.env || null }); - return createdSandbox.run(command) ?? { status: 0 }; -}; - runner.runCapture = (command) => { - const cmd = _n(command); - if (cmd.includes("gateway info")) return "Gateway endpoint: http://127.0.0.1:8080"; - if (cmd.includes("policy get") && cmd.includes("--output json")) return JSON.stringify({ scope: "sandbox", sandbox: "my-assistant", status: "effective", policy_source: "sandbox", hash: "fixture-policy", active_version: 1, policy: {} }); - const createdIdentity = createdSandbox.capture(command); - if (createdIdentity !== null) return createdIdentity; - if (cmd.includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; - { - const mockedCapture = require(${onboardScriptMocksPath}).mockOnboardRunCapture(command, { - defaultCurlOutput: "ok", - }); - if (mockedCapture !== null) return mockedCapture; - } - return ""; -}; - -// Existing sandbox has a custom preset selection: only "npm" (not the -// full "balanced" tier). Recreating the sandbox must preserve this -// customisation rather than reverting to the tier defaults. - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ - name: "my-assistant", - gpuEnabled: false, - policies: ["npm"], - policyTier: "balanced", - }, { sandboxId: createdSandbox.state.sandboxId }); - registry.getSandbox = () => sourceSandbox; - const createFixture = fixtureMocks.installVerifiedSandboxCreateFixture(registry, { - sandboxName: "my-assistant", - provider: "nvidia-prod", - model: "gpt-5.4", - getSandbox: registry.getSandbox, - }); - -const preflight = require(${JSON.stringify(path.join(repoRoot, "src", "lib", "onboard", "preflight.ts"))}); -preflight.checkPortAvailable = async () => ({ ok: true }); - - childProcess.spawn = (...args) => { - const command = _n([args[0], ...(Array.isArray(args[1]) ? args[1] : [])]); - if (command.includes("sandbox create") && createdSandbox.state.lifecycleState === "deleted") createdSandbox.recreate(args.flat()); - const child = new EventEmitter(); - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.unref = () => {}; - child.pid = 4242; - commands.push({ command, env: args[2]?.env || null }); - process.nextTick(() => { - child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); - child.emit("close", 0); - }); - return child; -}; - -const { createSandbox } = require(${onboardPath}); - -(async () => { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - process.env.NEMOCLAW_RECREATE_SANDBOX = "1"; - process.env.NEMOCLAW_RECREATE_WITHOUT_BACKUP = "1"; - await createSandbox(...fixtureMocks.sandboxCreateArgsWithVerifiedReservation( - [null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, null, null, null, null, null, null, []], - createFixture, - )); - const session = onboardSession.loadSession(); - console.log(JSON.stringify({ policyPresets: session && session.policyPresets })); -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); - - assert.deepEqual( - payload.policyPresets, - ["npm"], - "createSandbox should write the previous sandbox's policy presets to the onboard session before destroying it so they can be reapplied after recreation", - ); - }, - ); - it( "interactive mode prompts before reusing an existing ready sandbox", { @@ -991,7 +853,7 @@ runner.runFile = (file, args = [], opts = {}) => { if (cmd.includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; - registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ + registry.getSandbox = () => fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", toolDisclosure: "progressive", }, { sandboxId: createdSandbox.state.sandboxId }); @@ -1147,7 +1009,7 @@ runner.runFile = (file, args = [], opts = {}) => { } return ""; }; - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ + const sourceSandbox = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", toolDisclosure: "progressive", }, { sandboxId: createdSandbox.state.sandboxId }); @@ -1304,7 +1166,7 @@ runner.run = (command, opts = {}) => { } return ""; }; - const sourceSandbox = fixtureMocks.managedSandboxPolicyReceiptFixture({ + const sourceSandbox = fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", toolDisclosure: "progressive", }, { sandboxId: createdSandbox.state.sandboxId }); diff --git a/test/onboarding/onboard-terminal-dashboard.test.ts b/test/onboarding/onboard-terminal-dashboard.test.ts index 81a66bee850..97d6ea9e687 100644 --- a/test/onboarding/onboard-terminal-dashboard.test.ts +++ b/test/onboarding/onboard-terminal-dashboard.test.ts @@ -14,10 +14,6 @@ type CommandEntry = { env?: Record | null; }; -function writeExecutable(target: string, contents: string) { - fs.writeFileSync(target, contents, { mode: 0o755 }); -} - function parseStdoutJson(stdout: string): T { const line = stdout .trim() @@ -164,7 +160,7 @@ runner.runCapture = (command) => { registry.getSandbox = () => scenario === "reuse" - ? fixtureMocks.managedSandboxPolicyReceiptFixture({ + ? fixtureMocks.sandboxLifecycleFixture({ name: sandboxName, gpuEnabled: false, agent: "langchain-deepagents-code", diff --git a/test/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index 1e088eeb578..8b3747269a1 100644 --- a/test/onboarding/onboard.test.ts +++ b/test/onboarding/onboard.test.ts @@ -532,7 +532,6 @@ startGateway(null).catch((error) => { messaging: true, resourceProfile: true, }, - policyPresets: ["nous-web", "brave"], lastCompletedStep: "policies", lastStepStarted: "policies", steps: { @@ -570,7 +569,6 @@ startGateway(null).catch((error) => { messaging: false, resourceProfile: true, }); - expect(cleared.policyPresets).toBeNull(); expect(cleared.steps.gateway.status).toBe("complete"); expect(cleared.steps.provider_selection.status).toBe("pending"); expect(cleared.steps.sandbox.status).toBe("pending"); @@ -704,7 +702,7 @@ runner.runCapture = (command) => { if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; - registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ + registry.getSandbox = () => fixtureMocks.sandboxLifecycleFixture({ name: "my-assistant", toolDisclosure: "progressive", }, { sandboxId: existingSandbox.state.sandboxId }); diff --git a/test/package-contract/cli/debug-cli-command.test.ts b/test/package-contract/cli/debug-cli-command.test.ts index c7caffa9ef2..37577058436 100644 --- a/test/package-contract/cli/debug-cli-command.test.ts +++ b/test/package-contract/cli/debug-cli-command.test.ts @@ -64,7 +64,6 @@ describe("compiled diagnostics CLI", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: "alpha", diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index af9433119bc..1d167188dda 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -55,71 +55,6 @@ process.stdout.write("__RESULT__" + JSON.stringify({ expect(payload.hermesKeys).toEqual(["telegram"]); }); - describe("policy-remove custom presets", () => { - function runPolicyRemoveCustom( - presetName: string, - extraArgs: string[] = [], - envOverrides: Record = {}, - ) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-remove-custom-")); - const scriptPath = path.join(tmpDir, "policy-remove-custom-check.js"); - const script = String.raw` -const registry = require(${REGISTRY_PATH}); -const policies = require(${POLICIES_PATH}); -const credentials = require(${CREDENTIALS_PATH}); -const calls = []; -// No built-in matches. -policies.listPresets = () => []; -policies.listCustomPresets = () => [ - { file: "/tmp/my-api.yaml", name: "my-api", description: "custom preset" }, -]; -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 }); - return true; -}; -registry.getSandbox = (name) => - name === "test-sandbox" ? { name, policies: [], customPolicies: [] } : null; -registry.getCustomPolicies = () => [ - { name: "my-api", content: "network_policies:\n my-api: {}\n", sourcePath: "/tmp/my-api.yaml" }, -]; -registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); -credentials.prompt = async () => "y"; -process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-remove", ${JSON.stringify(presetName)}, ...${JSON.stringify(extraArgs)}]; -Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => { - process.stdout.write("\n__CALLS__" + JSON.stringify(calls)); -}); -`; - fs.writeFileSync(scriptPath, script); - return spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf-8", - env: { ...process.env, HOME: tmpDir, ...envOverrides }, - }); - } - - it("removes a custom preset by name using registry-persisted content", () => { - const result = runPolicyRemoveCustom("my-api", ["--yes"]); - expect(result.status).toBe(0); - const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; - expect(calls).toContainEqual({ - type: "remove", - sandboxName: "test-sandbox", - presetName: "my-api", - }); - expect(result.stdout).toMatch(/api\.example\.internal/); - }); - - it("rejects an unknown preset name even when no built-ins are defined", () => { - const result = runPolicyRemoveCustom("bogus", ["--yes"]); - expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/Unknown preset 'bogus'/); - }); - }); - describe("policy-add --from-file / --from-dir", () => { function runPolicyAddExternal( extraArgs: string[] = [], diff --git a/test/package-contract/cli/policy-prompt-eof.test.ts b/test/package-contract/cli/policy-prompt-eof.test.ts index c1601ddef59..7d9cf93e8f2 100644 --- a/test/package-contract/cli/policy-prompt-eof.test.ts +++ b/test/package-contract/cli/policy-prompt-eof.test.ts @@ -44,7 +44,7 @@ policies.listPresets = () => [ policies.listCustomPresets = () => []; policies.getAppliedPresets = () => ["npm"]; registry.getSandbox = (name) => - name === "test-sandbox" ? { name, policies: ["npm"], customPolicies: [] } : null; + name === "test-sandbox" ? { name } : null; registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); process.argv = ["node", "nemoclaw.js", "test-sandbox", ${JSON.stringify(command)}]; require(${CLI_PATH}); @@ -75,23 +75,23 @@ describe("policy preset prompt cancellation", () => { menu: "Applied presets:", usage: "policy remove ", }, - ])("$command exits non-zero before opening a picker without a terminal (#7418)", ({ - command, - menu, - usage, - }) => { - const result = runPolicyCommandAtStdinEof(command); + ])( + "$command exits non-zero before opening a picker without a terminal (#7418)", + ({ command, menu, usage }) => { + const result = runPolicyCommandAtStdinEof(command); - // The child exited on its own rather than being killed by the defensive - // timeout above. A hang would produce SIGKILL and a null status; the - // pre-#7418 regression exited 0 and is caught by the final assertion. - expect(result.error).toBeUndefined(); - expect(result.signal).toBeNull(); - expect(result.stderr).not.toContain(menu); - expect(result.stderr).toContain("No input available on stdin"); - expect(result.stderr).toContain(usage); - expect(result.status).toBe(1); - // Above the child's 30s cap, so any hang fails on the assertions above - // rather than as a bare suite timeout. - }, 45_000); + // The child exited on its own rather than being killed by the defensive + // timeout above. A hang would produce SIGKILL and a null status; the + // pre-#7418 regression exited 0 and is caught by the final assertion. + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.stderr).not.toContain(menu); + expect(result.stderr).toContain("No input available on stdin"); + expect(result.stderr).toContain(usage); + expect(result.status).toBe(1); + // Above the child's 30s cap, so any hang fails on the assertions above + // rather than as a bare suite timeout. + }, + 45_000, + ); }); diff --git a/test/package-contract/cli/policy-restore-acknowledgement.test.ts b/test/package-contract/cli/policy-restore-acknowledgement.test.ts index 7426ef72be6..fab0f2cc179 100644 --- a/test/package-contract/cli/policy-restore-acknowledgement.test.ts +++ b/test/package-contract/cli/policy-restore-acknowledgement.test.ts @@ -36,8 +36,6 @@ const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); registry.getSandbox = (name) => (name === "test-sandbox" ? { name, agent: "hermes" } : null); registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] }); -registry.getBaselineExclusions = () => [{ key: "npm_registry", digest: "digest-1" }]; -registry.getBaselineExclusionTransition = () => null; policies.resolveSandboxBaselinePolicy = () => ({ agent: "hermes", policyPath: "/policy-additions.yaml", diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts index 75443d42e10..ea2379beff2 100644 --- a/test/package-contract/openshell-policy-boundary.test.ts +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -64,19 +64,19 @@ describe("OpenShell policy boundary package contract", () => { it("routes the CommonJS CLI and ESM plugin through one canonical CJS boundary", async () => { const cliPolicy = require("../../dist/lib/policy/merge.js") as { - assertExternalPolicyRequirementContainment: (...args: unknown[]) => void; - assertMatchingPolicyAuthority: (recorded: unknown, observed: unknown) => void; + assertPolicyRequirementContainment: (...args: unknown[]) => void; parseOpenShellPolicy: (raw: string) => { yamlBody: string; policy: Record; }; - parseActiveGlobalPolicyAuthorityMetadata: ( - raw: string, - ) => { state: string; inspection?: { authority: string } }; - parseSandboxPolicyAuthorityMetadata: ( + parseActiveGlobalPolicyMetadata: (raw: string) => { + state: string; + inspection?: { policySource: string }; + }; + parseSandboxPolicyMetadata: ( raw: string, sandboxName: string, - ) => { authority: string; effectivePolicy: Record }; + ) => { policySource: string; effectivePolicy: Record }; withoutProviderComposedPolicies: ( policies: Record, ) => Record; @@ -91,14 +91,13 @@ describe("OpenShell policy boundary package contract", () => { path.join(repoRoot, "nemoclaw", "dist", "shared", "openshell-policy-boundary.cjs"), ).href )) as { - assertExternalPolicyRequirementContainment: typeof cliPolicy.assertExternalPolicyRequirementContainment; - assertMatchingPolicyAuthority: typeof cliPolicy.assertMatchingPolicyAuthority; + assertPolicyRequirementContainment: typeof cliPolicy.assertPolicyRequirementContainment; parseOpenShellPolicy: (raw: string) => { yamlBody: string; policy: Record; }; - parseActiveGlobalPolicyAuthorityMetadata: typeof cliPolicy.parseActiveGlobalPolicyAuthorityMetadata; - parseSandboxPolicyAuthorityMetadata: typeof cliPolicy.parseSandboxPolicyAuthorityMetadata; + parseActiveGlobalPolicyMetadata: typeof cliPolicy.parseActiveGlobalPolicyMetadata; + parseSandboxPolicyMetadata: typeof cliPolicy.parseSandboxPolicyMetadata; withoutProviderComposedPolicies: ( policies: Record, ) => Record; @@ -106,11 +105,10 @@ describe("OpenShell policy boundary package contract", () => { }; const canonicalBoundary = require("../../nemoclaw/dist/shared/openshell-policy-boundary.cjs") as { - assertExternalPolicyRequirementContainment: typeof cliPolicy.assertExternalPolicyRequirementContainment; - assertMatchingPolicyAuthority: typeof cliPolicy.assertMatchingPolicyAuthority; - parseActiveGlobalPolicyAuthorityMetadata: typeof cliPolicy.parseActiveGlobalPolicyAuthorityMetadata; + assertPolicyRequirementContainment: typeof cliPolicy.assertPolicyRequirementContainment; + parseActiveGlobalPolicyMetadata: typeof cliPolicy.parseActiveGlobalPolicyMetadata; parseOpenShellPolicy: typeof cliPolicy.parseOpenShellPolicy; - parseSandboxPolicyAuthorityMetadata: typeof cliPolicy.parseSandboxPolicyAuthorityMetadata; + parseSandboxPolicyMetadata: typeof cliPolicy.parseSandboxPolicyMetadata; stripProviderComposedPolicies: typeof cliPolicy.stripProviderComposedPolicies; }; expect( @@ -136,17 +134,12 @@ describe("OpenShell policy boundary package contract", () => { expect(cliPolicy.stripProviderComposedPolicies).toBe( canonicalBoundary.stripProviderComposedPolicies, ); - expect(cliPolicy.parseActiveGlobalPolicyAuthorityMetadata).toBe( - canonicalBoundary.parseActiveGlobalPolicyAuthorityMetadata, - ); - expect(cliPolicy.parseSandboxPolicyAuthorityMetadata).toBe( - canonicalBoundary.parseSandboxPolicyAuthorityMetadata, + expect(cliPolicy.parseActiveGlobalPolicyMetadata).toBe( + canonicalBoundary.parseActiveGlobalPolicyMetadata, ); - expect(cliPolicy.assertMatchingPolicyAuthority).toBe( - canonicalBoundary.assertMatchingPolicyAuthority, - ); - expect(cliPolicy.assertExternalPolicyRequirementContainment).toBe( - canonicalBoundary.assertExternalPolicyRequirementContainment, + expect(cliPolicy.parseSandboxPolicyMetadata).toBe(canonicalBoundary.parseSandboxPolicyMetadata); + expect(cliPolicy.assertPolicyRequirementContainment).toBe( + canonicalBoundary.assertPolicyRequirementContainment, ); const sandboxMetadata = JSON.stringify({ scope: "sandbox", @@ -157,8 +150,8 @@ describe("OpenShell policy boundary package contract", () => { active_version: 1, policy: { version: 1, network_policies: {} }, }); - expect(pluginBoundary.parseSandboxPolicyAuthorityMetadata(sandboxMetadata, "alpha")).toEqual( - canonicalBoundary.parseSandboxPolicyAuthorityMetadata(sandboxMetadata, "alpha"), + expect(pluginBoundary.parseSandboxPolicyMetadata(sandboxMetadata, "alpha")).toEqual( + canonicalBoundary.parseSandboxPolicyMetadata(sandboxMetadata, "alpha"), ); const globalMetadata = JSON.stringify({ scope: "global", @@ -168,8 +161,8 @@ describe("OpenShell policy boundary package contract", () => { active_version: 1, policy: { version: 1, network_policies: {} }, }); - expect(pluginBoundary.parseActiveGlobalPolicyAuthorityMetadata(globalMetadata)).toEqual( - canonicalBoundary.parseActiveGlobalPolicyAuthorityMetadata(globalMetadata), + expect(pluginBoundary.parseActiveGlobalPolicyMetadata(globalMetadata)).toEqual( + canonicalBoundary.parseActiveGlobalPolicyMetadata(globalMetadata), ); const pluginRunner = await import( @@ -302,10 +295,7 @@ describe("OpenShell policy boundary package contract", () => { it("ships the complete repository-owned NemoCUA agent definition (#9649)", () => { expect(packageFiles(repoRoot)).toEqual( - expect.arrayContaining([ - "agents/nemocua/Dockerfile", - "agents/nemocua/policy-additions.yaml", - ]), + expect.arrayContaining(["agents/nemocua/Dockerfile", "agents/nemocua/policy-additions.yaml"]), ); expect(packedPaths).toContain("agents/nemocua/manifest.yaml"); expect(packedPaths).toContain("agents/nemocua/Dockerfile"); diff --git a/test/package-contract/repro-2010.test.ts b/test/package-contract/repro-2010.test.ts index d1747501a92..e934b8d360d 100644 --- a/test/package-contract/repro-2010.test.ts +++ b/test/package-contract/repro-2010.test.ts @@ -266,15 +266,16 @@ require(${JSON.stringify(CLI_PATH)}); } } - it("shows ● with gateway-desync suffix when gateway has telegram but registry does not", () => { + it("shows the live OpenShell preset without a registry-desync suffix", () => { const output = runPolicyList({ registryPresets: [], gatewayPresets: ["telegram"] }); - expect(output).toMatch(/●.*telegram.*active on gateway, missing from local state/); + expect(output).toMatch(/●.*telegram.*user-added/); expect(output).toMatch(/○.*npm/); }); - it("shows ○ with registry-desync suffix when registry has telegram but gateway does not", () => { + it("ignores a legacy registry-only preset", () => { const output = runPolicyList({ registryPresets: ["telegram"], gatewayPresets: [] }); - expect(output).toMatch(/○.*telegram.*recorded locally, not active on gateway/); + expect(output).toMatch(/○.*telegram/); + expect(output).not.toContain("recorded locally"); }); it("shows ● with no suffix when both sources agree", () => { @@ -284,11 +285,11 @@ require(${JSON.stringify(CLI_PATH)}); expect(output).not.toContain("recorded locally"); }); - it("falls back to registry-only display with warning when gateway is unreachable", () => { + it("does not fall back to registry policy state when OpenShell is unreachable", () => { const output = runPolicyList({ registryPresets: ["telegram"], gatewayPresets: null }); - expect(output).toMatch(/●.*telegram/); - expect(output).toContain("Could not query gateway"); - expect(output).not.toContain("active on gateway"); + expect(output).toMatch(/○.*telegram/); + expect(output).toContain("Could not query OpenShell"); + expect(output).not.toContain("local state only"); }); }); }); diff --git a/test/process-recovery/rebuild-stale-recovery.test.ts b/test/process-recovery/rebuild-stale-recovery.test.ts index 8b775f39d07..3179ad5238b 100644 --- a/test/process-recovery/rebuild-stale-recovery.test.ts +++ b/test/process-recovery/rebuild-stale-recovery.test.ts @@ -18,9 +18,10 @@ * sandbox was absent — which is precisely the stale-recovery state. That left * the recommended recovery path dead-ended. * - * This suite asserts that `rebuild --yes` now treats a registered-but-not-live - * sandbox as a recovery rebuild: it skips the (impossible) backup, reports the - * stale state, and proceeds to recreate from the preserved registry metadata. + * OpenShell is now the sole policy authority, so a missing live sandbox also + * means there is no authoritative policy to carry into its replacement. This + * suite asserts that `rebuild --yes` reports that condition and preserves the + * registry state instead of reconstructing policy from NemoClaw metadata. */ import { describe, expect, it } from "vitest"; @@ -32,7 +33,7 @@ import { installRebuildFlowTestHooks(); -describe("stale sandbox rebuild recovery (#4497)", () => { +describe("stale sandbox rebuild safety (#4497)", () => { it("still backs up normally when the live sandbox IS present (control case)", async () => { const harness = createRebuildFlowHarness(); @@ -48,7 +49,7 @@ describe("stale sandbox rebuild recovery (#4497)", () => { expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); }); - it("recreates an absent sandbox from its preserved registry metadata", async () => { + it("refuses to recreate an absent sandbox without its authoritative live policy", async () => { const harness = createRebuildFlowHarness({ staleRecovery: true, onboard: () => undefined, @@ -56,33 +57,23 @@ describe("stale sandbox rebuild recovery (#4497)", () => { await expect( harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); + ).rejects.toThrow("Cannot rebuild an absent sandbox without its authoritative OpenShell policy"); - const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + const output = [...harness.logSpy.mock.calls, ...harness.errorSpy.mock.calls] + .map((call) => String(call[0])) + .join("\n"); expect(output).toContain("absent from the live OpenShell gateway"); - expect(output).toContain("No live workspace state to back up"); - expect(output).toContain("Creating new sandbox with current image"); - expect(output).toContain("rebuilt successfully"); - expect(output).toContain("Recovered from a stale registry entry"); + expect(output).toContain("Rebuild cannot recover its missing OpenShell policy"); + expect(output).toContain("nemoclaw alpha destroy --yes"); + expect(output).toContain("nemoclaw onboard"); + expect(output).not.toContain("Creating new sandbox with current image"); + expect(output).not.toContain("rebuilt successfully"); expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); expect(harness.restoreSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.prepareMcpBridgesForAbsentSandboxRebuildSpy).toHaveBeenCalledWith("alpha"); - expect(harness.onboardSpy).toHaveBeenCalledOnce(); - expect(harness.onboardSpy).toHaveBeenCalledWith( - expect.objectContaining({ - resume: true, - nonInteractive: true, - recreateSandbox: true, - authoritativeResumeConfig: true, - autoYes: true, - controlUiPort: 18789, - targetGatewayName: "nemoclaw", - targetGatewayPort: 8080, - }), - ); - // The journaled source row is the durable replacement contract, so it is - // preserved until replacement registration commits (#7734). + expect(harness.prepareMcpBridgesForAbsentSandboxRebuildSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expectNoSandboxDelete(harness.runOpenshellSpy); expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); expect(harness.restoreSandboxEntrySpy).not.toHaveBeenCalled(); expect(harness.restoreSandboxEntryIfMissingSpy).not.toHaveBeenCalled(); @@ -153,7 +144,7 @@ describe("stale sandbox rebuild recovery (#4497)", () => { expect(harness.onboardSpy).not.toHaveBeenCalled(); }); - it("preserves retryable metadata when stale recovery recreate fails (#4497)", async () => { + it("preserves retryable metadata when stale recovery is refused (#4497)", async () => { const harness = createRebuildFlowHarness({ defaultSandbox: "alpha", staleRecovery: true, @@ -164,7 +155,7 @@ describe("stale sandbox rebuild recovery (#4497)", () => { await expect( harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Recreate failed"); + ).rejects.toThrow("Cannot rebuild an absent sandbox without its authoritative OpenShell policy"); const output = [...harness.logSpy.mock.calls, ...harness.errorSpy.mock.calls] .map((call) => String(call[0])) @@ -172,25 +163,18 @@ describe("stale sandbox rebuild recovery (#4497)", () => { expect(output).not.toContain("Cannot back up state"); expect(output).toContain("absent from the live OpenShell gateway"); - expect(output).toContain("No live workspace state to back up"); + expect(output).toContain("Rebuild cannot recover its missing OpenShell policy"); + expect(output).toContain("nemoclaw alpha destroy --yes"); expect(output).not.toContain("Backing up sandbox state"); - expect(output).toContain("Creating new sandbox with current image"); - expect(output).toContain("Recovery recreate failed"); + expect(output).not.toContain("Creating new sandbox with current image"); expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).toHaveBeenCalledOnce(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expectNoSandboxDelete(harness.runOpenshellSpy); - // Rollback restores the journaled source snapshot without the ordinary - // removal receipt, so the same rebuild command remains retryable. + // The command stops before mutating either the sandbox or its registry row, + // so the same recovery command remains retryable if live policy returns. expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); - expect(harness.restoreSandboxEntrySpy).toHaveBeenCalledOnce(); - expect(harness.restoreSandboxEntrySpy).toHaveBeenCalledWith( - expect.objectContaining({ name: "alpha" }), - {}, - ); - const [restoredEntry] = harness.restoreSandboxEntrySpy.mock.calls[0] as [ - Record, - ]; - expect(restoredEntry.imageTag ?? null).toBeNull(); + expect(harness.restoreSandboxEntrySpy).not.toHaveBeenCalled(); expect(harness.restoreSandboxEntryIfMissingSpy).not.toHaveBeenCalled(); expect(harness.getDefaultSelectionState()).toEqual({ defaultSandbox: "alpha", diff --git a/test/repository/vitest-watch-triggers.test.ts b/test/repository/vitest-watch-triggers.test.ts index ea4dd8fe4e4..4b3792aefb1 100644 --- a/test/repository/vitest-watch-triggers.test.ts +++ b/test/repository/vitest-watch-triggers.test.ts @@ -116,30 +116,30 @@ function triggeredBy(relativePath: string): string[] { } describe("Vitest opaque-input watch triggers", () => { - it.each([ - "test/helpers/onboard-fixture-contract.json", - "test/helpers/onboard-script-mocks.cjs", - ])("maps %s to every sandbox identity consumer (#10463)", (fixturePath) => { - expect(triggeredBy(fixturePath)).toEqual([ - "test/helpers/onboard-created-sandbox-fixture.test.ts", - "test/onboarding/onboard-custom-dockerfile.test.ts", - "test/onboarding/onboard-extra-provider-reconciliation.test.ts", - "test/onboarding/onboard-fresh-create-identity.test.ts", - "test/onboarding/onboard-installer-restore-intent.test.ts", - "test/onboarding/onboard-managed-image-buildless-e2e.test.ts", - "test/onboarding/onboard-mcp-observability-redirect.test.ts", - "test/onboarding/onboard-messaging.test.ts", - "test/onboarding/onboard-prepared-build-context.test.ts", - "test/onboarding/onboard-reservation-recreate.test.ts", - "test/onboarding/onboard-sandbox-build.test.ts", - "test/onboarding/onboard-sandbox-recreation.test.ts", - "test/onboarding/onboard-script-mocks-contract.test.ts", - "test/onboarding/onboard-terminal-dashboard.test.ts", - "test/onboarding/onboard.test.ts", - "test/security/shellquote-sandbox.test.ts", - "test/repository/source-require-loader.test.ts", - ]); - }); + it.each(["test/helpers/onboard-fixture-contract.json", "test/helpers/onboard-script-mocks.cjs"])( + "maps %s to every sandbox identity consumer (#10463)", + (fixturePath) => { + expect(triggeredBy(fixturePath)).toEqual([ + "test/helpers/onboard-created-sandbox-fixture.test.ts", + "test/onboarding/onboard-custom-dockerfile.test.ts", + "test/onboarding/onboard-extra-provider-reconciliation.test.ts", + "test/onboarding/onboard-fresh-create-identity.test.ts", + "test/onboarding/onboard-installer-restore-intent.test.ts", + "test/onboarding/onboard-managed-image-buildless-e2e.test.ts", + "test/onboarding/onboard-mcp-observability-redirect.test.ts", + "test/onboarding/onboard-messaging.test.ts", + "test/onboarding/onboard-prepared-build-context.test.ts", + "test/onboarding/onboard-reservation-recreate.test.ts", + "test/onboarding/onboard-sandbox-build.test.ts", + "test/onboarding/onboard-sandbox-recreation.test.ts", + "test/onboarding/onboard-script-mocks-contract.test.ts", + "test/onboarding/onboard-terminal-dashboard.test.ts", + "test/onboarding/onboard.test.ts", + "test/security/shellquote-sandbox.test.ts", + "test/repository/source-require-loader.test.ts", + ]); + }, + ); it.each([".github/workflows/pr.yaml", ".github/workflows/pr.yml"])( "maps YAML workflow files to the shared display-name contract [%s]", @@ -152,13 +152,17 @@ describe("Vitest opaque-input watch triggers", () => { ".github/workflows/release-daily-brev-image.yaml", "scripts/release-daily-brev-image.sh", ])("maps each daily image caller input to its contract test [%s] (#9799)", (inputPath) => { - expect(triggeredBy(inputPath)).toEqual(["test/automation/releases/release-daily-brev-image.test.ts"]); + expect(triggeredBy(inputPath)).toEqual([ + "test/automation/releases/release-daily-brev-image.test.ts", + ]); }); it.each([".github/workflows/release-lkg-brev-image.yaml", "scripts/release-lkg-brev-image.sh"])( "maps each LKG image caller input to its contract test [%s] (#9798)", (inputPath) => { - expect(triggeredBy(inputPath)).toEqual(["test/automation/releases/release-lkg-brev-image.test.ts"]); + expect(triggeredBy(inputPath)).toEqual([ + "test/automation/releases/release-lkg-brev-image.test.ts", + ]); }, ); @@ -169,14 +173,14 @@ describe("Vitest opaque-input watch triggers", () => { ]); }); - it.each([ - "nemoclaw/src/shared/openshell-policy-boundary.cts", - "nemoclaw/tsconfig.shared.json", - ])("maps each policy compiler input to its spawned fixture contract [%s] (#10016)", (inputPath) => { - expect(triggeredBy(inputPath)).toEqual([ - "test/e2e/support/hermes-discord-policy-binding.test.ts", - ]); - }); + it.each(["nemoclaw/src/shared/openshell-policy-boundary.cts", "nemoclaw/tsconfig.shared.json"])( + "maps each policy compiler input to its spawned fixture contract [%s] (#10016)", + (inputPath) => { + expect(triggeredBy(inputPath)).toEqual([ + "test/e2e/support/hermes-discord-policy-binding.test.ts", + ]); + }, + ); it.each([ ".github/actions/docker-auth-setup/action.yaml", @@ -200,9 +204,7 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy(".github/actions/resolve-hermes-base-image/action.yaml")).toEqual([ "test/platform/images/base-image-resolver-helper.test.ts", ]); - expect( - triggeredBy(".github/actions/resolve-reviewed-hermes-platform/action.yaml"), - ).toEqual([ + expect(triggeredBy(".github/actions/resolve-reviewed-hermes-platform/action.yaml")).toEqual([ "test/agents/hermes/reviewed-hermes-platform-action.test.ts", "test/platform/images/protected-managed-image-contract.test.ts", "test/e2e/support/managed-image-protected-runtime-workflow.test.ts", @@ -264,6 +266,7 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy(".github/workflows/managed-images.yaml")).toEqual([ "test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts", "test/inference/managed/managed-image-publication-workflow.test.ts", + "test/inference/managed/pi-candidate-pr-publication-workflow.test.ts", "test/e2e-runtime/pull-public-exact-digest.test.ts", ]); expect(triggeredBy("test/e2e/live/managed-image-activation-e2e-helpers.ts")).toEqual([ diff --git a/test/runtime/gateway/gateway-drift-preflight.test.ts b/test/runtime/gateway/gateway-drift-preflight.test.ts index 73a2245263b..eb4f8e149bc 100644 --- a/test/runtime/gateway/gateway-drift-preflight.test.ts +++ b/test/runtime/gateway/gateway-drift-preflight.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -64,7 +64,6 @@ function writeRegistry(home: string): void { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agent: "openclaw", agentVersion: "test-version", }, diff --git a/test/runtime/gateway/gateway-state-reconcile-2276.test.ts b/test/runtime/gateway/gateway-state-reconcile-2276.test.ts index 62f2bb837a4..e420ea6c9b8 100644 --- a/test/runtime/gateway/gateway-state-reconcile-2276.test.ts +++ b/test/runtime/gateway/gateway-state-reconcile-2276.test.ts @@ -81,7 +81,6 @@ function writeDefaultRegistry(gatewayName: string, gatewayPort: number) { gatewayPort, dashboardPort: 28790, fromDockerfile: null, - policies: [], }, }, }), @@ -341,153 +340,139 @@ afterEach(async () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -// ─── Scenario 14 (#4497) ─── connect preserves enough state for rebuild ───── -// End-to-end recovery contract for the REOPENED issue: a healthy gateway -// reports the sandbox as gone, `connect` must NOT delete the registry entry, -// and the follow-up `rebuild --yes` must actually RECOVER it. -// -// The first fix (PR #4647) only stopped `connect` from deleting the entry. But -// `rebuild` then still dead-ended at its backup step with "Cannot back up -// state" because the live sandbox was absent — exactly this stale state. So the -// recommended recovery path was still broken. This scenario now asserts rebuild -// (a) locates the preserved entry (no "does not exist"), (b) does NOT dead-end -// at "Cannot back up state", and (c) reports the stale state and proceeds to -// recreate from the preserved registry metadata instead of aborting. -describe("connect preserves the registry so rebuild can recover in scenario 14 (#4497)", () => { - it("after a non-destructive connect, `rebuild --yes` recovers the stale sandbox", { - timeout: TIMEOUT_MS, - }, async () => { - gatewayListener = createServer(); - await new Promise((resolve, reject) => { - gatewayListener?.once("error", reject); - gatewayListener?.listen(0, "127.0.0.1", resolve); - }); - const gatewayAddress = gatewayListener.address(); - assert.ok(gatewayAddress && typeof gatewayAddress !== "string"); - const gatewayPort = gatewayAddress.port; - const gatewayName = `nemoclaw-${gatewayPort}`; - writeDefaultRegistry(gatewayName, gatewayPort); - writeDefaultSession(gatewayName); - writeDockerStub(gatewayName, gatewayPort); - writeStubOpenshell({ - sandboxGet: [{ output: SANDBOX_GET_NOT_FOUND, exit: 1 }], - status: [{ output: statusConnectedNemoclaw(gatewayName, gatewayPort), exit: 0 }], - gatewayInfo: [{ output: gatewayInfoNemoclaw(gatewayName, gatewayPort), exit: 0 }], - gatewaySelect: { output: "", exit: 0 }, - selectFlipsActive: false, - sandboxList: "", - }); - - // Step 3: routine connect must preserve the registry entry. - const connect = runCli("connect"); - assert.equal(connect.status, 1, `connect expected exit 1, got ${connect.status}`); - assert.equal( - registrySandboxPresent(connect), - true, - `connect must preserve the registry entry, got: ${JSON.stringify(connect.registry)}`, - ); - assert.equal(connect.sessionSandboxName, SANDBOX_NAME, "session must survive connect"); - assert.doesNotMatch(connect.stderr, /Removed stale local registry entry/); - - // Step 4: the previously-suggested rebuild must RECOVER the stale sandbox. - // The live `sandbox list` does not report it, so rebuild enters its - // stale-recovery path: it locates the preserved registry entry, skips the - // impossible backup (instead of dead-ending at "Cannot back up state"), - // and proceeds to recreate from the preserved metadata. - const repoRoot = path.join(import.meta.dirname, "../../.."); - const nodeBinDir = path.dirname(process.execPath); - const rebuild = spawnSync( - process.execPath, - [path.join(repoRoot, "bin", "nemoclaw.js"), SANDBOX_NAME, "rebuild", "--yes"], - { - cwd: repoRoot, - encoding: "utf-8", - timeout: TIMEOUT_MS, - env: { - ...process.env, - HOME: tmpDir, - PATH: `${homeLocalBin}:${nodeBinDir}:/usr/bin:/bin`, - NO_COLOR: "1", - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", - NEMOCLAW_SKIP_HOST_DNS_PREFLIGHT: "1", - NEMOCLAW_NON_INTERACTIVE: "1", - // The recreate handoff (onboard --resume) fails fast in this stubbed - // HOME — fine: the assertions below target the recovery markers that - // are emitted BEFORE the recreate, proving rebuild crossed the - // backup gate that previously blocked it. - NVIDIA_INFERENCE_API_KEY: "nvapi-test-key-for-rebuild", - NEMOCLAW_PROVIDER_KEY: "", +// ─── Scenario 14 (#4497) ─── connect preserves state without policy replay ─── +// A missing live sandbox has no OpenShell policy to hand to its replacement. +// Connect keeps the registry record for inspection, but rebuild must refuse to +// reconstruct policy from that record. +describe("connect preserves the registry without reconstructing policy in scenario 14 (#4497)", () => { + it( + "after a non-destructive connect, `rebuild --yes` refuses the stale sandbox", + { + timeout: TIMEOUT_MS, + }, + async () => { + gatewayListener = createServer(); + await new Promise((resolve, reject) => { + gatewayListener?.once("error", reject); + gatewayListener?.listen(0, "127.0.0.1", resolve); + }); + const gatewayAddress = gatewayListener.address(); + assert.ok(gatewayAddress && typeof gatewayAddress !== "string"); + const gatewayPort = gatewayAddress.port; + const gatewayName = `nemoclaw-${gatewayPort}`; + writeDefaultRegistry(gatewayName, gatewayPort); + writeDefaultSession(gatewayName); + writeDockerStub(gatewayName, gatewayPort); + writeStubOpenshell({ + sandboxGet: [{ output: SANDBOX_GET_NOT_FOUND, exit: 1 }], + status: [{ output: statusConnectedNemoclaw(gatewayName, gatewayPort), exit: 0 }], + gatewayInfo: [{ output: gatewayInfoNemoclaw(gatewayName, gatewayPort), exit: 0 }], + gatewaySelect: { output: "", exit: 0 }, + selectFlipsActive: false, + sandboxList: "", + }); + + // Step 3: routine connect must preserve the registry entry. + const connect = runCli("connect"); + assert.equal(connect.status, 1, `connect expected exit 1, got ${connect.status}`); + assert.equal( + registrySandboxPresent(connect), + true, + `connect must preserve the registry entry, got: ${JSON.stringify(connect.registry)}`, + ); + assert.equal(connect.sessionSandboxName, SANDBOX_NAME, "session must survive connect"); + assert.doesNotMatch(connect.stderr, /Removed stale local registry entry/); + + // Step 4: rebuild locates the stale registry entry but refuses to create a + // replacement without a live OpenShell policy source. + const repoRoot = path.join(import.meta.dirname, "../../.."); + const nodeBinDir = path.dirname(process.execPath); + const rebuild = spawnSync( + process.execPath, + [path.join(repoRoot, "bin", "nemoclaw.js"), SANDBOX_NAME, "rebuild", "--yes"], + { + cwd: repoRoot, + encoding: "utf-8", + timeout: TIMEOUT_MS, + env: { + ...process.env, + HOME: tmpDir, + PATH: `${homeLocalBin}:${nodeBinDir}:/usr/bin:/bin`, + NO_COLOR: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_SKIP_HOST_DNS_PREFLIGHT: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NVIDIA_INFERENCE_API_KEY: "nvapi-test-key-for-rebuild", + NEMOCLAW_PROVIDER_KEY: "", + }, }, - }, - ); - const rebuildOut = `${rebuild.stdout || ""}\n${rebuild.stderr || ""}`; - const installerInvocations = fs - .readFileSync(installerInvocationsFile, "utf8") - .split("\n") - .filter(Boolean); - const dockerInvocations = fs - .readFileSync(dockerInvocationsFile, "utf8") - .split("\n") - .filter(Boolean); - - assert.equal( - installerInvocations.length, - 0, - `rebuild must not invoke the OpenShell installer, got ${installerInvocations.length} invocation(s)`, - ); - assert.doesNotMatch( - rebuildOut, - /below minimum required version|Installing OpenShell/, - `rebuild must use the fixture OpenShell binaries, got:\n${rebuildOut}`, - ); - assert.doesNotMatch( - rebuildOut, - /below minimum.*upgrading|missing provider credential rewrite or MCP L7 policy support.*reinstalling/i, - `rebuild must not enter the OpenShell upgrade or repair path, got:\n${rebuildOut}`, - ); - assert.doesNotMatch( - rebuildOut, - /Installing OpenShell from release/, - `rebuild must not enter the OpenShell install path, got:\n${rebuildOut}`, - ); - assert.doesNotMatch( - rebuildOut, - /does not exist/, - `rebuild must locate the preserved sandbox, got:\n${rebuildOut}`, - ); - // The reopened-issue dead-end must be gone. - assert.doesNotMatch( - rebuildOut, - /Cannot back up state/, - `rebuild must not dead-end on the stale sandbox (#4497), got:\n${rebuildOut}`, - ); - assert.match( - rebuildOut, - new RegExp(`Rebuild sandbox '${SANDBOX_NAME}'`), - `rebuild must enter the rebuild flow, got:\n${rebuildOut}`, - ); - // It must recognize the stale state and skip the impossible backup. - assert.match( - rebuildOut, - /absent from the live OpenShell gateway/, - `rebuild must report the stale-recovery state (#4497), got:\n${rebuildOut}\nDocker invocations:\n${dockerInvocations.join("\n")}`, - ); - assert.match( - rebuildOut, - /No live workspace state to back up/, - `rebuild must skip backup on stale recovery (#4497), got:\n${rebuildOut}`, - ); - assert.doesNotMatch( - rebuildOut, - /Backing up sandbox state/, - `rebuild must not attempt backup on a stale sandbox (#4497), got:\n${rebuildOut}`, - ); - // And it must proceed to recreate from the preserved metadata — this line - // is printed right before the onboard --resume handoff. - assert.match( - rebuildOut, - /Creating new sandbox with current image/, - `rebuild must proceed to recreate the sandbox (#4497), got:\n${rebuildOut}`, - ); - }); + ); + const rebuildOut = `${rebuild.stdout || ""}\n${rebuild.stderr || ""}`; + const installerInvocations = fs + .readFileSync(installerInvocationsFile, "utf8") + .split("\n") + .filter(Boolean); + const dockerInvocations = fs + .readFileSync(dockerInvocationsFile, "utf8") + .split("\n") + .filter(Boolean); + + assert.equal( + installerInvocations.length, + 0, + `rebuild must not invoke the OpenShell installer, got ${installerInvocations.length} invocation(s)`, + ); + assert.doesNotMatch( + rebuildOut, + /below minimum required version|Installing OpenShell/, + `rebuild must use the fixture OpenShell binaries, got:\n${rebuildOut}`, + ); + assert.doesNotMatch( + rebuildOut, + /below minimum.*upgrading|missing provider credential rewrite or MCP L7 policy support.*reinstalling/i, + `rebuild must not enter the OpenShell upgrade or repair path, got:\n${rebuildOut}`, + ); + assert.doesNotMatch( + rebuildOut, + /Installing OpenShell from release/, + `rebuild must not enter the OpenShell install path, got:\n${rebuildOut}`, + ); + assert.doesNotMatch( + rebuildOut, + /does not exist/, + `rebuild must locate the preserved sandbox, got:\n${rebuildOut}`, + ); + assert.match( + rebuildOut, + new RegExp(`Rebuild sandbox '${SANDBOX_NAME}'`), + `rebuild must enter the rebuild flow, got:\n${rebuildOut}`, + ); + // It must recognize the stale state and preserve the registry record. + assert.match( + rebuildOut, + /absent from the live OpenShell gateway/, + `rebuild must report the stale-recovery state (#4497), got:\n${rebuildOut}\nDocker invocations:\n${dockerInvocations.join("\n")}`, + ); + assert.match( + rebuildOut, + /Rebuild cannot recover its missing OpenShell policy/, + `rebuild must explain why stale recovery is unavailable (#4497), got:\n${rebuildOut}`, + ); + assert.doesNotMatch( + rebuildOut, + /Backing up sandbox state/, + `rebuild must not attempt backup on a stale sandbox (#4497), got:\n${rebuildOut}`, + ); + assert.match( + rebuildOut, + new RegExp(`${SANDBOX_NAME} destroy --yes[\\s\\S]*nemoclaw onboard`), + `rebuild must print the supported clean replacement sequence (#4497), got:\n${rebuildOut}`, + ); + assert.doesNotMatch( + rebuildOut, + /Creating new sandbox with current image/, + `rebuild must not recreate without a live policy source (#4497), got:\n${rebuildOut}`, + ); + }, + ); }); diff --git a/test/runtime/gateway/recover-port-forward.test.ts b/test/runtime/gateway/recover-port-forward.test.ts index 77b95551f7c..0267bbaff94 100644 --- a/test/runtime/gateway/recover-port-forward.test.ts +++ b/test/runtime/gateway/recover-port-forward.test.ts @@ -134,7 +134,6 @@ function setupFixture(opts: { model: "nvidia/test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], dashboardPort: Number(port), }, }, diff --git a/test/runtime/messaging/rebuild-messaging-conflict-preflight.test.ts b/test/runtime/messaging/rebuild-messaging-conflict-preflight.test.ts index 0d05dee21c2..b77595b403d 100644 --- a/test/runtime/messaging/rebuild-messaging-conflict-preflight.test.ts +++ b/test/runtime/messaging/rebuild-messaging-conflict-preflight.test.ts @@ -91,7 +91,6 @@ function completeSession(sandboxName: string) { preferredInferenceApi: null, nimContainer: null, webSearchConfig: null, - policyPresets: [], messagingPlan: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: { @@ -102,7 +101,6 @@ function completeSession(sandboxName: string) { inference: step, openclaw: step, agent_setup: { status: "pending", startedAt: null, completedAt: null, error: null }, - policies: step, }, }; } @@ -126,7 +124,6 @@ function createConflictFixture() { gatewayPort: 8080, dashboardPort: 18789, fromDockerfile: null, - policies: [], agent: null, messaging: { schemaVersion: 1, plan: teamsPlan(name, "shared-teams-hash") }, }); @@ -234,22 +231,26 @@ function registryHasSandbox(nemoclawDir: string, name: string): boolean { } describe("rebuild messaging credential conflict preflight (#5954)", () => { - it("aborts BEFORE backup/delete when another sandbox shares the Teams credential", { - timeout: 90_000, - }, () => { - const f = createConflictFixture(); - const result = runRebuild(f.tmpDir); - const output = `${result.stderr || ""}${result.stdout || ""}`; - - // Aborted, with the actionable conflict explanation. - expect(result.status).not.toBe(0); - expect(output).toContain("uses the same teams credential"); - expect(output).toContain("Aborting"); - - // Nothing destructive ran: the sandbox is untouched and still registered. - expect(output).not.toContain("Backing up sandbox state"); - expect(output).not.toContain("Old sandbox deleted"); - expect(output).not.toContain("must not run before the conflict preflight"); - expect(registryHasSandbox(f.nemoclawDir, "my-assistant")).toBe(true); - }); + it( + "aborts BEFORE backup/delete when another sandbox shares the Teams credential", + { + timeout: 90_000, + }, + () => { + const f = createConflictFixture(); + const result = runRebuild(f.tmpDir); + const output = `${result.stderr || ""}${result.stdout || ""}`; + + // Aborted, with the actionable conflict explanation. + expect(result.status).not.toBe(0); + expect(output).toContain("uses the same teams credential"); + expect(output).toContain("Aborting"); + + // Nothing destructive ran: the sandbox is untouched and still registered. + expect(output).not.toContain("Backing up sandbox state"); + expect(output).not.toContain("Old sandbox deleted"); + expect(output).not.toContain("must not run before the conflict preflight"); + expect(registryHasSandbox(f.nemoclawDir, "my-assistant")).toBe(true); + }, + ); }); diff --git a/test/runtime/policy/permissive-runtime.test.ts b/test/runtime/policy/permissive-runtime.test.ts index 8acd3df3a99..c9155d8a7a9 100644 --- a/test/runtime/policy/permissive-runtime.test.ts +++ b/test/runtime/policy/permissive-runtime.test.ts @@ -7,10 +7,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; -import { - buildRuntimePermissivePolicy, - type ExactManagedMcpPolicy, -} from "../../../src/lib/shields/permissive-runtime.js"; +import { buildRuntimePermissivePolicy } from "../../../src/lib/shields/permissive-runtime.js"; const BASE_PERMISSIVE = YAML.stringify({ filesystem_policy: { @@ -21,14 +18,12 @@ const BASE_PERMISSIVE = YAML.stringify({ landlock: { compatibility: "best_effort" }, }); -const MANAGED_POLICY: ExactManagedMcpPolicy = { +const LIVE_MCP_POLICY = { key: "mcp_bridge_alpha", networkPolicy: { endpoints: [{ host: "alpha.example.com", port: 443, protocol: "mcp" }], binaries: [{ path: "/opt/hermes/.venv/bin/python*" }], }, - policyName: "mcp-bridge-alpha", - server: "alpha", }; const HERMES_DISCORD_PERMISSIVE = YAML.stringify({ @@ -77,8 +72,9 @@ function expectExactHermesSlackCredentialRoutes(endpoints: SlackEndpoint[]): voi path: endpoint.path, provider: endpoint.credential_binding?.provider, routes: - endpoint.rules?.map((rule) => `${String(rule.allow?.method)} ${String(rule.allow?.path)}`) ?? - [], + endpoint.rules?.map( + (rule) => `${String(rule.allow?.method)} ${String(rule.allow?.path)}`, + ) ?? [], })), ).toEqual([ { @@ -370,11 +366,11 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(writeTempPolicy).not.toHaveBeenCalled(); }); - it("preserves exact managed MCP entries without copying unrelated live egress (#7952)", () => { + it("preserves live MCP entries without copying unrelated live egress (#7952)", () => { const liveYaml = YAML.stringify({ filesystem_policy: { read_write: ["/proc"] }, network_policies: { - mcp_bridge_alpha: MANAGED_POLICY.networkPolicy, + mcp_bridge_alpha: LIVE_MCP_POLICY.networkPolicy, unrelated_live_entry: { endpoints: [{ host: "unrelated.example.com", port: 443 }], }, @@ -383,7 +379,6 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { const out = buildRuntimePermissivePolicy("/unused-base.yaml", { livePolicyYaml: liveYaml, - managedMcpPolicies: [MANAGED_POLICY], readBasePolicy: () => YAML.stringify({ ...YAML.parse(BASE_PERMISSIVE), @@ -398,7 +393,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { const result = YAML.parse(fs.readFileSync(out, "utf-8")); expect(result.network_policies).toMatchObject({ - mcp_bridge_alpha: MANAGED_POLICY.networkPolicy, + mcp_bridge_alpha: LIVE_MCP_POLICY.networkPolicy, permissive_baseline: { endpoints: [{ host: "*", port: 443 }], }, @@ -579,11 +574,13 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(out).toBe(basePath); }); - it("fails closed when the base cannot be read with managed MCP policies active (#7952)", () => { + it("fails closed when the base cannot be read with live MCP policies active (#7952)", () => { expect(() => buildRuntimePermissivePolicy("/path/to/static.yaml", { - livePolicyYaml: "version: 1\nnetwork_policies: {}\n", - managedMcpPolicies: [MANAGED_POLICY], + livePolicyYaml: YAML.stringify({ + version: 1, + network_policies: { mcp_bridge_alpha: LIVE_MCP_POLICY.networkPolicy }, + }), readBasePolicy: () => { throw new Error("ENOENT"); }, @@ -591,21 +588,25 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { ).toThrow(/Cannot read the Shields-down policy/); }); - it("fails closed when the base is not a mapping with managed MCP policies active (#7952)", () => { + it("fails closed when the base is not a mapping with live MCP policies active (#7952)", () => { expect(() => buildRuntimePermissivePolicy("/path/to/static.yaml", { - livePolicyYaml: "version: 1\nnetwork_policies: {}\n", - managedMcpPolicies: [MANAGED_POLICY], + livePolicyYaml: YAML.stringify({ + version: 1, + network_policies: { mcp_bridge_alpha: LIVE_MCP_POLICY.networkPolicy }, + }), readBasePolicy: () => "[]", }), ).toThrow(/Cannot parse the Shields-down policy/); }); - it("fails closed when staging fails with managed MCP policies active (#7952)", () => { + it("fails closed when staging fails with live MCP policies active (#7952)", () => { expect(() => buildRuntimePermissivePolicy("/path/to/static.yaml", { - livePolicyYaml: "version: 1\nnetwork_policies: {}\n", - managedMcpPolicies: [MANAGED_POLICY], + livePolicyYaml: YAML.stringify({ + version: 1, + network_policies: { mcp_bridge_alpha: LIVE_MCP_POLICY.networkPolicy }, + }), readBasePolicy: () => BASE_PERMISSIVE, writeTempPolicy: () => { throw new Error("ENOSPC: simulated /tmp full"); diff --git a/test/runtime/policy/policies-permissive-policy.test.ts b/test/runtime/policy/policies-permissive-policy.test.ts index 138ca28ee76..5b9c6741f94 100644 --- a/test/runtime/policy/policies-permissive-policy.test.ts +++ b/test/runtime/policy/policies-permissive-policy.test.ts @@ -10,10 +10,10 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { - managedPolicyMetadata, + livePolicyMetadata, managedRegistrationSource, SANDBOX_ID, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "policy", "index.ts")); @@ -54,7 +54,7 @@ if [ "$1 $2" = "sandbox get" ]; then fi if [ "$1 $2" = "policy get" ]; then if [[ " $* " == *" --output json "* ]]; then - printf '%s\n' ${JSON.stringify(managedPolicyMetadata("hermes-sandbox"))} + printf '%s\n' ${JSON.stringify(livePolicyMetadata("hermes-sandbox"))} exit 0 fi if [ -f ${JSON.stringify(policyOut)} ]; then @@ -80,7 +80,7 @@ if [ "$1 $2" = "policy set" ]; then printf 'Policy version 2 submitted\nPolicy version 2 loaded\n' exit 0 fi - printf 'message: fixture rejection\n' >&2 + printf "Error: code: 'Failed precondition', message: 'fixture rejection'\n" >&2 exit "${policySetStatus}" fi exit 1 diff --git a/test/runtime/policy/policies-teams.test.ts b/test/runtime/policy/policies-teams.test.ts index 168a3366cc2..63466e60e3b 100644 --- a/test/runtime/policy/policies-teams.test.ts +++ b/test/runtime/policy/policies-teams.test.ts @@ -10,10 +10,10 @@ import { describe, expect, it } from "vitest"; import * as policies from "../../../src/lib/policy"; import { - managedPolicyMetadata, + livePolicyMetadata, managedRegistrationSource, SANDBOX_ID, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const requireForTest = createRequire(import.meta.url); const YAML = requireForTest("yaml"); @@ -192,7 +192,7 @@ if [ "$1 $2" = "sandbox get" ]; then fi if [ "$1 $2" = "policy get" ]; then if [[ " $* " == *" --output json "* ]]; then - printf '%s\n' ${JSON.stringify(managedPolicyMetadata("hermes-sandbox"))} + printf '%s\n' ${JSON.stringify(livePolicyMetadata("hermes-sandbox"))} exit 0 fi if [ -f ${JSON.stringify(policyOut)} ]; then @@ -265,7 +265,7 @@ exit 1 ]); expect(allowedMethods(teamsPolicy, "teams.microsoft.com")).toEqual(["GET"]); expect(allowedMethods(teamsPolicy, "*.sharepoint.com")).toEqual(["GET"]); - expect(payload.registry.policies).toEqual(["teams"]); + expect(payload.registry).not.toHaveProperty("policies"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index f7ab0e5ffc9..1f5df7c963d 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - managedPolicyMetadata, + livePolicyMetadata, managedRegistrationSource, managedSandboxEntry, parseResultPayload, @@ -17,7 +17,7 @@ import { POLICY_VERSION, SANDBOX_ID, SANDBOX_IDENTITY, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const requireForTest = createRequire(import.meta.url); const YAML = requireForTest("yaml"); @@ -28,9 +28,9 @@ const policies = requireForTest( const resolveOpenshellModule = requireForTest( path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "resolve.ts"), ) as { resolveOpenshell: (...args: unknown[]) => string | null }; -const policyAuthorityModule = requireForTest( - path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "policy-authority.ts"), -) as typeof import("../../../src/lib/adapters/openshell/policy-authority"); +const policyStateModule = requireForTest( + path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "policy-state.ts"), +) as typeof import("../../../src/lib/adapters/openshell/policy-state"); const registryForTest = requireForTest( path.join(REPO_ROOT, "src", "lib", "state", "registry.ts"), ) as typeof import("../../../src/lib/state/registry"); @@ -48,15 +48,14 @@ function requirePresetContent(content: string | null): string { describe("policies", () => { beforeEach(() => { - vi.spyOn(policyAuthorityModule, "inspectSandboxPolicyAuthority").mockReturnValue({ - authority: "owner-unknown", + vi.spyOn(policyStateModule, "inspectSandboxPolicy").mockReturnValue({ + policySource: "sandbox", effectivePolicy: {}, policyIdentity: { hash: POLICY_HASH, activeVersion: POLICY_VERSION }, }); - vi.spyOn(policyAuthorityModule, "inspectOpenShellSandboxIdentityFingerprint").mockReturnValue( + vi.spyOn(policyStateModule, "inspectOpenShellSandboxIdentityFingerprint").mockReturnValue( SANDBOX_IDENTITY, ); - vi.spyOn(registryForTest, "compareAndSetSandboxPolicyCreationReceipt").mockReturnValue(true); }); afterEach(() => { @@ -193,7 +192,7 @@ if [ "$1 $2" = "sandbox get" ]; then fi if [ "$1 $2" = "policy get" ]; then if [[ " $* " == *" --output json "* ]]; then - printf '%s\n' ${JSON.stringify(managedPolicyMetadata("test-sandbox"))} + printf '%s\n' ${JSON.stringify(livePolicyMetadata("test-sandbox"))} exit 0 fi if [ -f ${JSON.stringify(policyOut)} ]; then @@ -245,7 +244,7 @@ exit 1 ); expect(payload.policy).toContain("npm_yarn:"); expect(payload.policy).toContain("pypi:"); - expect(payload.registry.policies).toEqual(["npm", "pypi"]); + expect(payload.registry).not.toHaveProperty("policies"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -277,7 +276,7 @@ if [ "$1 $2" = "sandbox get" ]; then fi if [ "$1 $2" = "policy get" ]; then if [[ " $* " == *" --output json "* ]]; then - printf '%s\n' ${JSON.stringify(managedPolicyMetadata("hermes-sandbox"))} + printf '%s\n' ${JSON.stringify(livePolicyMetadata("hermes-sandbox"))} exit 0 fi if [ -f ${JSON.stringify(policyOut)} ]; then @@ -337,7 +336,7 @@ exit 1 path: "/api/v*/channels/*/messages/*", }); expect(mutationRules).not.toContainEqual({ method: "PATCH", path: "/**" }); - expect(payload.registry.policies).toEqual(["discord"]); + expect(payload.registry).not.toHaveProperty("policies"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -369,7 +368,7 @@ if [ "$1 $2" = "sandbox get" ]; then fi if [ "$1 $2" = "policy get" ]; then if [[ " $* " == *" --output json "* ]]; then - printf '%s\n' ${JSON.stringify(managedPolicyMetadata("hermes-sandbox"))} + printf '%s\n' ${JSON.stringify(livePolicyMetadata("hermes-sandbox"))} exit 0 fi if [ -f ${JSON.stringify(policyOut)} ]; then @@ -417,7 +416,7 @@ exit 1 const binaries = wechatPolicy.binaries.map((entry: { path: string }) => entry.path); expect(binaries).toContain("/usr/bin/python3*"); expect(binaries).toContain("/opt/hermes/.venv/bin/python"); - expect(payload.registry.policies).toEqual(["wechat"]); + expect(payload.registry).not.toHaveProperty("policies"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -764,7 +763,6 @@ exit 1 let origHome: string | undefined; let resolveSpy: ReturnType; let savedGetSandbox: any; - let savedAddCustomPolicy: any; beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-issue4586-")); @@ -777,9 +775,7 @@ exit 1 .spyOn(resolveOpenshellModule, "resolveOpenshell") .mockReturnValue(fakeOpenshell); savedGetSandbox = registryModule.getSandbox; - savedAddCustomPolicy = registryModule.addCustomPolicy; registryModule.getSandbox = (name: string) => managedSandboxEntry(name); - registryModule.addCustomPolicy = () => true; }); afterEach(() => { @@ -787,7 +783,6 @@ exit 1 else process.env.HOME = origHome; resolveSpy.mockRestore(); registryModule.getSandbox = savedGetSandbox; - registryModule.addCustomPolicy = savedAddCustomPolicy; fs.rmSync(tmpHome, { recursive: true, force: true }); }); @@ -851,7 +846,6 @@ network_policies: let origHome: string | undefined; let resolveSpy: ReturnType; let savedGetSandbox: any; - let savedAddCustomPolicy: any; let savedUpdateSandbox: any; beforeEach(() => { @@ -885,7 +879,6 @@ exit 0 .spyOn(resolveOpenshellModule, "resolveOpenshell") .mockReturnValue(fakeOpenshell); savedGetSandbox = registryModule.getSandbox; - savedAddCustomPolicy = registryModule.addCustomPolicy; savedUpdateSandbox = registryModule.updateSandbox; }); @@ -894,17 +887,14 @@ exit 0 else process.env.HOME = origHome; resolveSpy.mockRestore(); registryModule.getSandbox = savedGetSandbox; - registryModule.addCustomPolicy = savedAddCustomPolicy; registryModule.updateSandbox = savedUpdateSandbox; fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it("refuses a custom preset when policy authority cannot be recorded (#9833)", () => { + it("refuses a custom preset when sandbox policy state cannot be located", () => { // The sandbox is ready on the gateway but missing from the local // registry, so the first observed authority cannot be persisted. registryModule.getSandbox = () => null; - const addSpy = vi.fn(() => false); - registryModule.addCustomPolicy = addSpy; const errors: string[] = []; const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errors.push(a.map((x) => String(x)).join(" ")); @@ -918,17 +908,16 @@ exit 0 { custom: { sourcePath: SOURCE_PATH } }, ); expect(result).toBe(false); - expect(addSpy).not.toHaveBeenCalled(); const combined = errors.join("\n"); expect(combined).toContain("my-assistant"); - expect(combined).toContain("policy authority is unavailable"); + expect(combined).toContain("policy state is unavailable"); } finally { errSpy.mockRestore(); logSpy.mockRestore(); } }); - it("refuses a built-in preset when policy authority cannot be recorded (#9833)", () => { + it("refuses a built-in preset when sandbox policy state cannot be located", () => { registryModule.getSandbox = () => null; const updateSpy = vi.fn(() => true); registryModule.updateSandbox = updateSpy; @@ -943,22 +932,20 @@ exit 0 expect(updateSpy).not.toHaveBeenCalled(); const combined = errors.join("\n"); expect(combined).toContain("my-assistant"); - expect(combined).toContain("policy authority is unavailable"); + expect(combined).toContain("policy state is unavailable"); } finally { errSpy.mockRestore(); logSpy.mockRestore(); } }); - it("applies a well-formed custom preset and records it verbatim (#9406)", () => { + it("applies a well-formed custom preset without recording a policy copy", () => { let sandbox: Record = managedSandboxEntry("my-assistant"); registryModule.getSandbox = () => sandbox; registryModule.updateSandbox = (_name: string, updates: Record) => { sandbox = { ...sandbox, ...updates }; return true; }; - const addSpy = vi.fn(() => true); - registryModule.addCustomPolicy = addSpy; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); try { @@ -969,14 +956,6 @@ exit 0 { custom: { sourcePath: SOURCE_PATH } }, ); expect(result).toBe(true); - expect(addSpy).toHaveBeenCalledWith( - "my-assistant", - expect.objectContaining({ - name: "slack-files-upload", - content: CUSTOM_CONTENT, - sourcePath: SOURCE_PATH, - }), - ); } finally { logSpy.mockRestore(); errSpy.mockRestore(); diff --git a/test/runtime/policy/policy-add-remove-session-sync.test.ts b/test/runtime/policy/policy-add-remove-session-sync.test.ts deleted file mode 100644 index 5338f9aedf5..00000000000 --- a/test/runtime/policy/policy-add-remove-session-sync.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Regression test for the same session/registry divergence that motivated -// the channels-add fix (see test/channels/channels-add-preset.test.ts). The bug -// surfaced first via `nemoclaw channels add slack` → `rebuild` -// (registry got slack, session did not, rebuild's resume step narrowed -// it back away). The exact same divergence applies to the standalone -// preset-mutation CLIs: -// -// - `nemoclaw policy-add ` (built-in preset) -// - `nemoclaw policy-add --from-file …` (custom preset YAML) -// - `nemoclaw policy-remove ` (any preset) -// -// All three call `policies.applyPreset` / `policies.applyPresetContent` / -// `policies.removePreset` to mutate the registry; none of them previously -// touched `session.policyPresets`. These tests pin down the invariant -// that after the channels-add fix was generalised, all three paths now -// keep session in sync with registry, with the same best-effort error -// handling. - -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, "../../.."); - -function runScript( - scriptBody: string, - extraEnv: Record = {}, -): SpawnSyncReturns { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-sync-")); - const scriptPath = path.join(tmpDir, "script.js"); - fs.writeFileSync(scriptPath, scriptBody); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - NEMOCLAW_NON_INTERACTIVE: "1", - ...extraEnv, - }, - timeout: 15000, - }); - fs.rmSync(tmpDir, { recursive: true, force: true }); - return result; -} - -// Stub every module that addSandboxPolicy / removeSandboxPolicy touches. -// The only side effect we actually want to observe is on the onboardSession -// stub, so everything else is faked to a no-op success. -function buildPreamble({ - presetNamesAvailable = ["github", "npm", "pypi"], - appliedPresets = [] as string[], - applyPresetResult = true, - sessionSandboxName = "test-sb" as string | null, - sessionPolicyPresets = ["npm"] as string[] | null, - sessionMissing = false, -}: { - presetNamesAvailable?: string[]; - appliedPresets?: string[]; - applyPresetResult?: boolean; - sessionSandboxName?: string | null; - sessionPolicyPresets?: string[] | null; - sessionMissing?: boolean; -} = {}): string { - const j = (p: string) => - JSON.stringify(path.join(repoRoot, "src", "lib", p.replace(/\.js$/, ".ts"))); - return String.raw` -const onboard = require(${j("onboard.js")}); -onboard.isNonInteractive = () => true; - -const credentials = require(${j("credentials/store.js")}); -credentials.prompt = async () => "y"; - -const policies = require(${j("policy/index.js")}); -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; -policies.applyPreset = (sandboxName, presetName) => { - calls.apply.push({ sandboxName, presetName }); - return ${JSON.stringify(applyPresetResult)}; -}; -policies.applyPresetContent = (sandboxName, presetName) => { - calls.applyContent.push({ sandboxName, presetName }); - return true; -}; -policies.removePreset = (sandboxName, presetName) => { - calls.remove.push({ sandboxName, presetName }); - return true; -}; -// loadPresetFromFile is used by --from-file path. -policies.loadPresetFromFile = (filePath) => ({ - presetName: "custom-preset-from-file", - content: { network_policies: {} }, -}); - -const onboardSession = require(${j("state/onboard-session.js")}); -const sessionUpdates = []; -let sessionState = ${ - sessionMissing - ? "null" - : `{ - sandboxName: ${JSON.stringify(sessionSandboxName)}, - policyPresets: ${JSON.stringify(sessionPolicyPresets)}, -}` - }; -onboardSession.loadSession = () => sessionState; -onboardSession.updateSession = (mutator) => { - if (!sessionState) sessionState = { sandboxName: null, policyPresets: null }; - const next = mutator(sessionState) || sessionState; - sessionState = next; - sessionUpdates.push({ - policyPresets: Array.isArray(next.policyPresets) ? [...next.policyPresets] : next.policyPresets, - }); - return next; -}; - -const channelModule = require(${j("actions/sandbox/policy-channel.js")}); - -module.exports = { channelModule, calls, sessionUpdates, getSessionState: () => sessionState }; -`; -} - -describe("policy-add / policy-remove keep session.policyPresets in sync with registry", () => { - it("appends the built-in preset to session.policyPresets after policy-add", () => { - const script = `${buildPreamble({ - sessionSandboxName: "test-sb", - sessionPolicyPresets: ["npm"], - })} -const ctx = module.exports; -(async () => { - try { - await ctx.channelModule.addSandboxPolicy("test-sb", { preset: "github", yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - // Contract 1: applyPreset called exactly once with the chosen preset. - assert.deepEqual(payload.calls.apply, [{ sandboxName: "test-sb", presetName: "github" }]); - // Contract 2: session updated exactly once, github appended. - assert.equal(payload.sessionUpdates.length, 1); - assert.deepEqual(payload.sessionUpdates[0].policyPresets, ["npm", "github"]); - assert.deepEqual(payload.finalSession.policyPresets, ["npm", "github"]); - }); - - it("does not sync session.policyPresets when built-in policy-add fails", () => { - const script = `${buildPreamble({ - applyPresetResult: false, - sessionSandboxName: "test-sb", - sessionPolicyPresets: ["npm"], - })} -const ctx = module.exports; -const exitCodes = []; -const originalExit = process.exit; -process.exit = (code) => { - exitCodes.push(code ?? 0); - throw new Error("__EXIT__" + (code ?? 0)); -}; -(async () => { - try { - await ctx.channelModule.addSandboxPolicy("test-sb", { preset: "github", yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - exitCodes, - }) + "\\n"); - } catch (err) { - if (!String(err && err.message).startsWith("__EXIT__")) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - return; - } - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - exitCodes, - }) + "\\n"); - } finally { - process.exit = originalExit; - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - assert.deepEqual(payload.calls.apply, [{ sandboxName: "test-sb", presetName: "github" }]); - assert.deepEqual(payload.exitCodes, [1]); - assert.deepEqual(payload.sessionUpdates, []); - assert.deepEqual(payload.finalSession.policyPresets, ["npm"]); - }); - - it("appends the custom preset (--from-file) to session.policyPresets", () => { - // Write a tiny YAML file the stubbed loadPresetFromFile will pretend to parse. - const presetFile = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preset-")); - const yamlPath = path.join(presetFile, "custom.yaml"); - fs.writeFileSync(yamlPath, "name: custom-preset-from-file\nnetwork_policies: {}\n"); - - const script = `${buildPreamble({ - sessionSandboxName: "test-sb", - sessionPolicyPresets: ["npm"], - })} -const ctx = module.exports; -(async () => { - try { - await ctx.channelModule.addSandboxPolicy("test-sb", { fromFile: ${JSON.stringify(yamlPath)}, yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - fs.rmSync(presetFile, { recursive: true, force: true }); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - // The custom-preset path goes through applyPresetContent (NOT applyPreset). - assert.deepEqual(payload.calls.applyContent, [ - { sandboxName: "test-sb", presetName: "custom-preset-from-file" }, - ]); - assert.equal(payload.sessionUpdates.length, 1); - assert.deepEqual(payload.sessionUpdates[0].policyPresets, ["npm", "custom-preset-from-file"]); - }); - - it("removes the preset from session.policyPresets after policy-remove", () => { - const script = `${buildPreamble({ - appliedPresets: ["npm", "github"], - sessionSandboxName: "test-sb", - sessionPolicyPresets: ["npm", "github"], - })} -const ctx = module.exports; -(async () => { - try { - await ctx.channelModule.removeSandboxPolicy("test-sb", { preset: "github", yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - assert.deepEqual(payload.calls.remove, [{ sandboxName: "test-sb", presetName: "github" }]); - assert.equal(payload.sessionUpdates.length, 1); - assert.deepEqual(payload.sessionUpdates[0].policyPresets, ["npm"]); - }); - - it("does not touch a session belonging to a different sandbox", () => { - const script = `${buildPreamble({ - sessionSandboxName: "other-sb", - sessionPolicyPresets: ["pypi"], - })} -const ctx = module.exports; -(async () => { - try { - await ctx.channelModule.addSandboxPolicy("test-sb", { preset: "github", yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - // Registry mutation still happens — that lives per-sandbox in the - // OpenShell policy engine, not in the session file. - assert.deepEqual(payload.calls.apply, [{ sandboxName: "test-sb", presetName: "github" }]); - // But session for "other-sb" must be left alone. - assert.deepEqual(payload.sessionUpdates, []); - assert.deepEqual(payload.finalSession.policyPresets, ["pypi"]); - }); - - it("completes policy-add when no onboard session exists", () => { - const script = `${buildPreamble({ sessionMissing: true })} -const ctx = module.exports; -(async () => { - try { - await ctx.channelModule.addSandboxPolicy("test-sb", { preset: "github", yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - // Registry mutation succeeded; session-sync was a no-op (no session - // to keep in sync). policy-add must NOT abort the operation in this case. - assert.deepEqual(payload.calls.apply, [{ sandboxName: "test-sb", presetName: "github" }]); - assert.deepEqual(payload.sessionUpdates, []); - }); - - // Restricted-tier suppression (see src/lib/onboard/policy-tier-suppression.ts) - // only filters agent-required presets at the onboarding boundary - // (suggestions / preservation / resume). The documented operator escape - // hatch — `nemoclaw policy-add ` — bypasses the - // suppression module and goes directly through `policies.applyPreset`. - // This regression pins down that contract: the restricted-incompatible - // presets can still be applied on demand. - it("policy-add openclaw-pricing succeeds independently of restricted-tier suppression (escape hatch contract)", () => { - const script = `${buildPreamble({ - presetNamesAvailable: ["npm", "openclaw-pricing"], - sessionSandboxName: "test-sb", - sessionPolicyPresets: [], - })} -const ctx = module.exports; -(async () => { - try { - await ctx.channelModule.addSandboxPolicy("test-sb", { preset: "openclaw-pricing", yes: true }); - process.stdout.write("\\n__RESULT__" + JSON.stringify({ - calls: ctx.calls, - sessionUpdates: ctx.sessionUpdates, - finalSession: ctx.getSessionState(), - }) + "\\n"); - } catch (err) { - process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); - const marker = result.stdout.lastIndexOf("__RESULT__"); - const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); - - assert.deepEqual(payload.calls.apply, [ - { sandboxName: "test-sb", presetName: "openclaw-pricing" }, - ]); - assert.deepEqual(payload.sessionUpdates[0].policyPresets, ["openclaw-pricing"]); - }); -}); diff --git a/test/runtime/policy/policy-channel-agent-resolution.test.ts b/test/runtime/policy/policy-channel-agent-resolution.test.ts index 76eb918a018..3c8ff04327c 100644 --- a/test/runtime/policy/policy-channel-agent-resolution.test.ts +++ b/test/runtime/policy/policy-channel-agent-resolution.test.ts @@ -30,7 +30,6 @@ registry.registerSandbox({ name: "egress-only", agent: "openclaw", policies: [] registry.registerSandbox({ name: "slack-configured", agent: "openclaw", - policies: [], messaging: { schemaVersion: 1, plan: makeMessagingPlan({ sandboxName: "slack-configured", channels: ["slack"] }), @@ -80,7 +79,6 @@ 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"); @@ -128,7 +126,6 @@ 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 })); @@ -153,7 +150,6 @@ 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 })); @@ -189,7 +185,6 @@ process.exit = (code) => { throw new Error("EXIT:" + String(code)); }; registry.registerSandbox({ name: "deepagents-sandbox", agent: "langchain-deepagents-code", - policies: [], }); (async () => { let exitCode = null; diff --git a/test/runtime/policy/policy-diagnostic-read.test.ts b/test/runtime/policy/policy-diagnostic-read.test.ts index 54ff9e321de..66bae50b5d4 100644 --- a/test/runtime/policy/policy-diagnostic-read.test.ts +++ b/test/runtime/policy/policy-diagnostic-read.test.ts @@ -26,7 +26,7 @@ describe("OpenShell policy read boundaries", () => { expect(command.slice(1)).toEqual(["policy", "get", "--full", "my-assistant"]); }); - it("queries the full effective policy when matching gateway presets", () => { + it("queries the gateway-scoped base policy when matching presets", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-diagnostic-")); const fakeOpenshell = path.join(tmpDir, "openshell"); const argsFile = path.join(tmpDir, "args.txt"); @@ -43,7 +43,9 @@ describe("OpenShell policy read boundaries", () => { vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); try { expect(policies.getGatewayPresets("my-assistant")).toEqual([]); - expect(fs.readFileSync(argsFile, "utf-8").trim()).toBe("policy get --full my-assistant"); + expect(fs.readFileSync(argsFile, "utf-8").trim()).toBe( + "policy get -g nemoclaw --base my-assistant", + ); } finally { vi.unstubAllEnvs(); fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/test/runtime/policy/policy-explain-cli.test.ts b/test/runtime/policy/policy-explain-cli.test.ts index 7c55c148850..50c0308e56b 100644 --- a/test/runtime/policy/policy-explain-cli.test.ts +++ b/test/runtime/policy/policy-explain-cli.test.ts @@ -12,7 +12,7 @@ import { POLICY_HASH, POLICY_VERSION, SANDBOX_ID, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const CLI = path.join(import.meta.dirname, "../../..", "bin", "nemoclaw.js"); @@ -115,9 +115,6 @@ describe("nemoclaw policy-explain (E2E)", () => { "policy-explain-e2e": { ...managedSandboxEntry("policy-explain-e2e"), createdAt: "2026-06-07T00:00:00.000Z", - policies: ["slack"], - policyTier: "balanced", - policyPresetsFinalized: true, }, }); @@ -129,10 +126,10 @@ describe("nemoclaw policy-explain (E2E)", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("# Sandbox policy context: policy-explain-e2e"); expect(result.stdout).toContain("## Active presets"); - expect(result.stdout).toContain("`slack`"); - expect(result.stdout).toContain("slack.com"); + expect(result.stdout).toContain("- none"); + expect(result.stdout).toContain("## Known unapplied presets"); expect(result.stdout).toContain("## Failure classification"); - expect(result.stdout).toContain("`balanced`"); + expect(result.stdout).toContain("no tier recorded"); expect(result.stdout).not.toMatch(/enforcement:|websocket_credential_rewrite|binaries:/); expect(result.stdout).not.toMatch(/network_policies:/); }); @@ -142,9 +139,6 @@ describe("nemoclaw policy-explain (E2E)", () => { "policy-explain-json": { ...managedSandboxEntry("policy-explain-json"), createdAt: "2026-06-07T00:00:00.000Z", - policies: ["github"], - policyTier: "balanced", - policyPresetsFinalized: true, }, }); @@ -160,7 +154,6 @@ describe("nemoclaw policy-explain (E2E)", () => { tier: { name: string } | null; activePresets: Array<{ name: string; allowedHostCategories: string[] }>; knownUnappliedPresets: Array<{ name: string }>; - baselineExclusions: Array<{ key: string; status: string; supportImpact: string }>; approvalPath: { inspect: string; add: string; @@ -173,14 +166,11 @@ describe("nemoclaw policy-explain (E2E)", () => { }; expect(parsed.sandboxName).toBe("policy-explain-json"); - expect(parsed.tier?.name).toBe("balanced"); - const active = parsed.activePresets.find((p) => p.name === "github"); - expect(active).toBeDefined(); - expect(active?.allowedHostCategories).toContain("api.github.com"); + expect(parsed.tier).toBeNull(); + expect(parsed.activePresets).toEqual([]); expect(parsed.knownUnappliedPresets.some((p) => p.name === "slack")).toBe(true); expect(parsed.approvalPath.inspect).toBe("nemoclaw policy-explain-json policy list"); expect(parsed.approvalPath.add).toBe("nemoclaw policy-explain-json policy add "); - expect(parsed.baselineExclusions).toEqual([]); expect(parsed.approvalPath.excludeBaseline).toContain("policy exclude --dry-run"); expect(parsed.approvalPath.restoreBaseline).toContain("policy restore "); expect( diff --git a/test/runtime/policy/policy-mutation-read-failure.test.ts b/test/runtime/policy/policy-mutation-read-failure.test.ts index 81560f7761a..aa6a72ffd81 100644 --- a/test/runtime/policy/policy-mutation-read-failure.test.ts +++ b/test/runtime/policy/policy-mutation-read-failure.test.ts @@ -12,13 +12,13 @@ import { POLICY_HASH, POLICY_VERSION, SANDBOX_IDENTITY, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const requireForTest = createRequire(import.meta.url); const policies = requireForTest( path.join(import.meta.dirname, "..", "../..", "src", "lib", "policy", "index.ts"), ) as typeof import("../../../src/lib/policy"); -const policyAuthority = requireForTest( +const policyState = requireForTest( path.join( import.meta.dirname, "..", @@ -27,9 +27,9 @@ const policyAuthority = requireForTest( "lib", "adapters", "openshell", - "policy-authority.ts", + "policy-state.ts", ), -) as typeof import("../../../src/lib/adapters/openshell/policy-authority"); +) as typeof import("../../../src/lib/adapters/openshell/policy-state"); const registry = requireForTest( path.join(import.meta.dirname, "..", "../..", "src", "lib", "state", "registry.ts"), ) as typeof import("../../../src/lib/state/registry"); @@ -51,12 +51,12 @@ describe("OpenShell policy mutation read failures", () => { const tempDirs: string[] = []; beforeEach(() => { - vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue({ - authority: "owner-unknown", + vi.spyOn(policyState, "inspectSandboxPolicy").mockReturnValue({ + policySource: "sandbox", effectivePolicy: {}, policyIdentity: { hash: POLICY_HASH, activeVersion: POLICY_VERSION }, }); - vi.spyOn(policyAuthority, "inspectOpenShellSandboxIdentityFingerprint").mockReturnValue( + vi.spyOn(policyState, "inspectOpenShellSandboxIdentityFingerprint").mockReturnValue( SANDBOX_IDENTITY, ); vi.spyOn(registry, "getSandbox").mockReturnValue(managedSandboxEntry("alpha")); @@ -92,7 +92,7 @@ describe("OpenShell policy mutation read failures", () => { expect(apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); - expect(calls).toEqual(["policy get --base alpha"]); + expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("refusing to apply")); }); @@ -124,7 +124,7 @@ describe("OpenShell policy mutation read failures", () => { expect(apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); - expect(calls).toEqual(["policy get --base alpha"]); + expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); expect( mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), @@ -158,7 +158,7 @@ describe("OpenShell policy mutation read failures", () => { expect(apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); - expect(calls).toEqual(["policy get --base alpha"]); + expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); expect( mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), @@ -192,7 +192,7 @@ describe("OpenShell policy mutation read failures", () => { expect(apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); - expect(calls).toEqual(["policy get --base alpha"]); + expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); expect( mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), diff --git a/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts b/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts index b24498f46ea..ca3e9201793 100644 --- a/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts +++ b/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts @@ -6,14 +6,14 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { - managedPolicyMetadata, + livePolicyMetadata, managedSandboxEntry, parseResultPayload, SANDBOX_ID, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const requireForTest = createRequire(import.meta.url); const YAML = requireForTest("yaml"); @@ -52,16 +52,6 @@ const REVIEWED_NPM_ENTRY = { ], }; -const UNRELATED_POLICY_ENTRY = { - name: "unrelated_fixture", - endpoints: [{ host: "fixture.example.com", port: 443, access: "full" }], - binaries: [{ path: "/usr/bin/fixture" }], -}; - -const REVIEWED_NPM_PRESET = YAML.stringify({ - preset: { name: "npm", description: "independent npm compatibility fixture" }, - network_policies: { npm_yarn: REVIEWED_NPM_ENTRY }, -}); const REVIEWED_PERSONAL_ENTRY = YAML.parse( fs.readFileSync( path.join(REPO_ROOT, "nemoclaw-blueprint/policies/presets/personal-open-internet.yaml"), @@ -73,14 +63,6 @@ function policyWith(networkPolicies: Record): string { return YAML.stringify({ version: 1, network_policies: networkPolicies }); } -function reviewedBaselinePolicy(): string { - return policyWith({ npm_registry: structuredClone(REVIEWED_BASELINE_ENTRY) }); -} - -function excludedBaselinePolicy(): string { - return policyWith({ unrelated_fixture: structuredClone(UNRELATED_POLICY_ENTRY) }); -} - function compatibilityEntry(npmEntry = REVIEWED_NPM_ENTRY) { const registryEndpoint = npmEntry.endpoints.find( (endpoint) => endpoint.host === "registry.npmjs.org" && endpoint.port === 443, @@ -118,7 +100,6 @@ function olderNpmEntry() { type LiveScenario = { sandboxName: string; - policies: string[]; initialPolicy: string; childScript: string; setMode?: "success" | "fail" | "fail-once"; @@ -126,7 +107,6 @@ type LiveScenario = { function runLiveScenario({ sandboxName, - policies: registeredPolicies, initialPolicy, childScript, setMode = "success", @@ -152,7 +132,7 @@ if [ "$1 $2" = "sandbox get" ]; then exit 0 fi if [ "$1 $2" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then - printf '%s\n' ${JSON.stringify(managedPolicyMetadata(sandboxName))} + printf '%s\n' ${JSON.stringify(livePolicyMetadata(sandboxName))} exit 0 fi printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} @@ -191,7 +171,6 @@ const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); registry.registerSandbox(${JSON.stringify({ ...managedSandboxEntry(sandboxName), - policies: registeredPolicies, })}); ${childScript} `; @@ -222,16 +201,6 @@ ${childScript} } } -function exclusionRegistration(sandboxName: string, digestCharacter: string): string { - return ` -registry.addBaselineExclusion(${JSON.stringify(sandboxName)}, { - version: 1, - agent: "openclaw", - key: "npm_registry", - digest: ${JSON.stringify(digestCharacter.repeat(64))}, -});`; -} - describe("OpenClaw npm compatibility policy lifecycle", () => { it("removes npm attribution superseded by Personal without mutating live policy", () => { const initialPolicy = policyWith({ @@ -239,7 +208,6 @@ describe("OpenClaw npm compatibility policy lifecycle", () => { }); const { calls, payload } = runLiveScenario({ sandboxName: "personal-owner", - policies: ["personal-open-internet", "npm"], initialPolicy, childScript: ` const removed = policies.removePreset("personal-owner", "npm"); @@ -252,49 +220,11 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ expect(payload.removed).toBe(true); expect(payload.policy).toBe(initialPolicy); - expect(payload.registry.policies).toEqual(["personal-open-internet"]); - expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(1); + expect(payload.registry).not.toHaveProperty("policies"); + expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(2); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); }); - it("preserves superseded npm attribution while baseline repair is pending", () => { - const initialPolicy = policyWith({ - personal_open_internet: structuredClone(REVIEWED_PERSONAL_ENTRY), - }); - const { calls, payload, stderr } = runLiveScenario({ - sandboxName: "personal-pending", - policies: ["personal-open-internet", "npm"], - initialPolicy, - childScript: ` -registry.beginBaselineExclusionTransition("personal-pending", { - id: "123e4567-e89b-42d3-a456-426614174920", - operation: "exclude", - exclusion: { - version: 1, - agent: "openclaw", - key: "npm_registry", - digest: "d".repeat(64), - }, - targetLiveDigest: null, - startedAt: "2026-08-17T00:00:00.000Z", -}); -const removed = policies.removePreset("personal-pending", "npm", { nonFatal: true }); -process.stdout.write("\\n__RESULT__" + JSON.stringify({ - removed, - policy: fs.readFileSync(process.env.CURRENT_POLICY, "utf-8"), - registry: registry.getSandbox("personal-pending"), -}));`, - }); - - expect(payload.removed).toBe(false); - expect(payload.policy).toBe(initialPolicy); - expect(payload.registry.policies).toEqual(["personal-open-internet", "npm"]); - expect(payload.registry.baselineExclusionTransition).toBeDefined(); - expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(1); - expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); - expect(stderr).toContain("baseline repair for 'npm_registry' is still pending"); - }); - it("does not restore overlapping npm web routes beside Personal during removal", () => { const initialPolicy = policyWith({ personal_open_internet: structuredClone(REVIEWED_PERSONAL_ENTRY), @@ -303,7 +233,6 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ }); const { calls, payload } = runLiveScenario({ sandboxName: "personal-npm", - policies: ["personal-open-internet", "npm"], initialPolicy, childScript: ` const removed = policies.removePreset("personal-npm", "npm"); @@ -318,14 +247,13 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ expect(payload.policy.network_policies).toEqual({ personal_open_internet: REVIEWED_PERSONAL_ENTRY, }); - expect(payload.registry.policies).toEqual(["personal-open-internet"]); + expect(payload.registry).not.toHaveProperty("policies"); expect(calls.filter((call) => call.startsWith("policy set "))).toHaveLength(1); }); it("repairs an active npm preset and restores the reviewed baseline on removal (#8497)", () => { const { calls, payload, stdout } = runLiveScenario({ sandboxName: "npm-lifecycle", - policies: ["npm"], initialPolicy: unoverlaidActivePolicy(), childScript: ` const beforeApplyState = policies.getOpenClawNpmCompatibilityState("npm-lifecycle"); @@ -353,11 +281,11 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ expect(payload.removed).toBe(true); expect(payload.afterRemove.network_policies.npm_yarn).toBeUndefined(); expect(payload.afterRemove.network_policies.npm_registry).toEqual(REVIEWED_BASELINE_ENTRY); - expect(payload.registry.policies).toEqual([]); + expect(payload.registry).not.toHaveProperty("policies"); expect(stdout).toContain("Effective egress scope that would replace the current preset policy"); expect(stdout).toContain("OpenClaw npm compatibility"); expect(stdout).not.toContain("already effective; no new egress would be opened"); - expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(6); + expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(7); expect(calls.filter((call) => call.startsWith("policy set "))).toHaveLength(2); }); @@ -366,7 +294,6 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ const oldActivePolicy = YAML.parse(activePolicy(oldNpmEntry)); const { calls, payload, stderr } = runLiveScenario({ sandboxName: "npm-old-overlay", - policies: ["npm"], initialPolicy: YAML.stringify(oldActivePolicy), setMode: "fail-once", childScript: ` @@ -387,12 +314,12 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ expect(payload.failedRemoval).toBe(false); expect(payload.afterFailedPolicy).toEqual(oldActivePolicy); - expect(payload.afterFailedRegistry.policies).toEqual(["npm"]); + expect(payload.afterFailedRegistry).not.toHaveProperty("policies"); expect(payload.removed, stderr).toBe(true); expect(payload.afterRemove.network_policies.npm_yarn).toBeUndefined(); expect(payload.afterRemove.network_policies.npm_registry).toEqual(REVIEWED_BASELINE_ENTRY); - expect(payload.registry.policies).toEqual([]); - expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(3); + expect(payload.registry).not.toHaveProperty("policies"); + expect(calls.filter((call) => call.startsWith("policy get "))).toHaveLength(6); expect(calls.filter((call) => call.startsWith("policy set "))).toHaveLength(2); }); @@ -401,7 +328,6 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ drifted.network_policies.npm_registry.endpoints[0].tls = "auto"; const { calls, payload, stderr } = runLiveScenario({ sandboxName: "npm-drift", - policies: ["npm"], initialPolicy: YAML.stringify(drifted), childScript: ` const removed = policies.removePreset("npm-drift", "npm", { nonFatal: true }); @@ -414,165 +340,8 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ expect(payload.removed).toBe(false); expect(payload.policy).toEqual(drifted); - expect(payload.registry.policies).toEqual(["npm"]); + expect(payload.registry).not.toHaveProperty("policies"); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); expect(stderr).toContain("differs from both the reviewed baseline"); }); - - it("keeps an approved baseline exclusion absent through apply and removal (#8497)", () => { - const { calls, payload, stderr } = runLiveScenario({ - sandboxName: "npm-excluded", - policies: [], - initialPolicy: excludedBaselinePolicy(), - childScript: ` -${exclusionRegistration("npm-excluded", "a")} -const applied = policies.applyPresets("npm-excluded", ["npm"]); -const afterApply = YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")); -const removed = policies.removePreset("npm-excluded", "npm"); -const afterRemove = YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")); -process.stdout.write("\\n__RESULT__" + JSON.stringify({ - applied, - afterApply, - removed, - afterRemove, - registry: registry.getSandbox("npm-excluded"), -}));`, - }); - - expect(payload.applied).toBe(true); - expect(payload.afterApply.network_policies.npm_yarn).toEqual(REVIEWED_NPM_ENTRY); - expect(payload.afterApply.network_policies.npm_registry).toBeUndefined(); - expect(payload.removed, stderr).toBe(true); - expect(payload.afterRemove.network_policies.npm_yarn).toBeUndefined(); - expect(payload.afterRemove.network_policies.npm_registry).toBeUndefined(); - expect(payload.registry.policies).toEqual([]); - expect(payload.registry.baselineExclusions).toHaveLength(1); - expect(calls.filter((call) => call.startsWith("policy set "))).toHaveLength(2); - }); - - it("refuses batch apply when an excluded baseline drifted back into the live policy (#8497)", () => { - const initialPolicy = reviewedBaselinePolicy(); - const { calls, payload, stderr } = runLiveScenario({ - sandboxName: "npm-excl-batch", - policies: [], - initialPolicy, - childScript: ` -${exclusionRegistration("npm-excl-batch", "b")} -const applied = policies.applyPresets("npm-excl-batch", ["npm"]); -process.stdout.write("\\n__RESULT__" + JSON.stringify({ - applied, - policy: fs.readFileSync(process.env.CURRENT_POLICY, "utf-8"), - registry: registry.getSandbox("npm-excl-batch"), -}));`, - }); - - expect(payload.applied).toBe(false); - expect(YAML.parse(payload.policy)).toEqual(YAML.parse(initialPolicy)); - expect(payload.registry.policies).toEqual([]); - expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); - expect(stderr).toContain("requires the live entry to remain absent"); - }); - - it("refuses direct apply when an excluded baseline drifted back into the live policy (#8497)", () => { - const initialPolicy = reviewedBaselinePolicy(); - const { calls, payload, stderr } = runLiveScenario({ - sandboxName: "npm-excl-direct", - policies: [], - initialPolicy, - childScript: ` -${exclusionRegistration("npm-excl-direct", "c")} -const applied = policies.applyPreset("npm-excl-direct", "npm"); -process.stdout.write("\\n__RESULT__" + JSON.stringify({ - applied, - policy: fs.readFileSync(process.env.CURRENT_POLICY, "utf-8"), - registry: registry.getSandbox("npm-excl-direct"), -}));`, - }); - - expect(payload.applied).toBe(false); - expect(YAML.parse(payload.policy)).toEqual(YAML.parse(initialPolicy)); - expect(payload.registry.policies).toEqual([]); - expect(calls).toHaveLength(1); - expect(calls[0]).toContain("policy get"); - expect(stderr).toContain("requires the live entry to remain absent"); - }); - - it("rejects custom ownership of the reserved npm compatibility key (#8497)", () => { - const policies = requireForTest( - path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), - ) as typeof import("../../../src/lib/policy"); - const errors: string[] = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { - errors.push(args.map(String).join(" ")); - }); - - try { - expect( - policies.applyPresetContent("custom-npm-key", "custom-registry", REVIEWED_NPM_PRESET, { - custom: { sourcePath: "/tmp/custom-registry.yaml" }, - }), - ).toBe(false); - expect(errors.join("\n")).toContain("reserved network policy key 'npm_yarn'"); - } finally { - errorSpy.mockRestore(); - } - }); - - it.each(["hermes", "langchain-deepagents-code"] as const)( - "does not inject an OpenClaw baseline into other agent policies [%s] (#8497)", - (agent) => { - const policies = requireForTest( - path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), - ) as typeof import("../../../src/lib/policy"); - - const result = policies.mergePresetNamesIntoPolicy(excludedBaselinePolicy(), ["npm"], { - agent, - }); - const effective = YAML.parse(result.policy); - expect(effective.network_policies.npm_yarn, agent).toBeDefined(); - expect(effective.network_policies.npm_registry, agent).toBeUndefined(); - }, - ); - - it("keeps an approved baseline exclusion absent during create-time composition (#8497)", () => { - const policies = requireForTest( - path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), - ) as typeof import("../../../src/lib/policy"); - const result = policies.mergePresetNamesIntoPolicy(excludedBaselinePolicy(), ["npm"], { - agent: "openclaw", - excludedBaselineKeys: ["npm_registry"], - }); - const effective = YAML.parse(result.policy); - - expect(effective.network_policies.npm_yarn).toBeDefined(); - expect(effective.network_policies.npm_registry).toBeUndefined(); - }); - - it("refuses create-time composition from a drifted OpenClaw npm baseline (#8497)", () => { - const policies = requireForTest( - path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), - ) as typeof import("../../../src/lib/policy"); - const driftedBaseline = YAML.parse(reviewedBaselinePolicy()); - driftedBaseline.network_policies.npm_registry.binaries = [{ path: "/**" }]; - - expect(() => - policies.mergePresetNamesIntoPolicy(YAML.stringify(driftedBaseline), ["npm"], { - agent: "openclaw", - }), - ).toThrow(/differs from the reviewed baseline/i); - }); - - it("discloses the temporary OpenClaw baseline widening and exact restoration (#8497)", () => { - const policies = requireForTest( - path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), - ) as typeof import("../../../src/lib/policy"); - const lines: string[] = []; - policies.logOpenClawNpmCompatibilityDisclosure((line) => lines.push(line)); - - expect(lines.join("\n")).toContain("/usr/local/bin/openclaw"); - expect(lines.join("\n")).toContain("GET-only REST"); - expect(lines.join("\n")).toContain("full L4 pass-through"); - expect(lines.join("\n")).toContain("HTTP methods and paths are not inspected"); - expect(lines.join("\n")).toContain("restores the exact reviewed GET-only baseline"); - }); }); diff --git a/test/runtime/policy/policy-preset-noop-disclosure.test.ts b/test/runtime/policy/policy-preset-noop-disclosure.test.ts index 67067d00573..e23509602ae 100644 --- a/test/runtime/policy/policy-preset-noop-disclosure.test.ts +++ b/test/runtime/policy/policy-preset-noop-disclosure.test.ts @@ -14,7 +14,7 @@ import { POLICY_HASH, POLICY_VERSION, SANDBOX_ID, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); const POLICY_MODULE = JSON.stringify(path.join(REPO_ROOT, "src/lib/policy/index.ts")); @@ -144,7 +144,7 @@ describe("preset no-op egress disclosure (#7179)", () => { expect(output).toContain("Preset 'npm' is already effective; no new egress would be opened."); expect(output).not.toContain("Effective egress that would be opened:"); expect(payload.calls).toEqual([]); - expect(payload.registry.policies).toEqual(["npm"]); + expect(payload.registry).not.toHaveProperty("policies"); }); it("skips the gateway set when every batch preset already matches", () => { @@ -157,7 +157,7 @@ describe("preset no-op egress disclosure (#7179)", () => { expect(output).toContain("Preset 'npm' is already effective"); expect(output).toContain("Preset 'pypi' is already effective"); expect(payload.calls).toEqual([]); - expect(payload.registry.policies).toEqual(["npm", "pypi"]); + expect(payload.registry).not.toHaveProperty("policies"); }); it("discloses and submits only the absent part of a mixed batch", () => { @@ -194,7 +194,7 @@ describe("preset no-op egress disclosure (#7179)", () => { expect(output).not.toContain("Effective egress"); expect(output).not.toContain("Preset 'npm' is already effective"); expect(payload.calls).toEqual(["policy set"]); - expect(payload.registry.policies).toEqual(["npm"]); + expect(payload.registry).not.toHaveProperty("policies"); }); it("discloses again when the live policy changed after an earlier no-op preview (#7179)", () => { diff --git a/test/runtime/policy/policy-roundtrip-docs.test.ts b/test/runtime/policy/policy-roundtrip-docs.test.ts index 7c5d805aab7..a1f6ed66f96 100644 --- a/test/runtime/policy/policy-roundtrip-docs.test.ts +++ b/test/runtime/policy/policy-roundtrip-docs.test.ts @@ -66,11 +66,11 @@ describe("policy round-trip documentation examples", () => { }); it.each(Array.from(SNAPSHOT_RESTORE_DOCS, (value) => [value]))( - "defines matching policy states in %s after a restore warning (#8210)", + "documents OpenShell policy authority without post-restore replay in %s", (docPath) => { - expect(readDoc(docPath), docPath).toContain( - "recorded in the sandbox registry and active on the gateway, or absent from both", - ); + const text = readDoc(docPath); + expect(text, docPath).toContain("current OpenShell policy"); + expect(text, docPath).not.toContain("recorded policy preset"); }, ); }); diff --git a/test/runtime/policy/policy-semantic-validation-runtime.test.ts b/test/runtime/policy/policy-semantic-validation-runtime.test.ts index 32ff887c481..b8edc7cde44 100644 --- a/test/runtime/policy/policy-semantic-validation-runtime.test.ts +++ b/test/runtime/policy/policy-semantic-validation-runtime.test.ts @@ -7,34 +7,28 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - managedPolicyInspection, + livePolicyInspection, managedSandboxEntry, SANDBOX_IDENTITY, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const { + captureSandboxBasePolicy, getSandbox, inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, - runCapture, + inspectSandboxPolicy, } = vi.hoisted(() => ({ - getSandbox: vi.fn(), - inspectOpenShellSandboxIdentityFingerprint: vi.fn(), - inspectSandboxPolicyAuthority: vi.fn(), - runCapture: vi.fn(), -})); - -vi.mock("../../../src/lib/adapters/openshell/policy-authority", async (importOriginal) => ({ - ...(await importOriginal< - typeof import("../../../src/lib/adapters/openshell/policy-authority") - >()), + captureSandboxBasePolicy: vi.fn(), + getSandbox: vi.fn(), + inspectOpenShellSandboxIdentityFingerprint: vi.fn(), + inspectSandboxPolicy: vi.fn(), + })); + +vi.mock("../../../src/lib/adapters/openshell/policy-state", async (importOriginal) => ({ + ...(await importOriginal()), + captureSandboxBasePolicy, inspectOpenShellSandboxIdentityFingerprint, - inspectSandboxPolicyAuthority, -})); - -vi.mock("../../../src/lib/runner", async (importOriginal) => ({ - ...(await importOriginal()), - runCapture, + inspectSandboxPolicy, })); vi.mock("../../../src/lib/state/registry", async (importOriginal) => ({ @@ -59,11 +53,11 @@ network_policies: beforeEach(() => { getSandbox.mockReset(); getSandbox.mockImplementation((name: string) => managedSandboxEntry(name)); - inspectSandboxPolicyAuthority.mockReset(); - inspectSandboxPolicyAuthority.mockReturnValue(managedPolicyInspection()); + inspectSandboxPolicy.mockReset(); + inspectSandboxPolicy.mockReturnValue(livePolicyInspection()); inspectOpenShellSandboxIdentityFingerprint.mockReset(); inspectOpenShellSandboxIdentityFingerprint.mockReturnValue(SANDBOX_IDENTITY); - runCapture.mockReset(); + captureSandboxBasePolicy.mockReset(); }); afterEach(() => { @@ -91,7 +85,7 @@ describe("custom policy semantic validation", () => { ), ).toBe(false); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("*:443")); - expect(runCapture).not.toHaveBeenCalled(); + expect(captureSandboxBasePolicy).not.toHaveBeenCalled(); } finally { errSpy.mockRestore(); } @@ -127,7 +121,7 @@ describe("custom policy semantic validation", () => { describe("Personal policy mutation validation", () => { it("returns false for non-fatal application when the reserved Personal entry drifts", () => { - runCapture.mockReturnValue(DRIFTED_PERSONAL_POLICY); + captureSandboxBasePolicy.mockReturnValue(DRIFTED_PERSONAL_POLICY); const weatherPreset = loadPreset("weather"); expect(weatherPreset).not.toBeNull(); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -147,7 +141,7 @@ describe("Personal policy mutation validation", () => { }); it("throws for ordinary application when the reserved Personal entry drifts", () => { - runCapture.mockReturnValue(DRIFTED_PERSONAL_POLICY); + captureSandboxBasePolicy.mockReturnValue(DRIFTED_PERSONAL_POLICY); const weatherPreset = loadPreset("weather"); expect(weatherPreset).not.toBeNull(); diff --git a/test/runtime/policy/policy-tiers-onboard-restricted-stale-otel.test.ts b/test/runtime/policy/policy-tiers-onboard-restricted-stale-otel.test.ts deleted file mode 100644 index bc89c2e92ae..00000000000 --- a/test/runtime/policy/policy-tiers-onboard-restricted-stale-otel.test.ts +++ /dev/null @@ -1,168 +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 path from "node:path"; -import { describe, it } from "vitest"; - -import { - buildPolicyTierOnboardPreamble as buildPreamble, - policyTierOnboardScriptRepoRoot as repoRoot, - runPolicyTierOnboardScript as runScript, -} from "../../helpers/policy-tier-onboard-script"; - -const policiesPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); - -function buildRestrictedOpenclawScript({ - applied, - selectedPresets, - resumeTier, -}: { - applied: string[]; - selectedPresets?: string[]; - resumeTier?: boolean; -}): string { - const resumePreamble = resumeTier - ? `\nregistry.getSandbox = () => ({ name: "test-sb", policyTier: "restricted" });\n` - : ""; - const callOpts = - selectedPresets !== undefined ? `, selectedPresets: ${JSON.stringify(selectedPresets)}` : ""; - return ( - buildPreamble({ - tierEnv: "restricted", - policyMode: "suggested", - stubOpenshellBin: true, - runCaptureReturn: "Running", - }) + - String.raw`${resumePreamble} -const policies = require(${policiesPath}); -const appliedCalls = []; -const removedCalls = []; -policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; -policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; -policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; -policies.getAppliedPresets = () => ${JSON.stringify(applied)}; - -console.log = () => {}; - -(async () => { - try { - const applied = await setupPoliciesWithSelection("test-sb", { agent: "openclaw"${callOpts} }); - process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -` - ); -} - -const otelDisabledEnv = { - NEMOCLAW_OPENCLAW_OTEL: undefined, - NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, -}; - -describe("restricted tier reconciles stale openclaw-diagnostics-otel-local with OTEL disabled", () => { - it("non-interactive path removes a previously-applied openclaw-diagnostics-otel-local", () => { - const script = buildRestrictedOpenclawScript({ - applied: ["openclaw-diagnostics-otel-local"], - }); - const result = runScript(script, otelDisabledEnv); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); - assert.ok( - !payload.applied.includes("openclaw-diagnostics-otel-local"), - `restricted reconciliation must exclude stale openclaw-diagnostics-otel-local when OTEL is disabled; got: ${JSON.stringify(payload.applied)}`, - ); - assert.ok( - payload.removedCalls.includes("openclaw-diagnostics-otel-local"), - `restricted reconciliation must call removePreset for stale openclaw-diagnostics-otel-local when OTEL is disabled; got: ${JSON.stringify(payload.removedCalls)}`, - ); - }); - - it("resume path excludes a previously-applied openclaw-diagnostics-otel-local", () => { - const script = buildRestrictedOpenclawScript({ - applied: ["openclaw-diagnostics-otel-local"], - selectedPresets: [], - resumeTier: true, - }); - const result = runScript(script, otelDisabledEnv); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); - assert.ok( - !payload.applied.includes("openclaw-diagnostics-otel-local"), - `resume target must exclude stale openclaw-diagnostics-otel-local on restricted when OTEL is disabled; got: ${JSON.stringify(payload.applied)}`, - ); - assert.ok( - payload.removedCalls.includes("openclaw-diagnostics-otel-local"), - `restricted resume must call removePreset for stale openclaw-diagnostics-otel-local when OTEL is disabled; got: ${JSON.stringify(payload.removedCalls)}`, - ); - }); -}); - -describe("restricted recordedTierName plumbing through resume", () => { - it("resume against an originally-restricted sandbox filters openclaw-pricing from the operator's selected presets", () => { - const script = buildRestrictedOpenclawScript({ - applied: [], - selectedPresets: ["openclaw-pricing", "npm"], - resumeTier: true, - }); - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); - assert.ok( - !payload.applied.includes("openclaw-pricing"), - `resume target must filter openclaw-pricing when recordedTierName='restricted'; got: ${JSON.stringify(payload.applied)}`, - ); - assert.ok( - !payload.appliedCalls.includes("openclaw-pricing"), - `resume must not call applyPreset/applyPresets for openclaw-pricing on restricted recordedTierName; got: ${JSON.stringify(payload.appliedCalls)}`, - ); - }); - - it("resume against an originally-balanced sandbox preserves operator-selected openclaw-pricing", () => { - const script = - buildPreamble({ - tierEnv: "balanced", - policyMode: "suggested", - stubOpenshellBin: true, - runCaptureReturn: "Running", - }) + - String.raw` -registry.getSandbox = () => ({ name: "test-sb", policyTier: "balanced" }); - -const policies = require(${policiesPath}); -const appliedCalls = []; -const removedCalls = []; -policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; -policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; -policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; -policies.getAppliedPresets = () => []; - -console.log = () => {}; - -(async () => { - try { - const applied = await setupPoliciesWithSelection("test-sb", { - agent: "openclaw", - selectedPresets: ["openclaw-pricing", "npm"], - }); - process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); - assert.ok( - payload.applied.includes("openclaw-pricing"), - `resume target must keep openclaw-pricing when recordedTierName='balanced'; got: ${JSON.stringify(payload.applied)}`, - ); - }); -}); diff --git a/test/runtime/policy/policy-tiers-onboard.test.ts b/test/runtime/policy/policy-tiers-onboard.test.ts index 82ca2e1327d..cbf01ced633 100644 --- a/test/runtime/policy/policy-tiers-onboard.test.ts +++ b/test/runtime/policy/policy-tiers-onboard.test.ts @@ -94,7 +94,6 @@ type SetupHarnessOptions = { currentApplied?: string[]; customPresets?: TestPreset[]; customOwnsObservability?: boolean; - recordedPolicyTier?: string | null; nonInteractive?: boolean; env?: NodeJS.ProcessEnv; }; @@ -106,7 +105,6 @@ function createSetupHarness({ currentApplied = [], customPresets = [], customOwnsObservability = false, - recordedPolicyTier = null, nonInteractive = true, env = {}, }: SetupHarnessOptions = {}) { @@ -119,8 +117,6 @@ function createSetupHarness({ }> = []; const appliedCalls: string[] = []; const removedCalls: string[] = []; - const tierUpdates: Array<{ sandboxName: string; policyTier: string }> = []; - const removedBuiltinAttributions: string[] = []; const deps: SetupPolicySelectionDeps = { policies: { @@ -134,9 +130,6 @@ function createSetupHarness({ ], listCustomPresets: () => customPresets, customPresetOwnsNetworkPolicyKey: () => customOwnsObservability, - removeBuiltinPresetAttribution: (_sandboxName, presetName) => { - removedBuiltinAttributions.push(presetName); - }, getAppliedPresets: () => [...currentApplied], clampSetupPolicyPresetNames: policy.clampSetupPolicyPresetNames, }, @@ -160,10 +153,6 @@ function createSetupHarness({ appliedCalls.push(...selected.filter((name) => !currentSet.has(name))); }, selectPolicyTier: async () => tierName, - setPolicyTier: (sandboxName, policyTier) => { - tierUpdates.push({ sandboxName, policyTier }); - }, - getRecordedPolicyTier: () => recordedPolicyTier, selectTierPresetsAndAccess: async (selectedTier, presets, initialSelected) => { const promptHarness = createPromptHarness(); return promptHarness.helpers.selectTierPresetsAndAccess( @@ -184,10 +173,8 @@ function createSetupHarness({ appliedCalls, deps, notes, - removedBuiltinAttributions, removedCalls, syncCalls, - tierUpdates, }; } @@ -314,51 +301,6 @@ process.exit = (code = 0) => { assert.match(result.stderr, /Interactive onboarding requires a TTY/); assert.ok(!result.stdout.includes("UNEXPECTED_SUCCESS")); }); - - it("persists the selected tier through the onboard registry adapter", () => { - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const policyPath = JSON.stringify(path.join(repoRoot, "src", "lib", "policy", "index.ts")); - const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); - const refreshPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "actions", "sandbox", "policy-context-refresh.ts"), - ); - const script = String.raw` -const registry = require(${registryPath}); -const updates = []; -registry.getSandbox = () => ({ name: "test-sb", model: null, provider: null }); -registry.updateSandbox = (_name, fields) => { updates.push(fields); return true; }; - -process.env.NEMOCLAW_NON_INTERACTIVE = "1"; -process.env.NEMOCLAW_POLICY_TIER = "open"; -process.env.NEMOCLAW_POLICY_MODE = "skip"; -process.env.NEMOCLAW_POLICY_PRESETS = ""; - -const { setupPoliciesWithSelection } = require(${onboardPath}); -const policies = require(${policyPath}); -policies.getAppliedPresets = () => []; -require(${refreshPath}).refreshSandboxPolicyContextFile = () => ({ status: "ok" }); -console.log = () => {}; - -(async () => { - try { - const applied = await setupPoliciesWithSelection("test-sb", {}); - process.stdout.write(JSON.stringify({ applied, updates }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message, stack: err.stack, updates }) + "\n"); - } -})(); -`; - const result = runAdapterScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split(/\n/).at(-1) || "{}"); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); - assert.deepEqual(payload.applied, []); - assert.equal( - payload.updates.find((update: { policyTier?: string }) => update.policyTier !== undefined) - ?.policyTier, - "open", - ); - }); }); describe("policy tier selection", () => { @@ -459,14 +401,13 @@ describe("policy tier setup", () => { it("persists the selected tier through setPolicyTier", async () => { const result = await runPolicySetup({ tierName: "open", policyMode: "skip" }); - assert.deepEqual(result.tierUpdates, [{ sandboxName: "test-sb", policyTier: "open" }]); assert.deepEqual(result.applied, []); }); it("repairs a resumed Personal selection before recording or syncing it", async () => { const harness = createSetupHarness({ currentApplied: ["personal-open-internet"], - recordedPolicyTier: "personal", + tierName: "personal", }); const onSelection = vi.fn(); @@ -477,7 +418,6 @@ describe("policy tier setup", () => { }); assert.deepEqual(selected, ["personal-open-internet", "weather"]); - assert.deepEqual(harness.tierUpdates, []); assert.deepEqual(harness.syncCalls, [ { sandboxName: "test-sb", @@ -491,7 +431,7 @@ describe("policy tier setup", () => { it("repairs missing Personal attribution when the tier is recorded", async () => { const harness = createSetupHarness({ currentApplied: [], - recordedPolicyTier: "personal", + tierName: "personal", }); const onSelection = vi.fn(); @@ -502,7 +442,6 @@ describe("policy tier setup", () => { }); assert.deepEqual(selected, ["personal-open-internet", "weather"]); - assert.deepEqual(harness.tierUpdates, []); assert.deepEqual(harness.syncCalls, [ { sandboxName: "test-sb", @@ -566,7 +505,6 @@ describe("policy tier setup", () => { }) as never); await assert.rejects(setupPoliciesWithSelection(harness.deps, "test-sb"), /process\.exit\(1\)/); - assert.deepEqual(harness.tierUpdates, []); assert.deepEqual(harness.syncCalls, []); }); @@ -626,7 +564,6 @@ describe("policy tier setup", () => { const result = await runPolicySetup( { tierName: tier, - recordedPolicyTier: tier, nonInteractive, currentApplied: ["npm", "pypi", "huggingface", "brew", "brave", "openclaw-pricing"], }, @@ -710,12 +647,11 @@ describe("policy tier setup", () => { assert.deepEqual(result.syncCalls[0]?.selected, expectedPresets); }); - it("preserves a recorded Balanced tier default during resumed reapply (#6844)", async () => { + it("removes a stale Balanced web-search preset when live intent no longer requests it", async () => { const result = await runPolicySetup( { - tierName: "restricted", currentApplied: ["npm", "brave"], - recordedPolicyTier: "balanced", + tierName: "balanced", }, { selectedPresets: ["npm", "brave"], @@ -724,15 +660,15 @@ describe("policy tier setup", () => { }, ); - assert.deepEqual(result.applied, ["npm", "brave"]); + assert.deepEqual(result.applied, ["npm"]); assert.deepEqual(result.syncCalls, [ { sandboxName: "test-sb", current: ["npm", "brave"], - selected: ["npm", "brave"], + selected: ["npm"], }, ]); - assert.deepEqual(result.removedCalls, []); + assert.deepEqual(result.removedCalls, ["brave"]); }); it("clamps resumed policy presets to web-search-supported presets", async () => { @@ -832,7 +768,6 @@ describe("policy tier setup", () => { assert.ok(result.applied.includes("corp-otel")); assert.ok(!result.applied.includes("observability-otlp-local")); assert.ok(!result.removedCalls.includes("observability-otlp-local")); - assert.deepEqual(result.removedBuiltinAttributions, ["observability-otlp-local"]); }); it("keeps exact custom OTLP ownership during selected resume without live built-in removal", async () => { @@ -851,7 +786,6 @@ describe("policy tier setup", () => { assert.deepEqual(result.applied, ["corp-otel"]); assert.deepEqual(result.removedCalls, []); - assert.deepEqual(result.removedBuiltinAttributions, ["observability-otlp-local"]); }); it("does not let stale declared custom OTLP content suppress the required built-in", async () => { @@ -867,7 +801,6 @@ describe("policy tier setup", () => { assert.ok(result.applied.includes("corp-otel")); assert.ok(result.applied.includes("observability-otlp-local")); assert.ok(result.appliedCalls.includes("observability-otlp-local")); - assert.deepEqual(result.removedBuiltinAttributions, []); }); it("falls back to tier suggestions when NEMOCLAW_POLICY_MODE is unknown (#2429)", async () => { @@ -982,8 +915,8 @@ describe("policy tier setup", () => { it("keeps an empty restricted resume target empty", async () => { const result = await runPolicySetup( - { recordedPolicyTier: "restricted" }, - { agent: "openclaw", selectedPresets: [] }, + { tierName: "restricted" }, + { agent: "openclaw", selectedPresets: [], tierName: "restricted" }, ); assert.ok(!result.applied.includes("openclaw-pricing")); @@ -992,7 +925,7 @@ describe("policy tier setup", () => { it("never applies DCode observability while an authoritative restricted rebuild tier is pending registration", async () => { const result = await runPolicySetup( - { recordedPolicyTier: null }, + {}, { agent: "langchain-deepagents-code", observabilityEnabled: true, @@ -1008,8 +941,8 @@ describe("policy tier setup", () => { it("removes previously-applied OpenClaw pricing during a restricted resume", async () => { const result = await runPolicySetup( - { recordedPolicyTier: "restricted", currentApplied: ["openclaw-pricing"] }, - { agent: "openclaw", selectedPresets: [] }, + { tierName: "restricted", currentApplied: ["openclaw-pricing"] }, + { agent: "openclaw", selectedPresets: [], tierName: "restricted" }, ); assert.ok(!result.applied.includes("openclaw-pricing")); @@ -1019,14 +952,14 @@ describe("policy tier setup", () => { it("excludes OpenClaw OTEL diagnostics during a restricted resume", async () => { const result = await runPolicySetup( { - recordedPolicyTier: "restricted", + tierName: "restricted", currentApplied: ["openclaw-diagnostics-otel-local"], env: { NEMOCLAW_OPENCLAW_OTEL: "1", NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: undefined, }, }, - { agent: "openclaw", selectedPresets: [] }, + { agent: "openclaw", selectedPresets: [], tierName: "restricted" }, ); assert.ok(!result.applied.includes("openclaw-diagnostics-otel-local")); diff --git a/test/runtime/policy/portable-policy-failure-finality.test.ts b/test/runtime/policy/portable-policy-failure-finality.test.ts index 69f123e2396..d6cc87b4f3c 100644 --- a/test/runtime/policy/portable-policy-failure-finality.test.ts +++ b/test/runtime/policy/portable-policy-failure-finality.test.ts @@ -9,10 +9,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import YAML from "yaml"; import { - managedPolicyMetadata, + livePolicyMetadata, managedRegistrationSource, SANDBOX_ID, -} from "../../helpers/managed-policy-receipt-fixture"; +} from "../../helpers/live-policy-fixture"; const repoRoot = path.join(import.meta.dirname, "../../.."); const policyModulePath = path.join(repoRoot, "src", "lib", "policy", "index.ts"); @@ -109,7 +109,7 @@ function buildOpenshellStub( appliedPolicyPath: string, ): string { const policyMetadata = { - ...JSON.parse(managedPolicyMetadata(SANDBOX_NAME)), + ...JSON.parse(livePolicyMetadata(SANDBOX_NAME)), policy: YAML.parse(basePolicy), }; return `#!/bin/sh @@ -311,7 +311,7 @@ const POLICY_SET_FAILURES: ReadonlyArray = [ policySetExitCode: UNPARSEABLE_FAILURE_EXIT_CODE, policySetStderr: TRANSPORT_RESET_STDERR, expectedOperatorMessage: `Could not confirm the policy update for sandbox '${SANDBOX_NAME}'`, - expectedGuidance: "read the current policy back before retrying", + expectedGuidance: "The current live policy differs from the requested document", }, ]; @@ -354,7 +354,7 @@ describe.each(POLICY_SET_FAILURES)( const registry = JSON.parse( fs.readFileSync(path.join(run.homeDir, ".nemoclaw", "sandboxes.json"), "utf-8"), ) as { sandboxes: Record }; - expect(registry.sandboxes[SANDBOX_NAME]?.policies).toEqual([]); + expect(registry.sandboxes[SANDBOX_NAME]).not.toHaveProperty("policies"); }); }, ); @@ -423,7 +423,7 @@ const SINGLE_PRESET_MUTATIONS: ReadonlyArray = [ basePolicy: BASE_POLICY_WITHOUT_PRESET, policySet: { exitCode: UNPARSEABLE_FAILURE_EXIT_CODE, stderr: UNPARSEABLE_FAILURE_STDERR }, expectedOperatorMessage: `Could not confirm the policy update for sandbox '${SANDBOX_NAME}'`, - expectedGuidance: "read the current policy back before retrying", + expectedGuidance: "The current live policy differs from the requested document", expectedExitCode: UNPARSEABLE_FAILURE_EXIT_CODE, }, ]; diff --git a/test/runtime/policy/rebuild-policy-presets.test.ts b/test/runtime/policy/rebuild-policy-presets.test.ts deleted file mode 100644 index 029a70e9d96..00000000000 --- a/test/runtime/policy/rebuild-policy-presets.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Tests for issue #1952: rebuild should restore policy presets. -// -// Verifies that: -// 1. backupSandboxState() captures applied policy presets in the manifest -// 2. The rebuild flow re-applies presets from the manifest after restore - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; - -import { pruneDisabledMessagingPolicyPresets } from "../../../src/lib/onboard/messaging-policy-presets"; - -type ManifestWithOptionalPresets = { - version: number; - sandboxName: string; - timestamp: string; - agentType: string; - agentVersion: string | null; - expectedVersion: string | null; - stateDirs: string[]; - dir: string; - backupPath: string; - blueprintDigest: string | null; - policyPresets?: string[] | null; -}; - -describe("rebuild policy preset restoration (#1952)", () => { - describe("RebuildManifest policyPresets field", () => { - it("manifest interface accepts policyPresets array", () => { - // Verify the manifest structure supports policyPresets - const manifest: ManifestWithOptionalPresets = { - version: 1, - sandboxName: "test", - timestamp: "2026-04-17", - agentType: "openclaw", - agentVersion: "1.0.0", - expectedVersion: "1.0.0", - stateDirs: ["workspace"], - dir: "/sandbox/.openclaw", - backupPath: "/tmp/backup", - blueprintDigest: null, - policyPresets: ["telegram", "npm"], - }; - expect(manifest.policyPresets).toEqual(["telegram", "npm"]); - }); - - it("manifest policyPresets defaults to undefined when not set", () => { - const manifest: ManifestWithOptionalPresets = { - version: 1, - sandboxName: "test", - timestamp: "2026-04-17", - agentType: "openclaw", - agentVersion: null, - expectedVersion: null, - stateDirs: [], - dir: "/sandbox/.openclaw", - backupPath: "/tmp/backup", - blueprintDigest: null, - }; - expect(manifest.policyPresets).toBeUndefined(); - }); - - it("manifest policyPresets can be an empty array", () => { - const manifest: ManifestWithOptionalPresets = { - version: 1, - sandboxName: "test", - timestamp: "2026-04-17", - agentType: "openclaw", - agentVersion: null, - expectedVersion: null, - stateDirs: [], - dir: "/sandbox/.openclaw", - backupPath: "/tmp/backup", - blueprintDigest: null, - policyPresets: [], - }; - expect(manifest.policyPresets).toEqual([]); - }); - }); - - describe("manifest serialization round-trip", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-manifest-test-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("policyPresets survives JSON write and read", () => { - const manifest: ManifestWithOptionalPresets = { - version: 1, - sandboxName: "test-sandbox", - timestamp: "2026-04-17T10-00-00-000Z", - agentType: "openclaw", - agentVersion: "1.0.0", - expectedVersion: "1.0.0", - stateDirs: ["workspace", "memory"], - dir: "/sandbox/.openclaw", - backupPath: tmpDir, - blueprintDigest: "abc123", - policyPresets: ["telegram", "npm", "pypi"], - }; - - const manifestPath = path.join(tmpDir, "rebuild-manifest.json"); - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - - const read = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - expect(read.policyPresets).toEqual(["telegram", "npm", "pypi"]); - }); - - it("older manifests without policyPresets read as undefined", () => { - // Simulate a manifest from before the fix - const oldManifest: ManifestWithOptionalPresets = { - version: 1, - sandboxName: "test-sandbox", - timestamp: "2026-04-01T10-00-00-000Z", - agentType: "openclaw", - agentVersion: "1.0.0", - expectedVersion: "1.0.0", - stateDirs: ["workspace"], - dir: "/sandbox/.openclaw", - backupPath: tmpDir, - blueprintDigest: null, - }; - - const manifestPath = path.join(tmpDir, "rebuild-manifest.json"); - fs.writeFileSync(manifestPath, JSON.stringify(oldManifest, null, 2)); - - const read = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - // The rebuild code uses `backup.manifest.policyPresets || []` - // so undefined falls back to empty array safely - expect(read.policyPresets || []).toEqual([]); - }); - }); - - describe("rebuild policy restore logic", () => { - it("empty policyPresets array results in no restore action", () => { - // Simulates the conditional: if (savedPresets.length > 0) - const savedPresets = []; - expect(savedPresets.length).toBe(0); - }); - - it("undefined policyPresets falls back to empty array via || []", () => { - // Simulates: const savedPresets = backup.manifest.policyPresets || []; - const manifest = { policyPresets: undefined }; - const savedPresets = manifest.policyPresets || []; - expect(savedPresets).toEqual([]); - expect(savedPresets.length).toBe(0); - }); - - it("null policyPresets falls back to empty array via || []", () => { - const manifest = { policyPresets: null }; - const savedPresets = manifest.policyPresets || []; - expect(savedPresets).toEqual([]); - }); - - it("policyPresets with values triggers restore loop", () => { - const manifest = { policyPresets: ["telegram", "npm"] }; - const savedPresets = manifest.policyPresets || []; - expect(savedPresets.length).toBe(2); - expect(savedPresets).toContain("telegram"); - expect(savedPresets).toContain("npm"); - }); - - it("disabled messaging channel policy presets are not restored", () => { - const manifest = { policyPresets: ["npm", "slack", "pypi"] }; - const savedPresets = pruneDisabledMessagingPolicyPresets(manifest.policyPresets || [], [ - "slack", - ]); - expect(savedPresets).toEqual(["npm", "pypi"]); - }); - - it("removes optional channel presets when their channel is disabled", () => { - const manifest = { policyPresets: ["telegram", "npm", "pypi"] }; - const savedPresets = pruneDisabledMessagingPolicyPresets(manifest.policyPresets || [], [ - "telegram", - ]); - expect(savedPresets).toEqual(["npm", "pypi"]); - }); - }); -}); diff --git a/test/runtime/sandbox/reboot-identity-drift.test.ts b/test/runtime/sandbox/reboot-identity-drift.test.ts index ba96f64f910..707e2946598 100644 --- a/test/runtime/sandbox/reboot-identity-drift.test.ts +++ b/test/runtime/sandbox/reboot-identity-drift.test.ts @@ -52,7 +52,6 @@ function setupFixture(sandboxName: string, mode: "healthy" | "identity_drift" | model: "nvidia/test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, }), diff --git a/test/runtime/sandbox/sandbox-agent-surface-parity.test.ts b/test/runtime/sandbox/sandbox-agent-surface-parity.test.ts index da7b1fc2bae..046dbb383f4 100644 --- a/test/runtime/sandbox/sandbox-agent-surface-parity.test.ts +++ b/test/runtime/sandbox/sandbox-agent-surface-parity.test.ts @@ -49,7 +49,6 @@ describe("agent parity across sandbox inventory surfaces", () => { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], agent: null, }, }, diff --git a/test/runtime/sandbox/sandbox-status-json-stdout.test.ts b/test/runtime/sandbox/sandbox-status-json-stdout.test.ts index a31bd3bef6e..20588ef384a 100644 --- a/test/runtime/sandbox/sandbox-status-json-stdout.test.ts +++ b/test/runtime/sandbox/sandbox-status-json-stdout.test.ts @@ -5,15 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { getSandboxStatusReport } from "../../../src/lib/actions/sandbox/status-snapshot.js"; -// `sandbox status --json` builds a machine-readable report through -// getSandboxStatusReport, which reconciles the gateway. When the gateway needs -// recovery, the reconcile path prints human progress to stdout (step(), -// "Waiting for gateway health...", and so on). We inject a reconcile that -// writes that progress and assert the --json report builder -// keeps stdout clean; otherwise the JSON document on stdout is unparseable. -// Writes go through process.stdout.write directly (what console.log delegates -// to), so the test targets the exact stream the builder must keep clean. -describe("sandbox status --json keeps stdout clean during gateway recovery", () => { +describe("sandbox status JSON", () => { let originalWrite: typeof process.stdout.write; let captured: string[]; @@ -22,8 +14,10 @@ describe("sandbox status --json keeps stdout clean during gateway recovery", () originalWrite = process.stdout.write.bind(process.stdout); process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => { captured.push(typeof chunk === "string" ? chunk : String(chunk)); - const cb = rest.find((a) => typeof a === "function") as undefined | (() => void); - if (cb) cb(); + const callback = rest.find((value) => typeof value === "function") as + | (() => void) + | undefined; + callback?.(); return true; }) as typeof process.stdout.write; }); @@ -32,93 +26,54 @@ describe("sandbox status --json keeps stdout clean during gateway recovery", () process.stdout.write = originalWrite; }); - it("does not leak reconcile/recovery progress onto stdout (it would corrupt --json)", async () => { + it("keeps stdout clean during gateway recovery", async () => { const report = await getSandboxStatusReport("ghost-sandbox", { reconcile: async () => { - process.stdout.write("\n [2/8] Starting OpenShell gateway\n"); - process.stdout.write(" Starting gateway cluster...\n"); - process.stdout.write(" Waiting for gateway health...\n"); - return { - state: "gateway_unreachable_after_restart", - output: "Gateway: nemoclaw\nStatus: unreachable", - }; + process.stdout.write("gateway recovery progress\n"); + return { state: "gateway_unreachable_after_restart", output: "" }; }, }); process.stdout.write = originalWrite; - - const onStdout = captured.join(""); - expect(onStdout).not.toContain("Starting OpenShell gateway"); - expect(onStdout).not.toContain("Starting gateway cluster"); - expect(onStdout).toBe(""); - - expect(report.schemaVersion).toBe(1); - expect(report.name).toBe("ghost-sandbox"); - expect(report.found).toBe(false); - expect(report.gatewayState).toBe("gateway_unreachable_after_restart"); - expect(report.baselineExclusions).toEqual([]); - expect(report.baselineExclusionStates).toEqual([]); - expect(report.baselineExclusionTransition).toBeNull(); + expect(captured.join("")).toBe(""); + expect(report).toEqual( + expect.objectContaining({ + schemaVersion: 1, + name: "ghost-sandbox", + found: false, + gatewayState: "gateway_unreachable_after_restart", + }), + ); }); - it("reports unknown runtime when a non-OpenClaw registry agent cannot be loaded", async () => { - const report = await getSandboxStatusReport("custom-sandbox", { - getSandbox: () => - ({ - name: "custom-sandbox", - agent: "missing-terminal-agent", - provider: "nvidia-prod", - model: "test-model", - policies: [], - baselineExclusions: [ - { - version: 1, - agent: "missing-terminal-agent", - key: "nous_research", - digest: "a".repeat(64), - }, - ], - openshellDriver: "native", - }) as never, + it("does not expose removed policy shadow fields", async () => { + const report = await getSandboxStatusReport("alpha", { + getSandbox: () => ({ name: "alpha", agent: "openclaw" }), + getGatewayPresets: () => ["npm"], reconcile: async () => ({ state: "missing", output: "" }), - getBaselineExclusionRuntimeStatus: () => "live-policy-mismatch", }); - - expect(report.agent).toBe("missing-terminal-agent"); - expect(report.agentRuntime).toBe("unknown"); - expect(report.agentLoadError).toMatch(/missing-terminal-agent/); - expect(report.baselineExclusions).toEqual(["nous_research"]); - expect(report.baselineExclusionStates).toEqual([ - { key: "nous_research", status: "live-policy-mismatch" }, - ]); + expect(report.policies).toEqual(["npm"]); + expect(report.policiesAvailable).toBe(true); + expect(report).not.toHaveProperty("baselineExclusions"); + expect(report).not.toHaveProperty("baselineExclusionTransition"); }); - it("reports a pending baseline policy transaction separately from committed exclusions", async () => { - const report = await getSandboxStatusReport("repairing-sandbox", { - getSandbox: () => - ({ - name: "repairing-sandbox", - policies: [], - baselineExclusions: [], - baselineExclusionTransition: { - id: "tx-1", - operation: "exclude", - exclusion: { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - }, - targetLiveDigest: null, - startedAt: "2026-07-19T00:00:00.000Z", - }, - }) as never, - reconcile: async () => ({ state: "missing", output: "" }), + it("distinguishes unavailable live policy from a verified empty policy", async () => { + const base = { + getSandbox: () => ({ name: "alpha", agent: "openclaw" }), + reconcile: async () => ({ state: "missing" as const, output: "" }), + }; + const unavailable = await getSandboxStatusReport("alpha", { + ...base, + getGatewayPresets: () => null, }); - - expect(report.baselineExclusions).toEqual([]); - expect(report.baselineExclusionTransition).toEqual({ - operation: "exclude", - key: "nous_research", + const empty = await getSandboxStatusReport("alpha", { + ...base, + getGatewayPresets: () => [], }); + + expect(unavailable.policies).toEqual([]); + expect(unavailable.policiesAvailable).toBe(false); + expect(empty.policies).toEqual([]); + expect(empty.policiesAvailable).toBe(true); }); }); diff --git a/test/runtime/sandbox/sandbox-stuck-recovery.test.ts b/test/runtime/sandbox/sandbox-stuck-recovery.test.ts index 387c43a6b50..e9d96152566 100644 --- a/test/runtime/sandbox/sandbox-stuck-recovery.test.ts +++ b/test/runtime/sandbox/sandbox-stuck-recovery.test.ts @@ -36,7 +36,6 @@ function setupFixture(sandboxName: string, phase: string) { model: "nvidia/test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, }), diff --git a/test/sandbox-connect-inference/auto-pair-approval.test.ts b/test/sandbox-connect-inference/auto-pair-approval.test.ts index c5d45434227..68df3f39a8c 100644 --- a/test/sandbox-connect-inference/auto-pair-approval.test.ts +++ b/test/sandbox-connect-inference/auto-pair-approval.test.ts @@ -60,7 +60,6 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -105,7 +104,6 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -173,7 +171,6 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -224,7 +221,6 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -271,7 +267,6 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -319,7 +314,6 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -351,7 +345,6 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -392,7 +385,6 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", @@ -431,7 +423,6 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "anthropic-prod", "claude-sonnet-4-20250514", diff --git a/test/sandbox-connect-inference/route-swap-repair.test.ts b/test/sandbox-connect-inference/route-swap-repair.test.ts index c349e7865ce..68792a7306a 100644 --- a/test/sandbox-connect-inference/route-swap-repair.test.ts +++ b/test/sandbox-connect-inference/route-swap-repair.test.ts @@ -18,7 +18,6 @@ describe("sandbox connect inference route swap (#1248)", () => { model: "claude-sonnet-4-20250514", provider: "anthropic-prod", gpuEnabled: false, - policies: [], }, "nvidia-prod", "nvidia/nemotron-3-super-120b-a12b", @@ -57,7 +56,6 @@ describe("sandbox connect inference route swap (#1248)", () => { model: "qwen3:0.6b", provider: "ollama-local", gpuEnabled: false, - policies: [], }, "ollama-local", "qwen3:0.6b", diff --git a/test/shields-external-policy-recovery.test.ts b/test/shields-external-policy-recovery.test.ts deleted file mode 100644 index f7e5eefee75..00000000000 --- a/test/shields-external-policy-recovery.test.ts +++ /dev/null @@ -1,474 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import { createRequire } from "node:module"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import YAML from "yaml"; -import { - createShieldsFlowHarness, - externalPolicyAuthorityInspection, - managedMcpPolicy, - managedMcpSandbox, - type ShieldsFlowHarness, -} from "./helpers/shields-flow-harness"; - -const requireSource = createRequire( - path.join(import.meta.dirname, "..", "src", "lib", "shields", "index.js"), -); -let tmpDir: string; -const TEST_PROCESS_START_IDENTITY = "test-process-start-identity"; - -function externalPolicyMutationAuthority(effectivePolicy: Record) { - return { - authority: "externally-managed" as const, - authorityRecordedNow: false, - gatewayName: "nemoclaw", - inspection: { authority: "externally-managed" as const, effectivePolicy }, - }; -} - -function prepareExternalMcpRecoveryFixture() { - const alpha = managedMcpPolicy("alpha"); - const beta = managedMcpPolicy("beta"); - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - livePolicyYaml: YAML.stringify({ - version: 1, - network_policies: { restrictive_baseline: {}, [alpha.key]: alpha.networkPolicy }, - }), - processStartIdentity: TEST_PROCESS_START_IDENTITY, - sandboxEntry: managedMcpSandbox([alpha]), - }); - harness.shieldsDown("openclaw", { throwOnError: true }); - const snapshotPath = String( - harness.getShieldsPosture("openclaw", false).state.shieldsPolicySnapshotPath, - ); - const savedPolicy = YAML.parse(fs.readFileSync(snapshotPath, "utf-8")); - const registry = requireSource( - "../state/registry.js", - ) as typeof import("../src/lib/state/registry.js"); - vi.mocked(registry.getSandbox).mockReturnValue({ - ...managedMcpSandbox([beta]), - policyAuthority: "externally-managed", - }); - return { alpha, beta, harness, savedPolicy, snapshotPath }; -} - -function bindExternalPolicyRecovery( - harness: ShieldsFlowHarness, - effectivePolicy: Record, -): void { - const authority = externalPolicyMutationAuthority(effectivePolicy); - harness.policyAuthoritySpy.mockReturnValue(authority); - harness.policyRecoveryAuthoritySpy.mockReturnValue(authority); - harness.runCaptureSpy.mockReturnValue(YAML.stringify(effectivePolicy)); -} - -function countPolicySets(harness: ShieldsFlowHarness): number { - return harness.runSpy.mock.calls.filter( - ([command]) => Array.isArray(command) && command.includes("policy") && command.includes("set"), - ).length; -} - -function readRestrictivePolicy(harness: ShieldsFlowHarness, sandboxName: string) { - const state = harness.getShieldsPosture(sandboxName, false).state; - return YAML.parse(fs.readFileSync(String(state.shieldsPolicySnapshotPath), "utf-8")) as Record< - string, - unknown - >; -} - -function readExternalRecoveryArtifact(artifactPath: string): { - content: string; - mode: number; -} { - const fileDescriptor = fs.openSync(artifactPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); - try { - return { - content: fs.readFileSync(fileDescriptor, "utf-8"), - mode: fs.fstatSync(fileDescriptor).mode & 0o777, - }; - } finally { - fs.closeSync(fileDescriptor); - } -} - -function mismatchedExternalAuthority() { - return { - authority: "externally-managed", - authorityRecordedNow: false, - gatewayName: "nemoclaw", - inspection: externalPolicyAuthorityInspection, - } as const; -} - -function throwInjectedFailure(message: string): never { - throw new Error(message); -} - -function prepareExternalRecoveryRetirementFixture() { - const sandboxName = "openclaw"; - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - confirmOpenClawInodeFlags: true, - initialOpenClawPosture: "locked", - processStartIdentity: TEST_PROCESS_START_IDENTITY, - }); - harness.shieldsDown(sandboxName, { throwOnError: true }); - harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "must make the effective policy", - ); - const recoveryState = harness.getShieldsPosture(sandboxName, false).state; - const recoveryArtifact = recoveryState.externalPolicyRecoveryArtifact; - const recoveryArtifactPath = String(recoveryArtifact?.path); - const recoveryArtifactContent = readExternalRecoveryArtifact(recoveryArtifactPath).content; - const restoredExternalAuthority = externalPolicyMutationAuthority( - readRestrictivePolicy(harness, sandboxName), - ); - harness.policyAuthoritySpy.mockReturnValue(restoredExternalAuthority); - harness.policyRecoveryAuthoritySpy.mockReturnValue(restoredExternalAuthority); - return { - harness, - recoveryArtifact, - recoveryArtifactContent, - recoveryArtifactPath, - sandboxName, - }; -} - -function injectStateCommitFailure(statePath: string, recoveryArtifactPath: string): void { - const originalRenameSync = fs.renameSync.bind(fs); - let injectedFailure = false; - vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => { - const shouldInject = - !injectedFailure && String(newPath) === statePath && !fs.existsSync(recoveryArtifactPath); - injectedFailure = injectedFailure || shouldInject; - return shouldInject - ? throwInjectedFailure("state commit denied") - : originalRenameSync(oldPath, newPath); - }); -} - -describe("external Shields policy recovery (#9833)", () => { - beforeEach(() => { - tmpDir = fs.mkdtempSync(`${os.tmpdir()}/nemoclaw-external-shields-recovery-`); - vi.stubEnv("HOME", tmpDir); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - fs.rmSync(tmpDir, { recursive: true, force: true }); - delete require.cache[requireSource.resolve("./index.js")]; - delete require.cache[requireSource.resolve("./timer-bound-lock.js")]; - delete require.cache[requireSource.resolve("./transition-lock.js")]; - delete require.cache[requireSource.resolve("./permissive-runtime.js")]; - delete require.cache[requireSource.resolve("../actions/sandbox/mcp-bridge-policy.js")]; - delete require.cache[requireSource.resolve("../cli/branding.js")]; - }); - - it("publishes the complete current MCP policy handoff (#9833)", () => { - const { alpha, beta, harness, savedPolicy, snapshotPath } = prepareExternalMcpRecoveryFixture(); - bindExternalPolicyRecovery(harness, savedPolicy); - - expect(() => - harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { - persistExternalRecoveryArtifact: true, - }), - ).toThrow(/must make the effective policy/iu); - - const requiredPolicy = structuredClone(savedPolicy); - delete requiredPolicy.network_policies[alpha.key]; - requiredPolicy.network_policies[beta.key] = beta.networkPolicy; - const recoveryPolicy = YAML.parse( - fs.readFileSync( - path.join(tmpDir, ".nemoclaw", "state", "shields-external-policy-openclaw.yaml"), - "utf-8", - ), - ); - expect(recoveryPolicy.network_policies).not.toHaveProperty(alpha.key); - expect(recoveryPolicy.network_policies[beta.key]).toEqual(beta.networkPolicy); - bindExternalPolicyRecovery(harness, requiredPolicy); - expect(harness.applyShieldsPolicySnapshot("openclaw", snapshotPath).status).toBe(0); - }); - - it("bounds and escapes control characters in policy-key diagnostics (#9833)", () => { - const unsafeKey = "safe\n\u001b[31m\u0085"; - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - initialOpenClawPosture: "locked", - livePolicyYaml: YAML.stringify({ version: 1, network_policies: { [unsafeKey]: {} } }), - processStartIdentity: TEST_PROCESS_START_IDENTITY, - }); - harness.shieldsDown("openclaw", { throwOnError: true }); - harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - - expect(() => harness.shieldsUp("openclaw", { throwOnError: true })).toThrow( - String.raw`network policy keys: "safe\u000a\u001b[31m\u0085"`, - ); - }); - - it("locks configuration only after external authority restores the exact snapshot (#9833)", () => { - const sandboxName = "openclaw"; - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - confirmOpenClawInodeFlags: true, - initialOpenClawPosture: "locked", - processStartIdentity: TEST_PROCESS_START_IDENTITY, - }); - harness.shieldsDown(sandboxName, { throwOnError: true }); - const policySetsAfterDown = countPolicySets(harness); - harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "must make the effective policy", - ); - expect(countPolicySets(harness)).toBe(policySetsAfterDown); - expect(harness.getOpenClawPosture()).toBe("mutable"); - const recoveryState = harness.getShieldsPosture(sandboxName, false).state; - const recoveryArtifactPath = String(recoveryState.externalPolicyRecoveryArtifact?.path); - const recoveryArtifactBeforeStatus = readExternalRecoveryArtifact(recoveryArtifactPath); - expect(recoveryArtifactBeforeStatus.mode).toBe(0o600); - expect(YAML.parse(recoveryArtifactBeforeStatus.content)).toEqual( - readRestrictivePolicy(harness, sandboxName), - ); - expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain(recoveryArtifactPath); - - vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process exit ${String(code)}`); - }) as typeof process.exit); - expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); - expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( - "must make the effective policy", - ); - expect(readExternalRecoveryArtifact(recoveryArtifactPath).content).toBe( - recoveryArtifactBeforeStatus.content, - ); - - const restoredExternalAuthority = externalPolicyMutationAuthority( - readRestrictivePolicy(harness, sandboxName), - ); - harness.policyAuthoritySpy.mockReturnValue(restoredExternalAuthority); - harness.policyRecoveryAuthoritySpy.mockReturnValue(restoredExternalAuthority); - - expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); - const verifiedUnlockedStatus = harness.errorSpy.mock.calls.flat().join("\n"); - expect(verifiedUnlockedStatus).toContain("to lock configuration and commit Shields UP"); - expect(verifiedUnlockedStatus).not.toContain("Configuration is already locked"); - - harness.shieldsUp(sandboxName, { throwOnError: true }); - - expect(harness.isShieldsDown(sandboxName)).toBe(false); - expect(harness.getOpenClawPosture()).toBe("locked"); - expect(countPolicySets(harness)).toBe(policySetsAfterDown); - expect(fs.existsSync(recoveryArtifactPath)).toBe(false); - expect(harness.getShieldsPosture(sandboxName, false).state).not.toHaveProperty( - "externalPolicyRecoveryArtifact", - ); - }); - - it("removes the external recovery artifact when Shields state is cleared (#9833)", () => { - const sandboxName = "openclaw"; - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - confirmOpenClawInodeFlags: true, - initialOpenClawPosture: "locked", - processStartIdentity: TEST_PROCESS_START_IDENTITY, - }); - harness.shieldsDown(sandboxName, { throwOnError: true }); - harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "must make the effective policy", - ); - const recoveryState = harness.getShieldsPosture(sandboxName, false).state; - const recoveryArtifactPath = String(recoveryState.externalPolicyRecoveryArtifact?.path); - expect(fs.existsSync(recoveryArtifactPath)).toBe(true); - - harness.clearShieldsState(sandboxName); - - expect(fs.existsSync(recoveryArtifactPath)).toBe(false); - expect(harness.getShieldsPosture(sandboxName, false).mode).toBe("mutable_default"); - }); - - it("keeps the external recovery artifact bound when state cleanup cannot remove it (#9833)", () => { - const sandboxName = "openclaw"; - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - confirmOpenClawInodeFlags: true, - initialOpenClawPosture: "locked", - processStartIdentity: TEST_PROCESS_START_IDENTITY, - }); - harness.shieldsDown(sandboxName, { throwOnError: true }); - harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "must make the effective policy", - ); - const recoveryState = harness.getShieldsPosture(sandboxName, false).state; - const recoveryArtifactPath = String(recoveryState.externalPolicyRecoveryArtifact?.path); - const removalError = new Error("permission denied") as NodeJS.ErrnoException; - removalError.code = "EACCES"; - vi.spyOn(fs, "rmSync").mockImplementationOnce((artifactPath) => { - expect(String(artifactPath)).toBe(recoveryArtifactPath); - throw removalError; - }); - - expect(() => harness.clearShieldsState(sandboxName)).toThrow( - `Could not remove external Shields policy recovery artifact '${recoveryArtifactPath}': permission denied`, - ); - - expect(fs.existsSync(recoveryArtifactPath)).toBe(true); - expect( - harness.getShieldsPosture(sandboxName, false).state.externalPolicyRecoveryArtifact?.path, - ).toBe(recoveryArtifactPath); - }); - - it("restores the bound recovery artifact when its removal cannot be made durable (#9833)", () => { - const { - harness, - recoveryArtifact, - recoveryArtifactContent, - recoveryArtifactPath, - sandboxName, - } = prepareExternalRecoveryRetirementFixture(); - const originalRmSync = fs.rmSync.bind(fs); - const originalFsyncSync = fs.fsyncSync.bind(fs); - let failNextDirectoryFsync = false; - let injectedFailure = false; - vi.spyOn(fs, "rmSync").mockImplementation((filePath, options) => { - const shouldInject = String(filePath) === recoveryArtifactPath && !injectedFailure; - originalRmSync(filePath, options); - failNextDirectoryFsync = failNextDirectoryFsync || shouldInject; - injectedFailure = injectedFailure || shouldInject; - }); - vi.spyOn(fs, "fsyncSync").mockImplementation((fileDescriptor) => { - const shouldInject = failNextDirectoryFsync; - failNextDirectoryFsync = false; - return shouldInject - ? throwInjectedFailure("directory sync denied") - : originalFsyncSync(fileDescriptor); - }); - - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "Could not make removal of external Shields policy recovery artifact", - ); - - expect(readExternalRecoveryArtifact(recoveryArtifactPath).content).toBe( - recoveryArtifactContent, - ); - expect( - harness.getShieldsPosture(sandboxName, false).state.externalPolicyRecoveryArtifact, - ).toEqual(recoveryArtifact); - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).not.toThrow(); - expect(fs.existsSync(recoveryArtifactPath)).toBe(false); - }); - - it("restores the bound recovery artifact when the Shields state commit fails (#9833)", () => { - const { - harness, - recoveryArtifact, - recoveryArtifactContent, - recoveryArtifactPath, - sandboxName, - } = prepareExternalRecoveryRetirementFixture(); - const statePath = path.join(tmpDir, ".nemoclaw", "state", `shields-${sandboxName}.json`); - injectStateCommitFailure(statePath, recoveryArtifactPath); - - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "Could not commit Shields state after removing external policy recovery artifact", - ); - - expect(readExternalRecoveryArtifact(recoveryArtifactPath).content).toBe( - recoveryArtifactContent, - ); - expect( - harness.getShieldsPosture(sandboxName, false).state.externalPolicyRecoveryArtifact, - ).toEqual(recoveryArtifact); - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).not.toThrow(); - expect(fs.existsSync(recoveryArtifactPath)).toBe(false); - }); - - it("does not claim to restore an unbound recovery artifact after a state failure (#9833)", () => { - const { harness, recoveryArtifactPath, sandboxName } = - prepareExternalRecoveryRetirementFixture(); - const statePath = path.join(tmpDir, ".nemoclaw", "state", `shields-${sandboxName}.json`); - const state = JSON.parse(fs.readFileSync(statePath, "utf-8")) as Record; - delete state.externalPolicyRecoveryArtifact; - fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); - injectStateCommitFailure(statePath, recoveryArtifactPath); - - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "restored Shields state; no bound artifact was available to restore", - ); - - expect(fs.existsSync(recoveryArtifactPath)).toBe(false); - expect(harness.getShieldsPosture(sandboxName, false).state).not.toHaveProperty( - "externalPolicyRecoveryArtifact", - ); - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).not.toThrow(); - }); - - it("withholds Shields success when external policy changes during config locking (#9833)", () => { - const sandboxName = "openclaw"; - const harness = createShieldsFlowHarness(requireSource, tmpDir, { - confirmOpenClawInodeFlags: true, - initialOpenClawPosture: "locked", - processStartIdentity: TEST_PROCESS_START_IDENTITY, - }); - harness.shieldsDown(sandboxName, { throwOnError: true }); - const policySetsAfterDown = countPolicySets(harness); - const restoredExternalAuthority = externalPolicyMutationAuthority( - readRestrictivePolicy(harness, sandboxName), - ); - const changedExternalAuthority = externalPolicyMutationAuthority({ - version: 1, - network_policies: {}, - }); - harness.policyAuthoritySpy.mockReturnValue(restoredExternalAuthority); - harness.policyRecoveryAuthoritySpy - .mockReturnValueOnce(restoredExternalAuthority) - .mockReturnValueOnce(restoredExternalAuthority) - .mockReturnValue(changedExternalAuthority); - - expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( - "policy verification after config lock failed", - ); - - expect(harness.getOpenClawPosture()).toBe("locked"); - expect(countPolicySets(harness)).toBe(policySetsAfterDown); - const errors = harness.errorSpy.mock.calls.flat().join("\n"); - expect(errors).toContain( - "Config remains locked; Shields remain DOWN until policy verification succeeds.", - ); - expect(errors).not.toContain("Config remains unlocked"); - expect(harness.auditSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "shields_up" }), - ); - const lockedRecovery = harness.getShieldsPosture(sandboxName, false); - const lockedRecoveryArtifactPath = String( - lockedRecovery.state.externalPolicyRecoveryArtifact?.path, - ); - expect(lockedRecovery.mode).toBe("locked_recovery"); - expect(fs.existsSync(lockedRecoveryArtifactPath)).toBe(true); - expect(harness.isShieldsDown(sandboxName)).toBe(false); - vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process exit ${String(code)}`); - }) as typeof process.exit); - expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); - expect(harness.getOpenClawPosture()).toBe("locked"); - harness.policyRecoveryAuthoritySpy.mockReturnValue(restoredExternalAuthority); - expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); - const verifiedLockedStatus = harness.errorSpy.mock.calls.flat().join("\n"); - expect(verifiedLockedStatus).toContain("Configuration is already locked"); - expect(verifiedLockedStatus).toContain(lockedRecoveryArtifactPath); - expect(verifiedLockedStatus).not.toContain("to lock configuration and commit Shields UP"); - harness.shieldsUp(sandboxName, { throwOnError: true }); - expect(harness.isShieldsDown(sandboxName)).toBe(false); - expect(harness.getOpenClawPosture()).toBe("locked"); - expect(fs.existsSync(lockedRecoveryArtifactPath)).toBe(false); - }); -}); diff --git a/test/state/registry.test.ts b/test/state/registry.test.ts index b2d337e53da..36d74a1b2b9 100644 --- a/test/state/registry.test.ts +++ b/test/state/registry.test.ts @@ -174,13 +174,12 @@ describe("registry", () => { // the durable SandboxEntry type; serializeSandboxEntryForDisk strips them. registry.registerSandbox({ name: "alpha", model: "m", provider: "p" }); registry.updateSandbox("alpha", { - policies: ["npm"], recoveredFromGateway: true, livePhase: "Ready", }); const data = JSON.parse(fs.readFileSync(regFile, "utf-8")); - expect(data.sandboxes.alpha.policies).toEqual(["npm"]); + expect(data.sandboxes.alpha.policies).toBeUndefined(); expect(data.sandboxes.alpha.recoveredFromGateway).toBeUndefined(); expect(data.sandboxes.alpha.livePhase).toBeUndefined(); }); @@ -262,34 +261,34 @@ describe("registry", () => { trustedPrivateHost: "mcp.corp.example", allowedIps: ["fd00::40", "10.20.30.40"], }, - ])("rejects $label from durable trusted-private MCP authority (#8267)", ({ - trustedPrivateHost, - allowedIps, - }) => { - registry.registerSandbox({ - name: "noncanonical-private-mcp", - agent: "hermes", - mcp: { - bridges: { - local: { - server: "local", - agent: "hermes", - adapter: "hermes-config", - url: "https://mcp.corp.example/mcp", - env: ["LOCAL_MCP_TOKEN"], - trustedPrivateHost, - allowedIps, - providerName: "noncanonical-private-mcp-mcp-local", - providerId: "11111111-2222-4333-8444-555555555555", - policyName: "mcp-bridge-local", - addedAt: new Date(0).toISOString(), + ])( + "rejects $label from durable trusted-private MCP authority (#8267)", + ({ trustedPrivateHost, allowedIps }) => { + registry.registerSandbox({ + name: "noncanonical-private-mcp", + agent: "hermes", + mcp: { + bridges: { + local: { + server: "local", + agent: "hermes", + adapter: "hermes-config", + url: "https://mcp.corp.example/mcp", + env: ["LOCAL_MCP_TOKEN"], + trustedPrivateHost, + allowedIps, + providerName: "noncanonical-private-mcp-mcp-local", + providerId: "11111111-2222-4333-8444-555555555555", + policyName: "mcp-bridge-local", + addedAt: new Date(0).toISOString(), + }, }, }, - }, - }); + }); - expect(registry.getSandbox("noncanonical-private-mcp").mcp?.bridges?.local).toBeUndefined(); - }); + expect(registry.getSandbox("noncanonical-private-mcp").mcp?.bridges?.local).toBeUndefined(); + }, + ); it("retains sanitized managed MCP names after the active bridge map is emptied", () => { registry.registerSandbox({ @@ -373,7 +372,7 @@ describe("registry", () => { registry.registerSandbox({ name: "up" }); registry.updateSandbox("up", { policies: ["pypi", "npm"], model: "new-model" }); const sb = registry.getSandbox("up"); - expect(sb.policies).toEqual(["pypi", "npm"]); + expect(sb.policies).toBeUndefined(); expect(sb.model).toBe("new-model"); }); @@ -499,16 +498,6 @@ describe("registry", () => { expect(registry.updateSandbox("nope", {})).toBe(false); }); - it("registerSandbox does not inherit a finalized policy marker (#4621)", () => { - // Snapshot restore spreads the source entry (possibly finalized) but resets - // policies; the clone must not carry a stale finalized marker. - registry.registerSandbox({ name: "clone", policies: [], policyPresetsFinalized: true }); - expect(registry.getSandbox("clone").policyPresetsFinalized).toBeUndefined(); - // The marker is set only by the post-policy registry write. - registry.updateSandbox("clone", { policyPresetsFinalized: true }); - expect(registry.getSandbox("clone").policyPresetsFinalized).toBe(true); - }); - it("updateSandbox rejects name changes", () => { registry.registerSandbox({ name: "orig" }); expect(registry.updateSandbox("orig", { name: "renamed" })).toBe(false); @@ -862,10 +851,7 @@ describe("registry", () => { sandboxName: "messaging", channels: [{ channelId: "telegram" }], }); - expect(data.sandboxes.messaging.messaging.plan.networkPolicy).toEqual({ - presets: [], - entries: [], - }); + expect(data.sandboxes.messaging.messaging.plan.networkPolicy).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.agentRender).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.buildSteps).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.runtimeSetup).toBeUndefined(); @@ -1087,52 +1073,6 @@ describe("registry", () => { expect(registry.getDisabledChannels("s1")).toEqual(["telegram"]); }); - it("addCustomPolicy persists name, content, and sourcePath", () => { - registry.registerSandbox({ name: "cp1" }); - const added = registry.addCustomPolicy("cp1", { - name: "my-api", - content: "preset:\n name: my-api\nnetwork_policies: {}\n", - sourcePath: "/tmp/my-api.yaml", - }); - expect(added).toBe(true); - const list = registry.getCustomPolicies("cp1"); - expect(list.length).toBe(1); - expect(list[0].name).toBe("my-api"); - expect(list[0].content).toMatch(/name: my-api/); - expect(list[0].sourcePath).toBe("/tmp/my-api.yaml"); - expect(typeof list[0].appliedAt).toBe("string"); - }); - - it("addCustomPolicy replaces an existing entry with the same name", () => { - registry.registerSandbox({ name: "cp2" }); - registry.addCustomPolicy("cp2", { name: "dup", content: "v1" }); - registry.addCustomPolicy("cp2", { name: "dup", content: "v2" }); - const list = registry.getCustomPolicies("cp2"); - expect(list.length).toBe(1); - expect(list[0].content).toBe("v2"); - }); - - it("removeCustomPolicyByName removes an entry and returns true", () => { - registry.registerSandbox({ name: "cp3" }); - registry.addCustomPolicy("cp3", { name: "a", content: "x" }); - registry.addCustomPolicy("cp3", { name: "b", content: "y" }); - expect(registry.removeCustomPolicyByName("cp3", "a")).toBe(true); - const list = registry.getCustomPolicies("cp3"); - expect(list.length).toBe(1); - expect(list[0].name).toBe("b"); - }); - - it("removeCustomPolicyByName returns false when the entry is missing", () => { - registry.registerSandbox({ name: "cp4" }); - expect(registry.removeCustomPolicyByName("cp4", "nope")).toBe(false); - }); - - it("getCustomPolicies returns [] for unknown or fresh sandboxes", () => { - expect(registry.getCustomPolicies("nonexistent")).toEqual([]); - registry.registerSandbox({ name: "cp5" }); - expect(registry.getCustomPolicies("cp5")).toEqual([]); - }); - describe("extra providers", () => { it("starts with an empty extra-provider list", () => { expect(registry.listExtraProviders()).toEqual([]); diff --git a/test/state/runtime-state-mutation-control.test.ts b/test/state/runtime-state-mutation-control.test.ts index c37025b848c..a21f865e41a 100644 --- a/test/state/runtime-state-mutation-control.test.ts +++ b/test/state/runtime-state-mutation-control.test.ts @@ -92,6 +92,8 @@ describe("runtime state mutation controller", () => { expect(harnessResult.writer_scans_remaining).toBe(0); expect(harnessResult.unstoppable_writer).toBe("writer-exclusion-timeout"); expect(harnessResult.unknown_writer).toBe("unreadable-writer-process"); + expect(harnessResult.pid_reuse_signal).toBe("ok"); + expect(harnessResult.pid_reuse_signal_calls).toEqual([]); }); it("publishes, rolls an activated fence back, and recovers every durable phase (#7744)", () => { diff --git a/test/state/snapshot-backup-audit-hardlinks.test.ts b/test/state/snapshot-backup-audit-hardlinks.test.ts index 1891d4f2e02..b43ca9ca2db 100644 --- a/test/state/snapshot-backup-audit-hardlinks.test.ts +++ b/test/state/snapshot-backup-audit-hardlinks.test.ts @@ -59,7 +59,6 @@ function writeRegistry(sandboxName: string): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: null, }, }, diff --git a/test/state/snapshot-gateway-guard.test.ts b/test/state/snapshot-gateway-guard.test.ts index ea10e2c080a..06446ec734a 100644 --- a/test/state/snapshot-gateway-guard.test.ts +++ b/test/state/snapshot-gateway-guard.test.ts @@ -61,7 +61,16 @@ function runCli(args: string, env: Record = {}): Cli * exit 0 with stale data, so the old isLive.status guard never fires. */ function writeExecutable(filePath: string, lines: string[]): void { - fs.writeFileSync(filePath, ["#!/bin/sh", ...lines].join("\n"), { mode: 0o755 }); + const policyGet = + path.basename(filePath) === "openshell" + ? [ + 'if [ "$1 $2" = "policy get" ]; then', + " printf 'version: 1\\nnetwork_policies: {}\\n'", + " exit 0", + "fi", + ] + : []; + fs.writeFileSync(filePath, ["#!/bin/sh", ...policyGet, ...lines].join("\n"), { mode: 0o755 }); } function writeSandboxRegistry( @@ -80,7 +89,6 @@ function writeSandboxRegistry( model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], ...entry, }, }, @@ -114,8 +122,6 @@ function writeEmptyOpenClawSnapshot(home: string, name: string): void { dir: "/sandbox/.openclaw", backupPath, blueprintDigest: null, - policyPresets: [], - customPolicies: [], name, }), { mode: 0o600 }, @@ -173,6 +179,7 @@ function makeStoppedGatewayEnv(prefix: string): Record { return { HOME: home, + NEMOCLAW_OPENSHELL_BIN: path.join(localBin, "openshell"), PATH: `${localBin}:${process.env.PATH ?? ""}`, }; } @@ -204,6 +211,7 @@ function makeHealthyVmGatewayEnv(prefix: string): Record { return { HOME: home, + NEMOCLAW_OPENSHELL_BIN: path.join(localBin, "openshell"), PATH: `${localBin}:${process.env.PATH ?? ""}`, }; } @@ -270,7 +278,7 @@ function makeVmRestoreToEnv( ' if printf "%s" "$cmd" | grep -q "cat --"; then cat "$REMOTE_OPENCLAW_JSON"; exit 0; fi', ' touch "$SNAPSHOT_RESTORE_MARKER"', ' if printf "%s" "$cmd" | grep -q ".nemoclaw-restore"; then cat > "$REMOTE_OPENCLAW_JSON"; exit 0; fi', - ' exit 92', + " exit 92", "fi", "exit 0", ]); @@ -318,6 +326,7 @@ function makeVmRestoreToEnv( return { HOME: home, + NEMOCLAW_OPENSHELL_BIN: path.join(localBin, "openshell"), NEMOCLAW_GATEWAY_RECOVERY_SETTLE_SECONDS: "0", NEMOCLAW_TEST_SNAPSHOT_RESTORE_MARKER: snapshotRestoreMarker, PATH: `${localBin}:${process.env.PATH ?? ""}`, @@ -377,9 +386,7 @@ describe("snapshot VM-driver gateway guard", () => { .update("fixture-clone-1") .digest("hex"), }); - expect(registryState.sandboxes["clone-1"].lifecycleGeneration).not.toBe( - "source-generation", - ); + expect(registryState.sandboxes["clone-1"].lifecycleGeneration).not.toBe("source-generation"); }, 15000); it("snapshot restore --to rejects a malformed clone identity before registration (#8942)", () => { diff --git a/test/state/snapshot-managed-restore-authority.test.ts b/test/state/snapshot-managed-restore-authority.test.ts index 0a099dcf0d4..d9805a5c442 100644 --- a/test/state/snapshot-managed-restore-authority.test.ts +++ b/test/state/snapshot-managed-restore-authority.test.ts @@ -98,7 +98,6 @@ function writeOpenClawRegistry(): void { model: "demo", provider: "compatible-endpoint", gpuEnabled: false, - policies: [], agent: "openclaw", }, }, diff --git a/test/state/snapshot-openclaw-managed-extensions.test.ts b/test/state/snapshot-openclaw-managed-extensions.test.ts index e734e7c62e3..8d7cc66b4e5 100644 --- a/test/state/snapshot-openclaw-managed-extensions.test.ts +++ b/test/state/snapshot-openclaw-managed-extensions.test.ts @@ -75,7 +75,6 @@ function writeOpenClawRegistry(sandboxName: string): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: null, }, }, @@ -118,77 +117,77 @@ describe("OpenClaw managed extension snapshot restore", () => { pluginTransitions.map((transition) => ({ installIndexSource, ...transition })), ); - it.each( - installIndexCases, - )("preserves fresh extensions and handles image-plugin $name from the $installIndexSource install index", ({ - installIndexSource, - previousPlugin, - freshPlugin, - }) => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-extension-restore-")); - const oldPath = process.env.PATH; - const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; - try { - const binDir = path.join(fixture, "bin"); - const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); - const freshRegistryPath = path.join(fixture, "fresh-installs.json"); - const sshLog = path.join(fixture, "ssh-log.jsonl"); - const extensionsDir = path.join(openclawDir, "extensions"); - const builtInManagedExtensions = - "nemoclaw,diagnostics-otel,brave,discord,openclaw-weixin,slack,whatsapp,msteams".split(","); - const freshImagePlugins = freshPlugin ? [freshPlugin] : []; - const managedExtensions = [...builtInManagedExtensions, ...freshImagePlugins]; - fs.mkdirSync(binDir, { recursive: true }); - for (const extensionName of managedExtensions) { - const extensionDir = path.join(extensionsDir, extensionName); - fs.mkdirSync(extensionDir, { recursive: true }); - const marker = `fresh-${extensionName}\n`; - fs.writeFileSync(path.join(extensionDir, "marker.txt"), marker); - } - fs.mkdirSync(path.join(extensionsDir, "stale-user-extension"), { recursive: true }); - fs.writeFileSync(path.join(extensionsDir, "stale-user-extension", "marker.txt"), "stale\n"); - fs.writeFileSync( - freshRegistryPath, - JSON.stringify({ - version: 1, - loadPaths: [], - installRecords: Object.fromEntries( - freshImagePlugins.map((id) => [ - id, - { - source: "path", - sourcePath: `/sandbox/.openclaw/extensions/${id}`, - installPath: `/sandbox/.openclaw/extensions/${id}`, - }, - ]), - ), - }), + it.each(installIndexCases)( + "preserves fresh extensions and handles image-plugin $name from the $installIndexSource install index", + ({ installIndexSource, previousPlugin, freshPlugin }) => { + const fixture = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-openclaw-extension-restore-"), ); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); + const freshRegistryPath = path.join(fixture, "fresh-installs.json"); + const sshLog = path.join(fixture, "ssh-log.jsonl"); + const extensionsDir = path.join(openclawDir, "extensions"); + const builtInManagedExtensions = + "nemoclaw,diagnostics-otel,brave,discord,openclaw-weixin,slack,whatsapp,msteams".split( + ",", + ); + const freshImagePlugins = freshPlugin ? [freshPlugin] : []; + const managedExtensions = [...builtInManagedExtensions, ...freshImagePlugins]; + fs.mkdirSync(binDir, { recursive: true }); + for (const extensionName of managedExtensions) { + const extensionDir = path.join(extensionsDir, extensionName); + fs.mkdirSync(extensionDir, { recursive: true }); + const marker = `fresh-${extensionName}\n`; + fs.writeFileSync(path.join(extensionDir, "marker.txt"), marker); + } + fs.mkdirSync(path.join(extensionsDir, "stale-user-extension"), { recursive: true }); + fs.writeFileSync(path.join(extensionsDir, "stale-user-extension", "marker.txt"), "stale\n"); + fs.writeFileSync( + freshRegistryPath, + JSON.stringify({ + version: 1, + loadPaths: [], + installRecords: Object.fromEntries( + freshImagePlugins.map((id) => [ + id, + { + source: "path", + sourcePath: `/sandbox/.openclaw/extensions/${id}`, + installPath: `/sandbox/.openclaw/extensions/${id}`, + }, + ]), + ), + }), + ); - const manifest = writeBackup("alpha", "2026-05-19T12-00-00-000Z", [ - { - id: previousPlugin, - installPath: `/sandbox/.openclaw/extensions/${previousPlugin}`, - loadPaths: [], - }, - ]); - const backupExtensionsDir = path.join(manifest.backupPath, "extensions"); - for (const extensionName of [...builtInManagedExtensions, previousPlugin]) { - const extensionDir = path.join(backupExtensionsDir, extensionName); - fs.mkdirSync(extensionDir, { recursive: true }); - const marker = `old-${extensionName}\n`; - fs.writeFileSync(path.join(extensionDir, "marker.txt"), marker); - } - fs.mkdirSync(path.join(backupExtensionsDir, "user-extension"), { recursive: true }); - fs.writeFileSync( - path.join(backupExtensionsDir, "user-extension", "marker.txt"), - "restored\n", - ); + const manifest = writeBackup("alpha", "2026-05-19T12-00-00-000Z", [ + { + id: previousPlugin, + installPath: `/sandbox/.openclaw/extensions/${previousPlugin}`, + loadPaths: [], + }, + ]); + const backupExtensionsDir = path.join(manifest.backupPath, "extensions"); + for (const extensionName of [...builtInManagedExtensions, previousPlugin]) { + const extensionDir = path.join(backupExtensionsDir, extensionName); + fs.mkdirSync(extensionDir, { recursive: true }); + const marker = `old-${extensionName}\n`; + fs.writeFileSync(path.join(extensionDir, "marker.txt"), marker); + } + fs.mkdirSync(path.join(backupExtensionsDir, "user-extension"), { recursive: true }); + fs.writeFileSync( + path.join(backupExtensionsDir, "user-extension", "marker.txt"), + "restored\n", + ); - const openshell = writeFakeOpenshell(binDir); - writeExecutable( - path.join(binDir, "ssh"), - `#!/usr/bin/env node + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node const fs = require("node:fs"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); @@ -235,89 +234,90 @@ if (cmd.includes("tar --no-same-owner -xf -")) { if (cmd.includes("chown") || cmd.includes("[ -d ")) process.exit(0); process.exit(0); `, - ); + ); - writeOpenClawRegistry("alpha"); - process.env.NEMOCLAW_OPENSHELL_BIN = openshell; - process.env.PATH = `${binDir}:${oldPath || ""}`; + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}:${oldPath || ""}`; - const restore = sandboxState.restoreRecreatedSandboxState("alpha", manifest.backupPath, { - targetAgentType: "openclaw", - }); - expect(restore.success).toBe(true); - expect(restore.restoredDirs).toEqual(["extensions"]); - for (const extensionName of managedExtensions) { + const restore = sandboxState.restoreRecreatedSandboxState("alpha", manifest.backupPath, { + targetAgentType: "openclaw", + }); + expect(restore.success).toBe(true); + expect(restore.restoredDirs).toEqual(["extensions"]); + for (const extensionName of managedExtensions) { + expect( + fs.readFileSync(path.join(extensionsDir, extensionName, "marker.txt"), "utf-8"), + ).toBe(`fresh-${extensionName}\n`); + } + expect(fs.existsSync(path.join(extensionsDir, previousPlugin))).toBe( + previousPlugin === freshPlugin, + ); + expect(fs.existsSync(path.join(extensionsDir, "stale-user-extension"))).toBe(false); expect( - fs.readFileSync(path.join(extensionsDir, extensionName, "marker.txt"), "utf-8"), - ).toBe(`fresh-${extensionName}\n`); - } - expect(fs.existsSync(path.join(extensionsDir, previousPlugin))).toBe( - previousPlugin === freshPlugin, - ); - expect(fs.existsSync(path.join(extensionsDir, "stale-user-extension"))).toBe(false); - expect( - fs.readFileSync(path.join(extensionsDir, "user-extension", "marker.txt"), "utf-8"), - ).toBe("restored\n"); + fs.readFileSync(path.join(extensionsDir, "user-extension", "marker.txt"), "utf-8"), + ).toBe("restored\n"); - const loggedCommands = fs - .readFileSync(sshLog, "utf-8") - .trim() - .split("\n") - .map((line) => JSON.parse(line).cmd as string); - const cleanupCommands = loggedCommands.filter( - (cmd) => cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf"), - ); - expect(cleanupCommands).toHaveLength(1); - expect(loggedCommands.some((cmd) => cmd.includes("installed_plugin_index"))).toBe(true); - expect(loggedCommands.some((cmd) => cmd.includes("plugins/installs.json"))).toBe( - installIndexSource === "legacy", - ); - const cleanupCommand = cleanupCommands[0]; - expect(cleanupCommand).not.toContain("rm -rf -- /sandbox/.openclaw/extensions"); - for (const extensionName of managedExtensions) { - expect(cleanupCommand).toContain(`! -name '${extensionName}'`); - } + const loggedCommands = fs + .readFileSync(sshLog, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line).cmd as string); + const cleanupCommands = loggedCommands.filter( + (cmd) => cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf"), + ); + expect(cleanupCommands).toHaveLength(1); + expect(loggedCommands.some((cmd) => cmd.includes("installed_plugin_index"))).toBe(true); + expect(loggedCommands.some((cmd) => cmd.includes("plugins/installs.json"))).toBe( + installIndexSource === "legacy", + ); + const cleanupCommand = cleanupCommands[0]; + expect(cleanupCommand).not.toContain("rm -rf -- /sandbox/.openclaw/extensions"); + for (const extensionName of managedExtensions) { + expect(cleanupCommand).toContain(`! -name '${extensionName}'`); + } - fs.writeFileSync( - freshRegistryPath, - JSON.stringify({ - version: 1, - loadPaths: [], - installRecords: { - "\u001b[31m../weather": { - source: "path", - sourcePath: "/sandbox/.openclaw/extensions/../weather", - installPath: "/sandbox/.openclaw/extensions/../weather", + fs.writeFileSync( + freshRegistryPath, + JSON.stringify({ + version: 1, + loadPaths: [], + installRecords: { + "\u001b[31m../weather": { + source: "path", + sourcePath: "/sandbox/.openclaw/extensions/../weather", + installPath: "/sandbox/.openclaw/extensions/../weather", + }, }, - }, - }), - ); - const rejected = sandboxState.restoreRecreatedSandboxState("alpha", manifest.backupPath, { - targetAgentType: "openclaw", - }); - expect(rejected.success).toBe(false); - expect(rejected.error).toBe("fresh OpenClaw plugin install registry failed validation"); - expect(fs.existsSync(path.join(extensionsDir, previousPlugin))).toBe( - previousPlugin === freshPlugin, - ); - for (const extensionName of managedExtensions) { + }), + ); + const rejected = sandboxState.restoreRecreatedSandboxState("alpha", manifest.backupPath, { + targetAgentType: "openclaw", + }); + expect(rejected.success).toBe(false); + expect(rejected.error).toBe("fresh OpenClaw plugin install registry failed validation"); + expect(fs.existsSync(path.join(extensionsDir, previousPlugin))).toBe( + previousPlugin === freshPlugin, + ); + for (const extensionName of managedExtensions) { + expect( + fs.readFileSync(path.join(extensionsDir, extensionName, "marker.txt"), "utf-8"), + ).toBe(`fresh-${extensionName}\n`); + } + const commandsAfterRejectedRestore = fs + .readFileSync(sshLog, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line).cmd as string); expect( - fs.readFileSync(path.join(extensionsDir, extensionName, "marker.txt"), "utf-8"), - ).toBe(`fresh-${extensionName}\n`); + commandsAfterRejectedRestore.filter( + (cmd) => cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf"), + ), + ).toHaveLength(1); + } finally { + restoreEnvBulk({ NEMOCLAW_OPENSHELL_BIN: oldOpenshell, PATH: oldPath }); + fs.rmSync(fixture, { recursive: true, force: true }); } - const commandsAfterRejectedRestore = fs - .readFileSync(sshLog, "utf-8") - .trim() - .split("\n") - .map((line) => JSON.parse(line).cmd as string); - expect( - commandsAfterRejectedRestore.filter( - (cmd) => cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf"), - ), - ).toHaveLength(1); - } finally { - restoreEnvBulk({ NEMOCLAW_OPENSHELL_BIN: oldOpenshell, PATH: oldPath }); - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); + }, + ); }); diff --git a/test/state/snapshot-restore-existing-dest.test.ts b/test/state/snapshot-restore-existing-dest.test.ts index aaed0bf0165..350792fe032 100644 --- a/test/state/snapshot-restore-existing-dest.test.ts +++ b/test/state/snapshot-restore-existing-dest.test.ts @@ -110,14 +110,12 @@ function makeExistingDestEnv( model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, dst: { name: "dst", model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], ...(destinationGatewayName ? { gatewayName: destinationGatewayName, @@ -163,6 +161,10 @@ function makeExistingDestEnv( `printf '%s\\n' "$*" >> ${JSON.stringify(osLog)}`, `ACTIVE_GATEWAY=${JSON.stringify(activeGateway)}`, `DELETED_DESTINATION=${JSON.stringify(deletedDestination)}`, + 'if [ "$1 $2" = "policy get" ]; then', + " printf 'version: 1\\nnetwork_policies: {}\\n'", + " exit 0", + "fi", 'if [ "$1" = "gateway" ] && [ "$2" = "select" ]; then', destinationGatewayName && opts.destinationGatewaySelectSucceeds === false ? ` if [ "$3" = ${JSON.stringify(destinationGatewayName)} ]; then echo "select failed" >&2; exit 17; fi` @@ -224,7 +226,14 @@ function makeExistingDestEnv( { mode: 0o755 }, ); - return { env: { HOME: home, PATH: `${localBin}:${process.env.PATH ?? ""}` }, osLog }; + return { + env: { + HOME: home, + NEMOCLAW_OPENSHELL_BIN: path.join(localBin, "openshell"), + PATH: `${localBin}:${process.env.PATH ?? ""}`, + }, + osLog, + }; } describe("snapshot restore --to existing destination (#3756)", () => { diff --git a/test/state/snapshot-runtime-auth-state.test.ts b/test/state/snapshot-runtime-auth-state.test.ts index f7f8860239d..ffb11223d7d 100644 --- a/test/state/snapshot-runtime-auth-state.test.ts +++ b/test/state/snapshot-runtime-auth-state.test.ts @@ -155,7 +155,6 @@ function writeOpenClawRegistry(sandboxName: string): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent: null, }, }, diff --git a/test/state/snapshot-shields-guard.test.ts b/test/state/snapshot-shields-guard.test.ts index febcea80a2d..5dc795d952d 100644 --- a/test/state/snapshot-shields-guard.test.ts +++ b/test/state/snapshot-shields-guard.test.ts @@ -58,7 +58,6 @@ function writeSandboxRegistry(home: string, sandboxName: string): void { model: "test-model", provider: "nvidia-prod", gpuEnabled: false, - policies: [], }, }, defaultSandbox: sandboxName, diff --git a/test/state/snapshot-stale-directory-restore.test.ts b/test/state/snapshot-stale-directory-restore.test.ts index b343ac8d4ae..de207d500b3 100644 --- a/test/state/snapshot-stale-directory-restore.test.ts +++ b/test/state/snapshot-stale-directory-restore.test.ts @@ -43,7 +43,6 @@ function writeSandboxRegistry(sandboxName: string, agent: string | null = null): model: "m", provider: "p", gpuEnabled: false, - policies: [], agent, }, }, diff --git a/test/state/snapshot-state-directory-contract.test.ts b/test/state/snapshot-state-directory-contract.test.ts index 044e972a176..7d154402e8b 100644 --- a/test/state/snapshot-state-directory-contract.test.ts +++ b/test/state/snapshot-state-directory-contract.test.ts @@ -58,7 +58,6 @@ function writeAgentRegistry(sandboxName: string, agent: string): void { model: "m", provider: "p", gpuEnabled: false, - policies: [], agent, }, }, @@ -130,34 +129,39 @@ describe("snapshot state-directory authorization", () => { "Backup state directories are not declared by target agent 'openclaw': workspace-research/nested", }, ], - ])("authorizes only a top-level concrete match for a dynamic state prefix: %s (#8006)", (stateDir, expected) => { - const manifest = writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { - stateDirs: [stateDir], - backedUpDirs: [], - failedBackupDirs: [stateDir], - }); - writeAgentRegistry("test-sandbox", "openclaw"); - - const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); - - expect(restore).toMatchObject(expected); - }); + ])( + "authorizes only a top-level concrete match for a dynamic state prefix: %s (#8006)", + (stateDir, expected) => { + const manifest = writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { + stateDirs: [stateDir], + backedUpDirs: [], + failedBackupDirs: [stateDir], + }); + writeAgentRegistry("test-sandbox", "openclaw"); + + const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); + + expect(restore).toMatchObject(expected); + }, + ); it.each([ ["hermes", "hermes"], ["deepagents", "langchain-deepagents-code"], - ])("keeps optional exact-directory discovery successful for %s when no state directory exists (#8006)", (sandboxName, agentName) => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exact-dir-discovery-")); - const oldPath = process.env.PATH; - const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; - try { - const binDir = path.join(fixture, "bin"); - const discoveryLog = path.join(fixture, "discovery.json"); - fs.mkdirSync(binDir, { recursive: true }); - const openshell = writeFakeOpenshell(binDir); - writeExecutable( - path.join(binDir, "ssh"), - `#!/usr/bin/env node + ])( + "keeps optional exact-directory discovery successful for %s when no state directory exists (#8006)", + (sandboxName, agentName) => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exact-dir-discovery-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const discoveryLog = path.join(fixture, "discovery.json"); + fs.mkdirSync(binDir, { recursive: true }); + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node const fs = require("node:fs"); const { spawnSync } = require("node:child_process"); const cmd = process.argv[process.argv.length - 1] || ""; @@ -169,23 +173,24 @@ if (cmd.startsWith("{ ")) { } process.exit(1); `, - ); - writeAgentRegistry(sandboxName, agentName); - process.env.NEMOCLAW_OPENSHELL_BIN = openshell; - process.env.PATH = `${binDir}:${oldPath || ""}`; - - sandboxState.backupSandboxState(sandboxName); - - const discovery = JSON.parse(fs.readFileSync(discoveryLog, "utf8")) as { - cmd: string; - status: number; - }; - expect(discovery.status).toBe(0); - expect(discovery.cmd).toMatch(/; :; } 2>\/dev\/null$/); - } finally { - restoreEnv("PATH", oldPath); - restoreEnv("NEMOCLAW_OPENSHELL_BIN", oldOpenshell); - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); + ); + writeAgentRegistry(sandboxName, agentName); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}:${oldPath || ""}`; + + sandboxState.backupSandboxState(sandboxName); + + const discovery = JSON.parse(fs.readFileSync(discoveryLog, "utf8")) as { + cmd: string; + status: number; + }; + expect(discovery.status).toBe(0); + expect(discovery.cmd).toMatch(/; :; } 2>\/dev\/null$/); + } finally { + restoreEnv("PATH", oldPath); + restoreEnv("NEMOCLAW_OPENSHELL_BIN", oldOpenshell); + fs.rmSync(fixture, { recursive: true, force: true }); + } + }, + ); }); diff --git a/test/state/snapshot.test.ts b/test/state/snapshot.test.ts index 8e71b905599..d9b93f0273e 100644 --- a/test/state/snapshot.test.ts +++ b/test/state/snapshot.test.ts @@ -142,7 +142,6 @@ function writeAgentRegistry( model: "m", provider: "p", gpuEnabled: false, - policies: [], agent, ...overrides, }, @@ -250,19 +249,6 @@ describe("listBackups computes virtual versions", () => { ]); }); - it("surfaces customPolicies (name + content + sourcePath) through the manifest round-trip", () => { - const custom = [ - { - name: "my-custom", - content: "version: 1\n\nnetwork_policies: {}\n", - sourcePath: "/host/policy.yaml", - }, - ]; - writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { customPolicies: custom }); - const [entry] = sandboxState.listBackups("test-sandbox"); - expect(entry.customPolicies).toEqual(custom); - }); - it("round-trips normalized managed workload and provider runtime authority", () => { const authority = managedSnapshotAuthority(); writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { @@ -295,33 +281,6 @@ describe("listBackups computes virtual versions", () => { expect(sandboxState.listBackups("test-sandbox")).toEqual([]); }); - it("preserves an empty customPolicies array so restore can distinguish zero-custom from legacy snapshots", () => { - writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { customPolicies: [] }); - const [entry] = sandboxState.listBackups("test-sandbox"); - expect(entry.customPolicies).toEqual([]); - }); - - it("ignores rebuild manifests with malformed customPolicies (entry missing content)", () => { - const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T14-02-00-000Z"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, "rebuild-manifest.json"), - JSON.stringify({ - version: 1, - sandboxName: "test-sandbox", - timestamp: "2026-04-21T14-02-00-000Z", - agentType: "openclaw", - agentVersion: null, - expectedVersion: null, - stateDirs: [], - dir: "/sandbox/.openclaw", - backupPath: dir, - blueprintDigest: null, - customPolicies: [{ name: "no-content" }], - }), - ); - expect(sandboxState.listBackups("test-sandbox")).toEqual([]); - }); it("preserves legacy manifests created before blueprintDigest existed", () => { const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T13-59-00-000Z"); fs.mkdirSync(dir, { recursive: true }); @@ -357,11 +316,10 @@ describe("listBackups computes virtual versions", () => { agentType: "openclaw", agentVersion: null, expectedVersion: null, - stateDirs: [], + stateDirs: "invalid", writableDir: "/sandbox/.openclaw-data", backupPath: dir, blueprintDigest: null, - policyPresets: [1], }), ); expect(sandboxState.listBackups("test-sandbox")).toEqual([]); @@ -1076,30 +1034,29 @@ process.exit(0); } }); - it.each([ - "weather", - "slack", - ])("rejects a generic %s OpenClaw peer link with a tampered target", (extensionName) => { - // The generic peer path is valid, but its target must remain the exact - // global OpenClaw install rather than an arbitrary absolute path. - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-target-tampered-")); - const oldPath = process.env.PATH; - const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; - try { - const binDir = path.join(fixture, "bin"); - const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); - const existingDirs = ["extensions"]; - fs.mkdirSync(binDir, { recursive: true }); - for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); - - const auditOutput = encodePreBackupAuditRows([ - `l\t/sandbox/.openclaw/extensions/${extensionName}/node_modules/openclaw\t/etc/passwd`, - ]); - - const openshell = writeFakeOpenshell(binDir); - writeExecutable( - path.join(binDir, "ssh"), - `#!/usr/bin/env node + it.each(["weather", "slack"])( + "rejects a generic %s OpenClaw peer link with a tampered target", + (extensionName) => { + // The generic peer path is valid, but its target must remain the exact + // global OpenClaw install rather than an arbitrary absolute path. + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-target-tampered-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); + const existingDirs = ["extensions"]; + fs.mkdirSync(binDir, { recursive: true }); + for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); + + const auditOutput = encodePreBackupAuditRows([ + `l\t/sandbox/.openclaw/extensions/${extensionName}/node_modules/openclaw\t/etc/passwd`, + ]); + + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node const cmd = process.argv[process.argv.length - 1] || ""; const existingDirs = ${JSON.stringify(existingDirs)}; if (cmd.includes("[ -d ")) { @@ -1112,26 +1069,27 @@ if (cmd.includes("find ")) { } process.exit(0); `, - ); - - writeOpenClawRegistry("alpha"); - process.env.NEMOCLAW_OPENSHELL_BIN = openshell; - process.env.PATH = `${binDir}:${oldPath || ""}`; + ); - const backup = sandboxState.backupSandboxState("alpha"); - expect(backup.success).toBe(false); - expect(backup.error).toContain(`extensions/${extensionName}`); - expect(backup.error).toMatch(/\/etc\/passwd/); - } finally { - if (oldOpenshell === undefined) { - delete process.env.NEMOCLAW_OPENSHELL_BIN; - } else { - process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(false); + expect(backup.error).toContain(`extensions/${extensionName}`); + expect(backup.error).toMatch(/\/etc\/passwd/); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); } - process.env.PATH = oldPath; - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); + }, + ); it("marks non-attributed directories failed when they are missing from partial extraction", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-missing-partial-")); diff --git a/test/state/state-dir-guard-verification.test.ts b/test/state/state-dir-guard-verification.test.ts index e6776ac05f8..147a3aca6c9 100644 --- a/test/state/state-dir-guard-verification.test.ts +++ b/test/state/state-dir-guard-verification.test.ts @@ -124,6 +124,9 @@ wrong_owner = module.Identity( ) print(json.dumps({ "devices-unlock": verify("/sandbox/.openclaw", "devices", "unlock"), + "identity-unlock": verify( + "/sandbox/.openclaw", "identity", "unlock", root_mode=0o700 + ), "devices-private-directory": verify( "/sandbox/.openclaw", "devices", "unlock", root_mode=0o700 ), @@ -161,7 +164,7 @@ describe("state directory guard verification", () => { expect(modes["0750"]).toBeNull(); }); - it("accepts native OpenClaw devices modes only while restoring mutable state (#8112)", () => { + it("accepts native OpenClaw private runtime modes only while restoring mutable state (#8112)", () => { const result = spawnSync( "python3", ["-I", "-c", VERIFY_OPENCLAW_NATIVE_MUTABLE_MODES, GUARD_PATH], @@ -171,6 +174,7 @@ describe("state directory guard verification", () => { expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0); const outcomes = JSON.parse(result.stdout) as Record | null>; expect(outcomes["devices-unlock"]).toEqual([]); + expect(outcomes["identity-unlock"]).toEqual([]); expect(outcomes["devices-private-directory"]).toEqual([]); expect(outcomes["devices-nested-private-directory"]).toEqual([]); expect(outcomes["devices-normal-directory"]).toBeNull(); diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index 5d538089f42..f6c20cacf65 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -473,7 +473,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne ? portableDisposition.liveIdentityFingerprint : undefined, gpuEnabled: false, - policies: [], ...(portableDisposition.kind === "hermes" ? { openshellDriver: "docker", @@ -493,7 +492,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne provider: null, model: null, gpuEnabled: false, - policies: [], ...candidate, }, ) diff --git a/test/support/setup-inference-test-harness.ts b/test/support/setup-inference-test-harness.ts index 2510d4a1eef..d475611ab60 100644 --- a/test/support/setup-inference-test-harness.ts +++ b/test/support/setup-inference-test-harness.ts @@ -384,7 +384,7 @@ export function createDirectSetupInferenceHarnessFactory( withOllamaModelOwnershipLock: (operation) => operation(), ...options.overrides, }); - const revalidatePolicyRequirements = vi.fn(); + const verifyLivePolicyRequirements = vi.fn(); const setupInference: SetupInference = ( sandboxName, model, @@ -405,8 +405,8 @@ export function createDirectSetupInferenceHarnessFactory( hermesToolGateways, { ...inferenceOptions, - revalidatePolicyRequirements: - inferenceOptions.revalidatePolicyRequirements ?? revalidatePolicyRequirements, + verifyLivePolicyRequirements: + inferenceOptions.verifyLivePolicyRequirements ?? verifyLivePolicyRequirements, }, ); return { @@ -415,7 +415,7 @@ export function createDirectSetupInferenceHarnessFactory( logs, runOpenshell, setupInference, - revalidatePolicyRequirements, + verifyLivePolicyRequirements, unloadOllamaModels, updateSandbox, verifyInferenceRoute, diff --git a/test/support/status-flow-test-harness.ts b/test/support/status-flow-test-harness.ts index ddea93db1d4..36051427be8 100644 --- a/test/support/status-flow-test-harness.ts +++ b/test/support/status-flow-test-harness.ts @@ -12,8 +12,7 @@ import type { ServingProcessHealth, } from "../../src/lib/actions/sandbox/status-snapshot"; import type { ProviderHealthStatus } from "../../src/lib/inference/health"; -import type { BaselineExclusionRuntimeStatus } from "../../src/lib/policy/baseline-exclusion"; -import type { BaselineExclusionTransition, SandboxHostMount } from "../../src/lib/state/registry"; +import type { SandboxHostMount } from "../../src/lib/state/registry"; type ShowSandboxStatus = (typeof import("../../src/lib/actions/sandbox/status"))["showSandboxStatus"]; @@ -51,7 +50,6 @@ const baseSandboxEntry = { name: "alpha", model: "nvidia/nemotron", provider: "ollama-local", - policies: ["npm", "telegram"], hostGpuDetected: true, gpuEnabled: true, sandboxGpuEnabled: true, @@ -74,6 +72,7 @@ const baseSandboxEntry = { export type StatusFlowHarnessOptions = { currentModel?: string; currentProvider?: string; + gatewayPresets?: string[] | null; routeDrift?: SandboxStatusRouteDrift | null; inferenceHealth?: ProviderHealthStatus | null; servingProcessHealth?: ServingProcessHealth | null; @@ -83,7 +82,6 @@ export type StatusFlowHarnessOptions = { | (() => PortableAgentReceiptDisposition | Error); registryEntry?: "present" | "missing"; withMcpLifecycleLock?: WithMcpLifecycleLock; - baselineExclusionStatus?: BaselineExclusionRuntimeStatus; lookup?: SandboxGatewayState; lookupState?: "present" | "missing"; gatewayRunning?: boolean; @@ -95,8 +93,6 @@ export type StatusFlowHarnessOptions = { agent?: string | null; agentVersion?: string | null; dcodeAutoApprovalMode?: "disabled" | "thread-opt-in"; - baselineExclusions?: Array<{ version: 1; agent: string; key: string; digest: string }>; - baselineExclusionTransition?: BaselineExclusionTransition; preferredInferenceApi?: string | null; compatibleEndpointReasoningEffort?: "low" | "medium" | "high" | null; hostMounts?: SandboxHostMount[]; @@ -275,8 +271,8 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): container: null, }); vi.spyOn(nim, "shouldShowNimLine").mockReturnValue(true); - vi.spyOn(policy, "getBaselineExclusionRuntimeStatus").mockReturnValue( - options.baselineExclusionStatus ?? "excluded", + vi.spyOn(policy, "getGatewayPresets").mockReturnValue( + options.gatewayPresets === undefined ? ["npm", "telegram"] : options.gatewayPresets, ); const checkAgentVersionSpy = vi.spyOn(sandboxVersion, "checkAgentVersion").mockReturnValue( options.versionCheck ?? { diff --git a/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle b/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle index c90e637d306..2c23cc955b4 100644 --- a/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle +++ b/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle @@ -1,4 +1,4 @@ -var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var image_runtime_exports={};__export(image_runtime_exports,{applyManagedBootstrapEnvelope:()=>applyManagedBootstrapEnvelope,main:()=>main2,managedBootstrapEnvelopeClaimPaths:()=>managedBootstrapEnvelopeClaimPaths,readManagedBootstrapEnvelope:()=>readManagedBootstrapEnvelope,recoverManagedBootstrapEnvelopeClaim:()=>recoverManagedBootstrapEnvelopeClaim,verifyManagedBootstrapImageCompletion:()=>verifyManagedBootstrapImageCompletion,waitForManagedBootstrapImageCompletion:()=>waitForManagedBootstrapImageCompletion});module.exports=__toCommonJS(image_runtime_exports);var import_node_fs4=__toESM(require("node:fs"));var import_node_path4=__toESM(require("node:path"));var import_node_child_process=require("node:child_process");var import_node_crypto6=require("node:crypto");var import_node_fs3=__toESM(require("node:fs"));var import_node_path3=__toESM(require("node:path"));var MAX_CORPORATE_CA_BYTES=128*1024;var PEM_CERTIFICATE_RE_GLOBAL=/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;var import_node_buffer2=require("node:buffer");function isObjectRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}var ChannelManifestRegistry=class{manifests=new Map;constructor(manifests=[]){for(const manifest of manifests){this.register(manifest)}}register(manifest){if(this.manifests.has(manifest.id)){throw new Error(`Duplicate channel manifest id '${manifest.id}'`)}this.manifests.set(manifest.id,manifest);return this}get(channelId){return this.manifests.get(channelId)}list(){return Array.from(this.manifests.values())}listAvailable(ctx={}){const supportedChannelIds=Array.isArray(ctx.supportedChannelIds)?new Set(ctx.supportedChannelIds):null;return this.list().filter(manifest=>{if(ctx.agent&&!manifest.supportedAgents.includes(ctx.agent)){return false}if(supportedChannelIds&&!supportedChannelIds.has(manifest.id)){return false}return true})}};function createChannelManifestRegistry(manifests=[]){return new ChannelManifestRegistry(manifests)}var discordManifest={schemaVersion:1,id:"discord",displayName:"Discord",description:"Discord bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"DISCORD_BOT_TOKEN",prompt:{label:"Discord Bot Token",help:"Discord Developer Portal \u2192 Applications \u2192 Bot \u2192 Reset/Copy Token."}},{id:"serverId",kind:"config",required:false,envKey:"DISCORD_SERVER_ID",statePath:"discordGuilds.serverId",prompt:{label:"Discord Server ID (for guild workspace access)",help:"Enable Developer Mode in Discord, then right-click your server and copy the Server ID.",emptyValueMessage:"guild channels stay disabled"}},{id:"requireMention",kind:"config",required:false,envKey:"DISCORD_REQUIRE_MENTION",statePath:"discordGuilds.requireMention",promptWhenInput:"serverId",validValues:["0","1"],defaultValue:"1",prompt:{label:"Discord mention mode",help:"Choose whether the bot should reply only when @mentioned or to all messages in this server."}},{id:"userId",kind:"config",required:false,envKey:"DISCORD_USER_ID",statePath:"discordGuilds.userIds",promptWhenInput:"serverId",prompt:{label:"Discord User ID (optional guild allowlist)",help:"Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.",emptyValueMessage:"any member in the configured server can message the bot"}}],credentials:[{id:"discordBotToken",sourceInput:"botToken",providerName:"{sandboxName}-discord-bridge",providerEnvKey:"DISCORD_BOT_TOKEN",placeholder:"openshell:resolve:env:DISCORD_BOT_TOKEN"}],policyPresets:[{name:"discord",requiredAtCreate:true,validationWarningLines:["For Discord preset validation, do not use curl as the success signal:","curl is not in the preset binary allowlist, so curl probes can fail even","when the policy is working. Use Node HTTPS against","https://discord.com/api/v10/gateway or validate the configured",'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',"can also be inconclusive behind a proxy."]}],render:[{id:"discord-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.discord",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false},proxy:"{{discordProxyUrl}}",dmPolicy:"{{discord.allowedUsers.dmPolicy}}",allowFrom:"{{discord.allowedUsers.values}}"}}}}},{id:"discord-openclaw-guilds",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{discord.hasGuilds}}",fragment:{path:"channels.discord",value:{groupPolicy:"allowlist",guilds:"{{discord.guilds}}"}}},{id:"discord-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.discord",value:{enabled:true}}},{id:"discord-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}","DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}","DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}"]},{id:"discord-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"discord",value:{require_mention:"{{discord.requireMention}}",free_response_channels:"",allowed_channels:"",auto_thread:true,reactions:true,channel_prompts:{}}}},{id:"discord-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.discord",value:{enabled:true}}}],runtime:{openclaw:{channelName:"discord",visibility:{configKeys:["discord"],logPatterns:["discord"]}}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/discord@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"},required:true}],hooks:[{id:"discord-openclaw-bridge-health",phase:"health-check",handler:"discord.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"discord-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"discord-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"serverId",kind:"config"},{id:"requireMention",kind:"config"},{id:"userId",kind:"config"}]}]};var googlechatManifest={schemaVersion:1,id:"googlechat",displayName:"Google Chat",description:"Google Chat (Chat API) bot messaging (experimental)",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"serviceAccount",kind:"secret",required:true,envKey:"GOOGLECHAT_SERVICE_ACCOUNT",maskCap:40,formatHint:"Paste the entire service-account JSON key on one line (minified) \u2014 the whole downloaded JSON file.",maxTokenAttempts:3,prompt:{label:"Google Chat service account JSON",help:["\u2503 GOOGLE CHAT \u2014 service account key","\u2503","\u2503 Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts","\u2503 \u2192 your bot's SA \u2192 Keys \u2192 Add key \u2192 Create new key \u2192 JSON","\u2503","\u2503 A .json file downloads. Paste its contents below as ONE line (minified).",""].join("\n")}},{id:"audienceType",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE_TYPE",statePath:"googlechatConfig.audienceType",validValues:["app-url","project-number"],defaultValue:"app-url"},{id:"audience",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE",statePath:"googlechatConfig.audience",prompt:{label:"Google Chat webhook audience",help:"Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",emptyValueMessage:"inbound webhook verification will be unconfigured"}},{id:"appPrincipal",kind:"config",required:false,envKey:"GOOGLECHAT_APP_PRINCIPAL",statePath:"googlechatConfig.appPrincipal",formatPattern:"^[0-9]{6,32}$",formatHint:"appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",prompt:{label:"Google Chat appPrincipal",help:[" Workspace account \u2192 leave blank, done."," Personal Gmail \u2192 needs the add-on's ~21-digit ID (not an email), stable across rebuilds.",""," If you already know it, paste it at the prompt and you're done."," If not, leave it blank \u2014 the first DM reveals it once the sandbox is live:",""," 1. Watch the gateway log:",' nemoclaw logs --follow | grep "unexpected add-on principal"'," 2. DM the bot once \u2014 it won't reply yet, that's expected. The log prints:"," unexpected add-on principal: "," 3. Save that and rebuild:"," GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat"," nemoclaw rebuild --yes"].join("\n"),emptyValueMessage:"Workspace accounts do not need it; personal accounts must set it later"}},{id:"allowFrom",kind:"config",required:false,envKey:"GOOGLECHAT_ALLOWED_USERS",statePath:"allowedIds.googlechat",prompt:{label:"Google Chat DM allowlist (comma-separated)",help:["Optional: restrict who can DM the bot."," OpenClaw: users/NNN (emails ignored)"," Hermes: email (users/NNN ignored)"," Blank: pairing mode (recommended) \u2014 OpenClaw's pairing reply shows your users/NNN"," Filling this switches DM policy to allowlist \u2014 a wrong-form entry is dropped silently, with no pairing code."].join("\n"),emptyValueMessage:"bot will require manual pairing"}},{id:"projectId",kind:"config",required:false,envKey:"GOOGLE_CHAT_PROJECT_ID",statePath:"googlechatConfig.projectId",prompt:{label:"Google Chat GCP project ID (Hermes Pub/Sub pull)",help:"The Google Cloud project that owns the Pub/Sub subscription Hermes pulls Chat events from. OpenClaw ignores this.",emptyValueMessage:"required for the Hermes Google Chat channel"}},{id:"subscriptionName",kind:"config",required:false,envKey:"GOOGLE_CHAT_SUBSCRIPTION_NAME",statePath:"googlechatConfig.subscriptionName",prompt:{label:"Google Chat Pub/Sub subscription (projects/

/subscriptions/)",help:["The pull subscription bound to the Chat events topic. Hermes pulls from it over the Pub/Sub REST API; the gateway-minted token is scoped to both chat.bot and pubsub."," Its topic must grant roles/pubsub.publisher to the app's push account:"," Interactive features service-@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"," Classic bot chat-api-push@system.gserviceaccount.com"," Shown at Chat API \u2192 Configuration \u2192 Connection settings"," Missing it channel connects, no event arrives, Chat says the bot is not responding"].join("\n"),emptyValueMessage:"required for the Hermes Google Chat channel"}}],credentials:[],policyPresets:[{name:"googlechat",policyKeys:["googlechat"],agentPolicyKeys:{hermes:["googlechat_hermes"]}}],render:[{id:"googlechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.googlechat",value:{enabled:true,serviceAccountFile:"/nonexistent/googlechat-gateway-minted-no-service-account-file",audienceType:"{{googlechatConfig.audienceType}}",audience:"{{googlechatConfig.audience}}",appPrincipal:"{{googlechatConfig.appPrincipal}}",webhookPath:"/googlechat",healthMonitor:{enabled:false},dm:{policy:"{{allowedIds.googlechat.dmPolicy}}",allowFrom:"{{allowedIds.googlechat.values}}"}}}},{id:"googlechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.googlechat",value:{enabled:true}}},{id:"googlechat-openclaw-gateway-reload-off",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"gateway.reload",value:{mode:"off"}}},{id:"googlechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["GOOGLE_CHAT_PROJECT_ID={{googlechatConfig.projectId}}","GOOGLE_CHAT_SUBSCRIPTION_NAME={{googlechatConfig.subscriptionName}}","GOOGLE_CHAT_ALLOWED_USERS={{allowedIds.googlechat.csv}}"]},{id:"googlechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.google_chat",value:{enabled:true}}}],runtime:{openclaw:{channelName:"googlechat",visibility:{configKeys:["googlechat"],logPatterns:["googlechat"]},nodePreloads:[{module:"googlechat-trusted-proxy-fetch",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",installedMessage:"[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)"},{module:"googlechat-outbound-auth",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",installedMessage:"[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"-----BEGIN (?:RSA )?PRIVATE KEY-----",message:"[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",exitCode:78}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/googlechat@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-Dv0xOmcxAThEr6hoK+ioofHNu18hfbIceQrEHX3AHZPpOUiTJvToVpA5eX87NQINewwfSJf0gVhE6kSbSk2Aew=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.7.1.tgz"},required:true},{id:"hermesGooglePubsubPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-cloud-pubsub==2.39.0",required:true},{id:"hermesGoogleApiClientPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-api-python-client==2.194.0",required:true},{id:"hermesGoogleAuthPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-auth==2.55.1",required:true}],hooks:[{id:"googlechat-tunnel-audience-gate",phase:"enroll",handler:"googlechat.tunnelAudienceGate",agents:["openclaw"],inputs:["audienceType","audience"],outputs:[{id:"audience",kind:"config"}],onFailure:"skip-channel"},{id:"googlechat-service-account",phase:"enroll",handler:"googlechat.tokenPaste",outputs:[{id:"serviceAccount",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"googlechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowFrom",kind:"config"}]},{id:"googlechat-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"appPrincipal",kind:"config"}]},{id:"googlechat-hermes-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"projectId",kind:"config"},{id:"subscriptionName",kind:"config"}]}]};var slackManifest={schemaVersion:1,id:"slack",displayName:"Slack",description:"Slack bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"SLACK_BOT_TOKEN",formatPattern:"^xoxb-[A-Za-z0-9_-]+$",formatHint:"Slack bot tokens start with 'xoxb-' (e.g. xoxb---).",prompt:{label:"Slack Bot Token",help:"Slack API \u2192 Your Apps \u2192 OAuth & Permissions \u2192 Bot User OAuth Token (xoxb-...)."}},{id:"appToken",kind:"secret",required:true,envKey:"SLACK_APP_TOKEN",formatPattern:"^xapp-[A-Za-z0-9_-]+$",formatHint:"Slack app tokens start with 'xapp-' (e.g. xapp----).",prompt:{label:"Slack App Token (Socket Mode)",help:"Slack API \u2192 Your Apps \u2192 Basic Information \u2192 App-Level Tokens (xapp-...)."}},{id:"allowedUsers",kind:"config",required:false,envKey:"SLACK_ALLOWED_USERS",statePath:"allowedIds.slack",prompt:{label:"Slack Member IDs (comma-separated allowlist)",help:"In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.",emptyValueMessage:"bot will require manual pairing"}},{id:"allowedChannels",kind:"config",required:false,envKey:"SLACK_ALLOWED_CHANNELS",statePath:"slackConfig.allowedChannels",prompt:{label:"Slack Channel IDs (comma-separated allowlist)",help:"Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.",emptyValueMessage:"channel @mentions stay unrestricted by channel ID"}}],credentials:[{id:"slackBotToken",sourceInput:"botToken",providerName:"{sandboxName}-slack-bridge",providerEnvKey:"SLACK_BOT_TOKEN",placeholder:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",primary:true},{id:"slackAppToken",sourceInput:"appToken",providerName:"{sandboxName}-slack-app",providerEnvKey:"SLACK_APP_TOKEN",placeholder:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"}],policyPresets:[{name:"slack",requiredAtCreate:true}],render:[{id:"slack-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.slack",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false},dmPolicy:"{{allowedIds.slack.dmPolicy}}",allowFrom:"{{allowedIds.slack.values}}",groupPolicy:"{{allowedIds.slack.groupPolicy}}",channels:"{{allowedIds.slack.channels}}"}}}}},{id:"slack-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.slack",value:{enabled:true}}},{id:"slack-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}","SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}"]},{id:"slack-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.slack",value:{enabled:true,extra:{rich_blocks:true}}}}],runtime:{openclaw:{channelName:"slack",visibility:{configKeys:["slack"],logPatterns:["slack"]},nodePreloads:[{module:"slack-channel-guard",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Slack channel guard (unhandled-rejection safety net)",installedMessage:"[channels] Slack channel guard installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)",message:"[SECURITY] Slack token leaked into {path} - refusing to serve",exitCode:78}]},hermes:{}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/slack@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"},required:true}],hooks:[{id:"slack-socket-mode-gateway-conflict",phase:"pre-enable",handler:"slack.socketModeGatewayConflict",onFailure:"abort"},{id:"slack-openclaw-bridge-health",phase:"health-check",handler:"slack.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"slack-socket-mode-gateway-status",phase:"status",handler:"slack.socketModeGatewayStatus",outputs:[{id:"gatewayOverlaps",kind:"status"}]},{id:"slack-status-health",phase:"status",handler:"slack.statusHealth",providesReadiness:true,agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]},{id:"slack-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true},{id:"appToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"slack-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedUsers",kind:"config"},{id:"allowedChannels",kind:"config"}]},{id:"slack-credential-validation",phase:"reachability-check",handler:"slack.validateCredentials",inputs:["botToken","appToken"],onFailure:"skip-channel"}]};var TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT={channelId:"teams",renderId:"teams-openclaw-channel",hookId:"teams-openclaw-channel",handlerId:"common.staticOutputs",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",configPath:"channels.msteams",webhookPath:"/api/messages"};function authorizeTeamsOpenClawWebhookField(entry){if(!isPlainDataObject(entry))return[];const contract=TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT;if(ownDataPropertyValue(entry,"channelId")!==contract.channelId||ownDataPropertyValue(entry,"renderId")!==contract.renderId||ownDataPropertyValue(entry,"hookId")!==contract.hookId||ownDataPropertyValue(entry,"handler")!==contract.handlerId||ownDataPropertyValue(entry,"kind")!==contract.kind||ownDataPropertyValue(entry,"agent")!==contract.agent||ownDataPropertyValue(entry,"target")!==contract.target||ownDataPropertyValue(entry,"path")!==contract.configPath){return[]}const value=ownDataPropertyValue(entry,"value");if(!isPlainDataObject(value))return[];const webhook=ownDataPropertyValue(value,"webhook");if(!isPlainDataObject(webhook)||!hasExactlyOwnDataProperties(webhook,["path","port"])||!isTcpPort(ownDataPropertyValue(webhook,"port"))||ownDataPropertyValue(webhook,"path")!==contract.webhookPath){return[]}return[{path:["value","webhook"],value:webhook}]}function isPlainDataObject(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function hasExactlyOwnDataProperties(value,expected){const actual=Object.getOwnPropertyNames(value).sort();return actual.length===expected.length&&actual.every((key,index)=>key===expected[index])}function isTcpPort(value){return Number.isInteger(value)&&value>=1&&value<=65535}var teamsManifest={schemaVersion:1,id:"teams",displayName:"Microsoft Teams",description:"Microsoft Teams bot messaging (experimental)",enrollmentNotes:["Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.","Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"appId",kind:"config",required:true,envKey:"MSTEAMS_APP_ID",statePath:"teamsConfig.appId",prompt:{label:"Microsoft Teams Client ID",help:"Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID."}},{id:"clientSecret",kind:"secret",required:true,envKey:"MSTEAMS_APP_PASSWORD",prompt:{label:"Microsoft Teams Client Secret",help:"Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost."}},{id:"tenantId",kind:"config",required:true,envKey:"MSTEAMS_TENANT_ID",statePath:"teamsConfig.tenantId",prompt:{label:"Microsoft Teams Tenant ID",help:"Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`."}},{id:"allowedUsers",kind:"config",required:false,envKey:"TEAMS_ALLOWED_USERS",statePath:"allowedIds.teams",prompt:{label:"Microsoft Teams AAD Object IDs (comma-separated allowlist)",help:"Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot."}},{id:"webhookPort",kind:"config",required:false,envKey:"MSTEAMS_PORT",statePath:"teamsConfig.webhookPort",defaultValue:"3978",prompt:{label:"Microsoft Teams webhook port",help:"Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages."}},{id:"requireMention",kind:"config",required:false,envKey:"TEAMS_REQUIRE_MENTION",statePath:"teamsConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Microsoft Teams mention mode",help:"Controls OpenClaw group and channel behavior only. Direct messages are unaffected."}}],credentials:[{id:"teamsClientSecret",sourceInput:"clientSecret",providerName:"{sandboxName}-teams-bridge",providerEnvKey:"MSTEAMS_APP_PASSWORD",placeholder:"openshell:resolve:env:MSTEAMS_APP_PASSWORD",primary:true}],policyPresets:[{name:"teams",policyKeys:["teams"],requiredAtCreate:true}],hostForward:{port:"{{teamsConfig.webhookPort}}",label:"Microsoft Teams webhook"},render:[{id:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.renderId,kind:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.kind,agent:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.agent,target:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.target,fragment:{path:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.configPath,value:{enabled:true,appId:"{{teamsConfig.appId}}",tenantId:"{{teamsConfig.tenantId}}",webhook:{port:"{{teamsConfig.webhookPort}}",path:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.webhookPath},healthMonitor:{enabled:false},streaming:{mode:"off"},dmPolicy:"{{allowedIds.teams.dmPolicy}}",allowFrom:"{{allowedIds.teams.values}}",groupPolicy:"open",requireMention:"{{teamsConfig.requireMention}}"}}},{id:"teams-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.msteams",value:{enabled:true}}},{id:"teams-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TEAMS_CLIENT_ID={{teamsConfig.appId}}","TEAMS_TENANT_ID={{teamsConfig.tenantId}}","TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}","TEAMS_PORT={{teamsConfig.webhookPort}}"]},{id:"teams-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.teams",value:{enabled:true}}}],runtime:{openclaw:{channelName:"msteams",visibility:{configKeys:["msteams"],logPatterns:["msteams","teams"]},nodePreloads:[{module:"msteams-message-hints",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Microsoft Teams message hint patch (native mentions)",installedMessage:"[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)"}]},hermes:{envAliases:[{envKey:"MSTEAMS_APP_PASSWORD",targetEnvKey:"TEAMS_CLIENT_SECRET",match:"^openshell:resolve:env:v[0-9]+_MSTEAMS_APP_PASSWORD$",value:"openshell:resolve:env:MSTEAMS_APP_PASSWORD"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/msteams@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"},required:true},{id:"hermesTeamsAppsPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"microsoft-teams-apps==2.0.13.4",required:true}],hooks:[{id:"teams-host-forward-port-conflict",phase:"pre-enable",handler:"teams.hostForwardPortConflict",inputs:["webhookPort"],onFailure:"abort"},{id:"teams-host-forward-port-status",phase:"status",handler:"teams.hostForwardPortStatus",outputs:[{id:"hostForwardPortOverlaps",kind:"status"}]},{id:"teams-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"clientSecret",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"teams-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appId",kind:"config",required:true},{id:"tenantId",kind:"config",required:true},{id:"allowedUsers",kind:"config"},{id:"webhookPort",kind:"config"},{id:"requireMention",kind:"config"}]}]};var telegramManifest={schemaVersion:1,id:"telegram",displayName:"Telegram",description:"Telegram bot messaging",diagnosticsProbe:"log-tail",enrollmentNotes:["For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).","After changing privacy mode, remove and re-add the bot to each group before testing @mentions."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"TELEGRAM_BOT_TOKEN",prompt:{label:"Telegram Bot Token",help:"Create a bot via @BotFather on Telegram, then copy the token."}},{id:"allowedIds",kind:"config",required:false,envKey:"TELEGRAM_ALLOWED_IDS",statePath:"allowedIds.telegram",prompt:{label:"Telegram User ID (for DM access)",help:"Send /start to @userinfobot on Telegram to get your numeric user ID.",emptyValueMessage:"bot will require manual pairing"}},{id:"requireMention",kind:"config",required:false,envKey:"TELEGRAM_REQUIRE_MENTION",statePath:"telegramConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Telegram group mention mode",help:"Controls Telegram group-chat behavior only \u2014 reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS."}},{id:"groupPolicy",kind:"config",required:false,envKey:"TELEGRAM_GROUP_POLICY",statePath:"telegramConfig.groupPolicy",validValues:["open","allowlist","disabled"],defaultValue:"open",prompt:{label:"Telegram group policy",help:"Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy."}}],credentials:[{id:"telegramBotToken",sourceInput:"botToken",providerName:"{sandboxName}-telegram-bridge",providerEnvKey:"TELEGRAM_BOT_TOKEN",placeholder:"openshell:resolve:env:TELEGRAM_BOT_TOKEN"}],policyPresets:[{name:"telegram",requiredAtCreate:true,policyKeys:["telegram_bot"],agentPolicyKeys:{hermes:["telegram"]}}],render:[{id:"telegram-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.telegram",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false},proxy:"{{proxyUrl}}",groupPolicy:"{{telegramConfig.groupPolicy}}",dmPolicy:"{{allowedIds.telegram.dmPolicy}}",allowFrom:"{{allowedIds.telegram.values}}"}}}}},{id:"telegram-openclaw-groups",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{telegramConfig.openclawGroups}}",fragment:{path:"channels.telegram.groups",value:"{{telegramConfig.openclawGroups}}"}},{id:"telegram-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.telegram",value:{enabled:true}}},{id:"telegram-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}"]},{id:"telegram-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"telegram",value:{require_mention:"{{telegramConfig.requireMention}}"}}},{id:"telegram-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.telegram",value:{enabled:true}}}],runtime:{openclaw:{channelName:"telegram",visibility:{configKeys:["telegram"],logPatterns:["telegram"]},nodePreloads:[{module:"telegram-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Telegram diagnostics (provider readiness + inference errors)",installedMessage:"[channels] Telegram diagnostics installed (NODE_OPTIONS updated)"}]}},hooks:[{id:"telegram-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"telegram-allowlist-aliases",phase:"enroll",handler:"telegram.allowlistAliases",outputs:[{id:"allowedIds",kind:"config"}]},{id:"telegram-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"requireMention",kind:"config"},{id:"allowedIds",kind:"config"}]},{id:"telegram-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"groupPolicy",kind:"config"}]},{id:"telegram-get-me-reachability",phase:"reachability-check",handler:"telegram.getMeReachability",inputs:["botToken"],onFailure:"skip-channel"},{id:"telegram-openclaw-bridge-health",phase:"health-check",handler:"telegram.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"telegram-gateway-conflict-status",phase:"status",handler:"telegram.gatewayConflictStatus",outputs:[{id:"bridgeHealth",kind:"status"}]},{id:"telegram-status-health",phase:"status",handler:"telegram.statusHealth",agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]}]};var WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT={channelId:"wechat",planHookId:"wechat-seed-openclaw-account",handlerId:"wechat.seedOpenClawAccount",outputId:"openclawWeixinAccountFile",kind:"build-file",required:true,mode:"0600"};var WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId;var WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId;var WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId;var WECHAT_TOKEN_PLACEHOLDER="openshell:resolve:env:WECHAT_BOT_TOKEN";function authorizeWechatAccountFilePlaceholders(value){const content=isPlainDataObject2(value)?ownDataPropertyValue2(value,"content"):void 0;if(!isPlainDataObject2(value)||!hasExactlyOwnDataProperties2(value,["content","mode","path"])||!isWechatAccountFilePath(ownDataPropertyValue2(value,"path"))||ownDataPropertyValue2(value,"mode")!==WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.mode||!isPlainDataObject2(content)||!hasOnlyOwnDataProperties(content,["baseUrl","savedAt","token","userId"])||!hasOwnDataProperty(content,"savedAt")||!hasOwnDataProperty(content,"token")||ownDataPropertyValue2(content,"token")!==WECHAT_TOKEN_PLACEHOLDER||!isNonEmptyString(ownDataPropertyValue2(content,"savedAt"))||!isOptionalNonEmptyString(content,"baseUrl")||!isOptionalNonEmptyString(content,"userId")){return[]}return[{path:["content","token"],value:WECHAT_TOKEN_PLACEHOLDER}]}function isWechatAccountFilePath(value){if(typeof value!=="string")return false;const prefix="openclaw-weixin/accounts/";const suffix=".json";if(!value.startsWith(prefix)||!value.endsWith(suffix))return false;const accountId=value.slice(prefix.length,-suffix.length);return accountId===accountId.trim()&&isSafeWechatAccountId(accountId)}function isSafeWechatAccountId(accountId){return accountId.length>0&&accountId!=="."&&accountId!==".."&&!/[\\/\0-\x1F\x7F]/.test(accountId)&&!accountId.includes("..")}function isPlainDataObject2(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue2(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function hasOwnDataProperty(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor!==void 0&&"value"in descriptor}function hasExactlyOwnDataProperties2(value,expected){const actual=Object.getOwnPropertyNames(value).sort();return actual.length===expected.length&&actual.every((key,index)=>key===expected[index])}function hasOnlyOwnDataProperties(value,allowed){return Object.getOwnPropertyNames(value).every(key=>allowed.includes(key))}function isNonEmptyString(value){return typeof value==="string"&&value.length>0}function isOptionalNonEmptyString(value,key){return!hasOwnDataProperty(value,key)||isNonEmptyString(ownDataPropertyValue2(value,key))}var wechatManifest={schemaVersion:1,id:"wechat",displayName:"WeChat",description:"WeChat (personal) bot messaging",enrollmentHelp:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only.",supportedAgents:["openclaw","hermes"],auth:{mode:"host-qr"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"WECHAT_BOT_TOKEN",prompt:{label:"WeChat Bot Token",help:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only."}},{id:"accountId",kind:"config",required:true,envKey:"WECHAT_ACCOUNT_ID",statePath:"wechatConfig.accountId"},{id:"baseUrl",kind:"config",required:false,envKey:"WECHAT_BASE_URL",statePath:"wechatConfig.baseUrl"},{id:"userId",kind:"config",required:false,envKey:"WECHAT_USER_ID",statePath:"wechatConfig.userId"},{id:"allowedIds",kind:"config",required:false,envKey:"WECHAT_ALLOWED_IDS",statePath:"allowedIds.wechat",prompt:{label:"WeChat User ID(s) (DM allowlist)",help:"Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.",emptyValueMessage:"bot will require manual pairing"}}],credentials:[{id:"wechatBotToken",sourceInput:"botToken",providerName:"{sandboxName}-wechat-bridge",providerEnvKey:"WECHAT_BOT_TOKEN",placeholder:"openshell:resolve:env:WECHAT_BOT_TOKEN"}],policyPresets:[{name:"wechat",policyKeys:["wechat_bridge"],requiredAtCreate:true}],render:[{id:"wechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.openclaw-weixin",value:{enabled:true}}},{id:"wechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.openclaw-weixin",value:{enabled:true}}},{id:"wechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}","WEIXIN_BASE_URL={{wechatConfig.baseUrl}}","WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}"]},{id:"wechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.weixin",value:{enabled:true}}}],runtime:{openclaw:{channelName:"openclaw-weixin",visibility:{configKeys:["openclaw-weixin"],logPatterns:["wechat","openclaw-weixin"]},nodePreloads:[{module:"wechat-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing WeChat diagnostics (provider readiness + inference errors)",installedMessage:"[channels] WeChat diagnostics installed (NODE_OPTIONS updated)"}]},hermes:{envAliases:[{envKey:"WECHAT_BOT_TOKEN",targetEnvKey:"WEIXIN_TOKEN",match:"^openshell:resolve:env:v[0-9]+_WECHAT_BOT_TOKEN$",value:"openshell:resolve:env:WECHAT_BOT_TOKEN"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@tencent-weixin/openclaw-weixin@2.4.3",pin:true,integrity:"sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",tarballUrl:"https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz",runtimeLock:{cachePath:"/usr/local/share/nemoclaw/wechat-npm-cache",installCacheEnvKey:"NEMOCLAW_WECHAT_NPM_INSTALL_CACHE",lockFile:"/usr/local/lib/nemoclaw/wechat-runtime/package-lock.json",projectsRoot:"/sandbox/.openclaw/npm/projects",verifierPath:"/usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts",offline:true,legacyPeerDeps:true},required:true}],hooks:[{id:"wechat-host-qr",phase:"enroll",handler:"wechat.ilinkLogin",inputs:["allowedIds"],outputs:[{id:"botToken",kind:"secret",required:true},{id:"accountId",kind:"config",required:true},{id:"baseUrl",kind:"config"},{id:"userId",kind:"config"},{id:"allowedIds",kind:"config"}],onFailure:"skip-channel"},{id:"wechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedIds",kind:"config"}]},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId,phase:"post-agent-install",handler:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId,agents:["openclaw"],inputs:["wechatConfig.accountId","wechatConfig.baseUrl","wechatConfig.userId","credential.wechatBotToken.placeholder"],outputs:[{id:"openclawWeixinAccountsIndex",kind:"build-file",required:true},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId,kind:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.kind,required:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.required},{id:"openclawConfigPatch",kind:"build-file",required:true}],onFailure:"abort"},{id:"wechat-health-check",phase:"health-check",handler:"wechat.healthCheck",inputs:["wechatConfig.accountId"],onFailure:"abort"}]};var whatsappManifest={schemaVersion:1,id:"whatsapp",displayName:"WhatsApp",description:"WhatsApp Web messaging (QR pairing)",enrollmentHelp:"WhatsApp Web pairs via QR code scanned with your phone \u2014 no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.",enrollmentNotes:["After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics."],supportedAgents:["openclaw","hermes"],auth:{mode:"in-sandbox-qr"},inputs:[{id:"mode",kind:"config",required:false,envKey:"WHATSAPP_MODE",statePath:"whatsappConfig.mode",validValues:["self-chat","bot"],defaultValue:"self-chat",prompt:{label:"WhatsApp reply mode",help:"self-chat replies only to messages the paired account sends to itself. bot replies to other senders and stops replying to that self-chat: an unknown sender receives a pairing code you approve with `hermes pairing approve whatsapp `, unless you set WHATSAPP_ALLOWED_IDS to a fixed sender list before this command.",emptyValueMessage:"the sandbox replies only in your own self-chat"}},{id:"allowedIds",kind:"config",required:false,envKey:"WHATSAPP_ALLOWED_IDS",statePath:"allowedIds.whatsapp"}],credentials:[],policyPresets:["whatsapp"],render:[{id:"whatsapp-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.whatsapp",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false}}}}}},{id:"whatsapp-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.whatsapp",value:{enabled:true}}},{id:"whatsapp-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WHATSAPP_ENABLED=true","WHATSAPP_MODE={{whatsappConfig.mode}}","WHATSAPP_DM_POLICY={{whatsappConfig.dmPolicy}}","WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}"]},{id:"whatsapp-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.whatsapp",value:{enabled:true}}}],runtime:{openclaw:{channelName:"whatsapp",visibility:{configKeys:["whatsapp"],logPatterns:["whatsapp"]},nodePreloads:[{module:"whatsapp-qr-compact",injectInto:["connect"],optional:true,installMessage:"[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/whatsapp@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"},required:true}],hooks:[{id:"whatsapp-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"mode",kind:"config"}]},{id:"whatsapp-status-health",phase:"status",handler:"whatsapp.statusHealth",agents:["openclaw","hermes"],outputs:[{id:"channelHealth",kind:"status"}]}]};var BUILT_IN_CHANNEL_MANIFESTS=[telegramManifest,discordManifest,wechatManifest,slackManifest,whatsappManifest,teamsManifest,googlechatManifest];function createBuiltInChannelManifestRegistry(){return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS)}var EXACT_TEMPLATE_PATTERN=/^\{\{\s*([^}]+?)\s*\}\}$/;var TEMPLATE_REFERENCE_PATTERN=/\{\{\s*([^}]+?)\s*\}\}/g;function resolvedRenderTemplateReference(value){return{matched:true,value}}function resolveSandboxNameTemplate(value,sandboxName){return value.replaceAll("{sandboxName}",sandboxName)}function resolveRenderTemplatesInValue(value,context){if(typeof value==="string")return resolveRenderTemplatesInString(value,context);if(Array.isArray(value)){if(value.length===0)return value;const resolved=value.map(entry=>resolveRenderTemplatesInValue(entry,context)).filter(entry=>entry!==void 0);return resolved.length>0?resolved:void 0}if(value&&typeof value==="object"){const sourceEntries=Object.entries(value);if(sourceEntries.length===0)return value;const entries=sourceEntries.map(([key,entry])=>[key,resolveRenderTemplatesInValue(entry,context)]).filter(entry=>entry[1]!==void 0);return entries.length>0?Object.fromEntries(entries):void 0}return value}function isTruthyRenderTemplate(value,context){if(!value)return true;const resolved=resolveRenderTemplatesInString(value,context);if(resolved===void 0||resolved===null||resolved===false)return false;if(Array.isArray(resolved))return resolved.length>0;if(typeof resolved==="object")return Object.keys(resolved).length>0;if(typeof resolved==="string")return resolved.trim().length>0;return true}function resolveRenderTemplatesInString(value,context){const exact=value.match(EXACT_TEMPLATE_PATTERN);if(exact?.[1])return resolveTemplateReference(exact[1].trim(),context);let omitted=false;const resolved=value.replace(TEMPLATE_REFERENCE_PATTERN,(match,reference)=>{const replacement=resolveTemplateReference(reference.trim(),context);if(replacement===void 0||replacement===null){omitted=true;return""}if(Array.isArray(replacement))return replacement.map(String).join(",");if(typeof replacement==="object")return JSON.stringify(replacement);return String(replacement)});return omitted?void 0:resolved}function resolveTemplateReference(reference,context){const resolved=context.referenceResolver?.(reference,context);return resolved?.matched?resolved.value:"{{"+reference+"}}"}function allowedIds(context,channel){return parseList(stateValue(context,`allowedIds.${channel}`))}function stateValue(context,path5){const stateInput=context.inputs.find(input=>input.statePath===path5);if(stateInput?.value!==void 0)return stateInput.value;const inputId=path5.split(".").at(-1);return context.inputs.find(input=>input.inputId===inputId)?.value}function parseList(value){if(Array.isArray(value))return unique(value.map(String).map(cleanString).filter(Boolean));const text=cleanString(value);if(!text)return[];return unique(text.split(",").map(cleanString).filter(Boolean))}function parseBoolean(value){if(typeof value==="boolean")return value;const text=cleanString(value)?.toLowerCase();if(text==="1"||text==="true"||text==="yes"||text==="on")return true;if(text==="0"||text==="false"||text==="no"||text==="off")return false;return void 0}function nonEmptyString(value){return cleanString(value)||void 0}function cleanString(value){const text=String(value??"");if(/[\r\n]/.test(text)){throw new Error("Messaging template values must not contain line breaks.")}return text.trim()}function nonEmptyArray(values){return values.length>0?[...values]:void 0}function nonEmptyCsv(values){return values.length>0?values.join(","):void 0}function nonEmptyObject(value){return Object.keys(value).length>0?value:void 0}function unique(values){return[...new Set(values)]}var resolveDiscordTemplateReference=(reference,context)=>{if(reference==="discordProxyUrl")return resolvedRenderTemplateReference(void 0);switch(reference){case"discord.guilds":return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context)));case"discord.hasGuilds":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0);case"discord.guildIds.csv":return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context))));case"discord.allowedUsers.values":return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context)));case"discord.allowedUsers.csv":return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context)));case"discord.allowedUsers.dmPolicy":return resolvedRenderTemplateReference(discordAllowedUsers(context).length>0?"allowlist":void 0);case"discord.allowAllUsers":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0&&discordAllowedUsers(context).length===0?true:void 0);case"discord.requireMention":return resolvedRenderTemplateReference(discordRequireMention(context));default:return void 0}};function discordGuilds(context){const serverIds=parseList(stateValue(context,"discordGuilds.serverId"));if(serverIds.length===0)return{};const users=parseList(stateValue(context,"discordGuilds.userIds"));const requireMention=parseBoolean(stateValue(context,"discordGuilds.requireMention"))??true;return Object.fromEntries(serverIds.map(serverId=>[serverId,{requireMention,...users.length>0?{users}:{}}]))}function discordAllowedUsers(context){const users=new Set(allowedIds(context,"discord"));for(const guild of Object.values(discordGuilds(context))){for(const user of guild.users??[])users.add(String(user))}return[...users]}function discordRequireMention(context){for(const guild of Object.values(discordGuilds(context))){if(typeof guild.requireMention==="boolean")return guild.requireMention}return true}var DEFAULT_AUDIENCE_TYPE="app-url";var APP_PRINCIPAL_DISCOVERY_SENTINEL="000000000000000000000";var resolveGooglechatTemplateReference=(reference,context)=>{switch(reference){case"googlechatConfig.audienceType":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audienceType"))??DEFAULT_AUDIENCE_TYPE);case"googlechatConfig.audience":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audience")));case"googlechatConfig.appPrincipal":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.appPrincipal"))??APP_PRINCIPAL_DISCOVERY_SENTINEL);case"googlechatConfig.projectId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.projectId")));case"googlechatConfig.subscriptionName":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.subscriptionName")));default:break}const allowReference=reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy|csv)$/);if(!allowReference?.[1])return void 0;const ids=allowedIds(context,"googlechat");switch(allowReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"csv":return resolvedRenderTemplateReference(ids.length>0?ids.join(","):void 0);default:return void 0}};var resolveSlackTemplateReference=(reference,context)=>{if(reference==="slackConfig.allowedChannels.csv"){return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context)))}const allowedIdsReference=reference.match(/^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"slack");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"groupPolicy":return resolvedRenderTemplateReference(ids.length>0||slackAllowedChannels(context).length>0?"allowlist":void 0);case"channels":return resolvedRenderTemplateReference(slackChannelConfig(context,ids));default:return void 0}};function slackChannelConfig(context,users){const allowedChannels=slackAllowedChannels(context);const entry={enabled:true,requireMention:true,...users.length>0?{users:[...users]}:{}};if(allowedChannels.length>0){return Object.fromEntries(allowedChannels.map(channelId=>[channelId,{...entry}]))}return users.length>0?{"*":entry}:void 0}function slackAllowedChannels(context){return parseList(stateValue(context,"slackConfig.allowedChannels"))}var DEFAULT_TEAMS_WEBHOOK_PORT=3978;var resolveTeamsTemplateReference=(reference,context)=>{switch(reference){case"teamsConfig.appId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.appId")));case"teamsConfig.tenantId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.tenantId")));case"teamsConfig.webhookPort":return resolvedRenderTemplateReference(teamsWebhookPort(context));case"teamsConfig.requireMention":return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"teamsConfig.requireMention")));default:break}const allowedIdsReference=reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"teams");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function teamsWebhookPort(context){const raw=nonEmptyString(stateValue(context,"teamsConfig.webhookPort"));if(!raw)return DEFAULT_TEAMS_WEBHOOK_PORT;const port=Number(raw);if(!Number.isInteger(port)||port<1||port>65535){throw new Error("Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.")}return port}var DEFAULT_PROXY_HOST="10.200.0.1";var DEFAULT_PROXY_PORT="3128";var DEFAULT_TELEGRAM_GROUP_POLICY="open";var TELEGRAM_GROUP_POLICIES=new Set(["open","allowlist","disabled"]);var resolveTelegramTemplateReference=(reference,context)=>{if(reference==="proxyUrl")return resolvedRenderTemplateReference(proxyUrl(context.env));if(reference==="telegramConfig.groupPolicy"){return resolvedRenderTemplateReference(telegramGroupPolicy(context))}if(reference==="telegramConfig.openclawGroups"){return resolvedRenderTemplateReference(telegramOpenClawGroups(context))}if(reference==="telegramConfig.requireMention"){return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"telegramConfig.requireMention")))}const allowedIdsReference=reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"telegram");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function proxyUrl(env){const host=nonEmptyString(env?.NEMOCLAW_PROXY_HOST)??DEFAULT_PROXY_HOST;const port=nonEmptyString(env?.NEMOCLAW_PROXY_PORT)??DEFAULT_PROXY_PORT;return`http://${host}:${port}`}function telegramGroupPolicy(context){const value=nonEmptyString(stateValue(context,"telegramConfig.groupPolicy"));return value&&TELEGRAM_GROUP_POLICIES.has(value)?value:DEFAULT_TELEGRAM_GROUP_POLICY}function telegramOpenClawGroups(context){if(telegramGroupPolicy(context)!=="open")return void 0;const requireMention=parseBoolean(stateValue(context,"telegramConfig.requireMention"));return requireMention===true?{"*":{requireMention:true}}:void 0}var WECHAT_ILINK_HOSTS=new Set(["ilinkai.weixin.qq.com","ilinkai.wechat.com"]);var WECHAT_ILINK_IDC_HOST_PATTERN=/^idc-[0-9]+[.]weixin[.]qq[.]com$/;function normalizeWechatIlinkBaseUrl(value){const raw=String(value??"");if(/[\r\n]/.test(raw)){throw new Error("WeChat baseUrl must not contain line breaks.")}const text=raw.trim();if(!text)return void 0;let url;try{url=new URL(text)}catch{throw new Error("WeChat baseUrl must be a valid URL.")}if(url.protocol!=="https:"){throw new Error("WeChat baseUrl must use HTTPS.")}if(url.username||url.password){throw new Error("WeChat baseUrl must not include credentials.")}if(!isWechatIlinkHost(url.hostname)){throw new Error("WeChat baseUrl must use an expected iLink host.")}if(url.pathname&&url.pathname!=="/"||url.search||url.hash){throw new Error("WeChat baseUrl must be an iLink origin URL.")}return url.origin}function isWechatIlinkHost(hostname){const normalized=hostname.toLowerCase();return WECHAT_ILINK_HOSTS.has(normalized)||WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized)}var resolveWechatTemplateReference=(reference,context)=>{const wechatConfig=reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/);if(wechatConfig?.[1]){if(wechatConfig[1]==="baseUrl"){return resolvedRenderTemplateReference(normalizeWechatIlinkBaseUrl(stateValue(context,"wechatConfig.baseUrl")))}return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"wechatConfig."+wechatConfig[1])))}const allowedIdsReference=reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=wechatAllowedIds(context);switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function wechatAllowedIds(context){const ids=allowedIds(context,"wechat");const userId=nonEmptyString(stateValue(context,"wechatConfig.userId"));return userId&&!ids.includes(userId)?[userId,...ids]:ids}var DEFAULT_WHATSAPP_MODE="self-chat";var BOT_WHATSAPP_MODE="bot";var WHATSAPP_MODES=new Set([DEFAULT_WHATSAPP_MODE,BOT_WHATSAPP_MODE]);var resolveWhatsappTemplateReference=(reference,context)=>{if(reference==="whatsappConfig.mode"){return resolvedRenderTemplateReference(whatsappMode(context))}if(reference==="whatsappConfig.dmPolicy"){return resolvedRenderTemplateReference(whatsappDmPolicy(context))}const allowedIdsReference=reference.match(/^allowedIds[.]whatsapp[.](values|csv)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"whatsapp");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));default:return void 0}};function whatsappMode(context){const value=nonEmptyString(stateValue(context,"whatsappConfig.mode"));return value&&WHATSAPP_MODES.has(value)?value:DEFAULT_WHATSAPP_MODE}function whatsappDmPolicy(context){if(whatsappMode(context)!==BOT_WHATSAPP_MODE)return void 0;return allowedIds(context,"whatsapp").length>0?"allowlist":"pairing"}var BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS=[resolveTelegramTemplateReference,resolveDiscordTemplateReference,resolveWechatTemplateReference,resolveSlackTemplateReference,resolveWhatsappTemplateReference,resolveTeamsTemplateReference,resolveGooglechatTemplateReference];function createBuiltInRenderTemplateResolver(){return(reference,context)=>{for(const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS){const resolved=resolver(reference,context);if(resolved)return resolved}return void 0}}var import_node_crypto=__toESM(require("node:crypto"));function hashCredential(value){const normalized=String(value??"").trim();if(!normalized)return null;return import_node_crypto.default.createHash("sha256").update(normalized).digest("hex")}function planCredentialBindings(manifest,context,inputs,environment=process.env){return manifest.credentials.map(credential=>{const sourceInput=inputs.find(input=>input.inputId===credential.sourceInput);const credentialAvailable=sourceInput?.credentialAvailable===true||context.credentialAvailability?.[credential.id]===true||context.credentialAvailability?.[`${manifest.id}.${credential.id}`]===true;const envKey=sourceInput?.sourceEnv??credential.providerEnvKey;const credentialHash=credentialAvailable?hashCredential(environment[envKey])??void 0:void 0;return{channelId:manifest.id,credentialId:credential.id,sourceInput:credential.sourceInput,providerName:resolveSandboxNameTemplate(credential.providerName,context.sandboxName),providerEnvKey:credential.providerEnvKey,placeholder:credential.placeholder,credentialAvailable,...credentialHash!==void 0?{credentialHash}:{}}})}function planHostForward(manifest,inputs,active,referenceResolver,environment=process.env){if(!active||!manifest.hostForward)return void 0;const context={inputs,env:environment,referenceResolver};if(!isTruthyRenderTemplate(manifest.hostForward.when,context))return void 0;const portValue=resolveRenderTemplatesInValue(manifest.hostForward.port,context);const port=normalizeForwardPort(manifest.id,portValue);return{channelId:manifest.id,port,label:manifest.hostForward.label}}function normalizeForwardPort(channelId,value){const port=typeof value==="number"?value:Number(String(value??"").trim());if(!Number.isInteger(port)||port<1||port>65535){throw new Error(`Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`)}return port}var OPENSHELL_ENV_PLACEHOLDER_PREFIX="openshell:resolve:env:";var OPENSHELL_ALIAS_PLACEHOLDER_RE=/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/;function normalizeProviderPlaceholderForEnvKey(value,envKey){if(value.startsWith(OPENSHELL_ENV_PLACEHOLDER_PREFIX)){return placeholderSuffixMatchesEnvKey(value.slice(OPENSHELL_ENV_PLACEHOLDER_PREFIX.length),envKey)?`${OPENSHELL_ENV_PLACEHOLDER_PREFIX}${envKey}`:null}const aliasMatch=value.match(OPENSHELL_ALIAS_PLACEHOLDER_RE);if(!aliasMatch||!placeholderSuffixMatchesEnvKey(aliasMatch[1],envKey)){return null}return value.replace(/-OPENSHELL-RESOLVE-ENV-.+$/,`-OPENSHELL-RESOLVE-ENV-${envKey}`)}function placeholderSuffixMatchesEnvKey(suffix,envKey){if(suffix===envKey)return true;const revisionMatch=suffix.match(/^v[0-9]+_(.+)$/);return revisionMatch?.[1]===envKey}function hasFullPersistedCredentialBindingShape(binding){return typeof binding.channelId==="string"&&typeof binding.credentialId==="string"&&typeof binding.sourceInput==="string"&&typeof binding.providerName==="string"&&typeof binding.providerEnvKey==="string"&&typeof binding.placeholder==="string"&&typeof binding.credentialAvailable==="boolean"}function normalizeFullPersistedCredentialBindings(bindings){return bindings.map(binding=>({channelId:binding.channelId,credentialId:binding.credentialId,sourceInput:binding.sourceInput,providerName:binding.providerName,providerEnvKey:binding.providerEnvKey,placeholder:normalizeProviderPlaceholderForEnvKey(binding.placeholder,binding.providerEnvKey)??binding.placeholder,credentialAvailable:binding.credentialAvailable===true,...typeof binding.credentialHash==="string"?{credentialHash:binding.credentialHash}:{}}))}function normalizePersistedAgentCredentialPlaceholders(render,credentialBindings){const credentialEnvKeys=new Set(credentialBindings.map(binding=>binding.providerEnvKey).filter(Boolean));if(credentialEnvKeys.size===0)return[...render];return render.map(entry=>{if(entry.kind!=="env-lines")return entry;return{...entry,lines:entry.lines.map(line=>normalizeCredentialEnvLine(line,credentialEnvKeys))}})}function normalizeCredentialEnvLine(line,credentialEnvKeys){const index=line.indexOf("=");if(index<=0)return line;const envKey=line.slice(0,index).trim();if(!credentialEnvKeys.has(envKey))return line;const value=line.slice(index+1);const normalized=normalizeProviderPlaceholderForEnvKey(value,envKey);return normalized?`${envKey}=${normalized}`:line}function normalizePersistedSandboxMessagingPlanShape(plan,environment=process.env){const manifestRegistry=createBuiltInChannelManifestRegistry();const disabledChannels=plan.disabledChannels.filter(channelId=>typeof channelId==="string");const disabledSet=new Set(disabledChannels);const channels=plan.channels.map(channel=>normalizePersistedChannel(channel,disabledSet,manifestRegistry.get(channel.channelId),environment));const credentialBindings=normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment);const normalizedPlan={...plan,channels,disabledChannels,credentialBindings,networkPolicy:plan.networkPolicy&&Array.isArray(plan.networkPolicy.entries)?plan.networkPolicy:{presets:[],entries:[]},agentRender:normalizePersistedAgentCredentialPlaceholders(Array.isArray(plan.agentRender)?[...plan.agentRender]:[],credentialBindings),buildSteps:Array.isArray(plan.buildSteps)?[...plan.buildSteps]:[],...plan.runtimeSetup!==void 0?{runtimeSetup:normalizeRuntimeSetup(plan.runtimeSetup)}:{},stateUpdates:Array.isArray(plan.stateUpdates)?[...plan.stateUpdates]:[],healthChecks:Array.isArray(plan.healthChecks)?[...plan.healthChecks]:[]};return normalizedPlan}function normalizePersistedChannel(channel,disabledSet,manifest,environment){const disabled=channel.disabled??disabledSet.has(channel.channelId);const configured=channel.configured??true;const hasFullShape=hasFullChannelShape(channel);const inputs=hasFullShape?normalizeFullInputs(channel.channelId,channel.inputs??[]):normalizePersistedInputs(channel,manifest);const active=channel.active??(configured&&!disabled&&requiredInputsAvailable(manifest,inputs));const hostForward=manifest?planHostForward(manifest,inputs,active&&!disabled,createBuiltInRenderTemplateResolver(),environment):void 0;return{channelId:channel.channelId,displayName:channel.displayName??manifest?.displayName??channel.channelId,authMode:channel.authMode??manifest?.auth.mode??"none",active,selected:channel.selected??configured,configured,disabled,inputs,...hostForward?{hostForward}:{},hooks:Array.isArray(channel.hooks)?[...channel.hooks]:[]}}function normalizePersistedInputs(channel,manifest){const persistedById=new Map((channel.inputs??[]).filter(input=>typeof input.inputId==="string").map(input=>[input.inputId,input]));const fromManifest=(manifest?.inputs??[]).map(input=>inputReferenceFromManifest(channel.channelId,input,persistedById.get(input.id)));const manifestInputIds=new Set((manifest?.inputs??[]).map(input=>input.id));const unknownInputs=[...persistedById.values()].flatMap(input=>{if(!input.inputId||manifestInputIds.has(input.inputId))return[];return[normalizeUnknownInput(channel.channelId,input)]});return[...fromManifest,...unknownInputs]}function normalizeFullInputs(channelId,inputs){return inputs.filter(input=>typeof input.inputId==="string").map(input=>({channelId:typeof input.channelId==="string"?input.channelId:channelId,inputId:input.inputId,kind:input.kind==="secret"||input.kind==="config"?input.kind:"config",required:typeof input.required==="boolean"?input.required:false,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}))}function inputReferenceFromManifest(channelId,input,persisted){return{channelId,inputId:input.id,kind:input.kind,required:input.required,...input.envKey?{sourceEnv:input.envKey}:{},...input.kind==="config"&&input.statePath?{statePath:input.statePath}:{},...persisted?.credentialAvailable!==void 0?{credentialAvailable:persisted.credentialAvailable}:{},...persisted?.value!==void 0?{value:persisted.value}:{}}}function normalizeUnknownInput(channelId,input){const kind=input.kind==="secret"||input.kind==="config"?input.kind:"config";return{channelId,inputId:input.inputId,kind,required:input.required===true,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}}function requiredInputsAvailable(manifest,inputs){if(!manifest)return true;return manifest.inputs.every(manifestInput=>{if(!manifestInput.required)return true;const input=inputs.find(entry=>entry.inputId===manifestInput.id);if(!input)return false;if(input.kind==="secret")return input.credentialAvailable===true;if(input.value===void 0)return false;return typeof input.value==="string"?input.value.trim().length>0:true})}function normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment){const persisted=plan.credentialBindings??[];if(Array.isArray(plan.credentialBindings)&&plan.channels.every(hasFullChannelShape)&&persisted.every(hasFullPersistedCredentialBindingShape)){return normalizeFullPersistedCredentialBindings(persisted)}const manifests=channels.flatMap(channel=>{const manifest=manifestRegistry.get(channel.channelId);return manifest?[manifest]:[]});const planForBindings={...plan,channels,credentialBindings:[],networkPolicy:{presets:[],entries:[]},agentRender:[],buildSteps:[],runtimeSetup:{nodePreloads:[],envAliases:[],secretScans:[]},stateUpdates:[],healthChecks:[]};const generated=credentialBindingsFromManifests(planForBindings,manifests,new Map(channels.map(channel=>[channel.channelId,channel.inputs])),environment);return generated.map(binding=>overlayPersistedCredentialBinding(binding,persisted))}function credentialBindingsFromManifests(plan,manifests,inputRegistry,environment){const context=compilerContext(plan);return manifests.flatMap(manifest=>planCredentialBindings(manifest,context,inputRegistry.get(manifest.id)??[],environment).map(binding=>overlayPersistedCredentialBinding(binding,plan.credentialBindings)))}function overlayPersistedCredentialBinding(binding,persisted){const match=persisted.find(candidate=>credentialBindingMatches(binding,candidate));if(!match)return binding;return{...binding,credentialAvailable:typeof match.credentialAvailable==="boolean"?match.credentialAvailable:binding.credentialAvailable,...typeof match.credentialHash==="string"&&match.credentialHash.length>0?{credentialHash:match.credentialHash}:binding.credentialHash?{credentialHash:binding.credentialHash}:{}}}function credentialBindingMatches(binding,candidate){if(candidate.channelId&&candidate.channelId!==binding.channelId)return false;if(candidate.providerEnvKey&&candidate.providerEnvKey===binding.providerEnvKey)return true;if(candidate.credentialId&&candidate.credentialId===binding.credentialId)return true;if(candidate.sourceInput&&candidate.sourceInput===binding.sourceInput)return true;return false}function hasFullChannelShape(channel){return typeof channel.displayName==="string"&&typeof channel.authMode==="string"&&typeof channel.active==="boolean"&&typeof channel.selected==="boolean"&&typeof channel.configured==="boolean"&&typeof channel.disabled==="boolean"&&Array.isArray(channel.inputs)}function normalizeRuntimeSetup(setup){return{nodePreloads:Array.isArray(setup?.nodePreloads)?[...setup.nodePreloads]:[],envAliases:Array.isArray(setup?.envAliases)?[...setup.envAliases]:[],secretScans:Array.isArray(setup?.secretScans)?[...setup.secretScans]:[]}}function compilerContext(plan){return{sandboxName:plan.sandboxName,agent:plan.agent,workflow:plan.workflow,isInteractive:false,configuredChannels:plan.channels.map(channel=>channel.channelId),disabledChannels:plan.disabledChannels,credentialAvailability:credentialAvailabilityFromPlan(plan)}}function credentialAvailabilityFromPlan(plan){const availability={};for(const channel of plan.channels){for(const input of channel.inputs){if(input.kind!=="secret"||input.credentialAvailable!==true)continue;availability[`${channel.channelId}.${input.inputId}`]=true;if(input.sourceEnv)availability[input.sourceEnv]=true}}for(const credential of plan.credentialBindings){if(!credential.credentialAvailable)continue;availability[credential.credentialId]=true;availability[`${credential.channelId}.${credential.credentialId}`]=true;availability[`${credential.channelId}.${credential.sourceInput}`]=true;availability[credential.providerEnvKey]=true}return availability}function normalizeMessagingChannelId(channelId){return channelId.trim().toLowerCase()}function enabledPlanChannels(plan){const disabled=new Set((plan.disabledChannels??[]).map(normalizeMessagingChannelId).filter(Boolean));return plan.channels.filter(channel=>{const channelId=normalizeMessagingChannelId(channel.channelId);return channelId.length>0&&channel.active&&!channel.disabled&&!disabled.has(channelId)})}function selectActiveMessagingChannelIds(plan){const seen=new Set;const channels=[];for(const item of enabledPlanChannels(plan)){const channel=normalizeMessagingChannelId(item.channelId);if(!channel||seen.has(channel))continue;seen.add(channel);channels.push(channel)}return channels}function selectEnabledMessagingAgentRender(plan){const active=new Set(selectActiveMessagingChannelIds(plan));return plan.agentRender.filter(render=>render.agent===plan.agent&&active.has(normalizeMessagingChannelId(render.channelId)))}function selectEnabledPostAgentInstallBuildFiles(plan){const active=new Set(selectActiveMessagingChannelIds(plan));const channels=enabledPlanChannels(plan);return plan.buildSteps.filter(step=>{const channelId=normalizeMessagingChannelId(step.channelId);if(!active.has(channelId)||step.kind!=="build-file")return false;if(!step.hookId)return true;const matchingChannels=channels.filter(channel=>normalizeMessagingChannelId(channel.channelId)===channelId);if(matchingChannels.length!==1)return false;const matchedHook=matchingChannels[0]?.hooks?.find(hook=>hook.id===step.hookId);return matchedHook!==void 0&&matchedHook.phase==="post-agent-install"})}function parseSandboxMessagingPlan(value,options={}){if(!isObjectRecord(value)||value.schemaVersion!==1||typeof value.sandboxName!=="string"||typeof value.agent!=="string"||typeof value.workflow!=="string"||!Array.isArray(value.channels)||!Array.isArray(value.disabledChannels)||!isOptionalObjectArray(value,"credentialBindings")||Object.hasOwn(value,"networkPolicy")&&!isObjectRecord(value.networkPolicy)||!isOptionalObjectArray(value,"agentRender")||!isOptionalObjectArray(value,"buildSteps")||!isRuntimeSetup(value.runtimeSetup)||!isOptionalObjectArray(value,"stateUpdates")||!isOptionalObjectArray(value,"healthChecks")){return null}if(options.sandboxName&&value.sandboxName!==options.sandboxName)return null;if(options.agent&&value.agent!==options.agent)return null;const supported=Array.isArray(options.supportedChannelIds)?new Set(options.supportedChannelIds):null;const normalizedChannelIds=new Set;for(const channel of value.channels){if(!isObjectRecord(channel)||typeof channel.channelId!=="string")return null;const normalizedChannelId=normalizeMessagingChannelId(channel.channelId);if(!normalizedChannelId||normalizedChannelId!==channel.channelId||normalizedChannelIds.has(normalizedChannelId)){return null}if(Object.hasOwn(channel,"configured")&&typeof channel.configured!=="boolean"){return null}if(Object.hasOwn(channel,"active")&&typeof channel.active!=="boolean")return null;if(Object.hasOwn(channel,"disabled")&&typeof channel.disabled!=="boolean")return null;if(Object.hasOwn(channel,"inputs")&&!Array.isArray(channel.inputs))return null;if(Object.hasOwn(channel,"hostForward")&&!isHostForward(channel.hostForward))return null;if(Object.hasOwn(channel,"hooks")&&!Array.isArray(channel.hooks))return null;if(Array.isArray(channel.inputs)&&channel.inputs.some(input=>!isObjectRecord(input)||typeof input.inputId!=="string"||Object.hasOwn(input,"channelId")&&input.channelId!==normalizedChannelId)){return null}if(Array.isArray(channel.hooks)&&channel.hooks.some(hook=>!isObjectRecord(hook)||Object.hasOwn(hook,"channelId")&&hook.channelId!==normalizedChannelId)){return null}if(Object.hasOwn(channel,"hostForward")&&isObjectRecord(channel.hostForward)&&channel.hostForward.channelId!==normalizedChannelId){return null}if(supported&&!supported.has(channel.channelId))return null;normalizedChannelIds.add(normalizedChannelId)}if(!value.disabledChannels.every(isCanonicalMessagingChannelId))return null;const disabledChannelIds=new Set(value.disabledChannels);if(disabledChannelIds.size!==value.disabledChannels.length||[...disabledChannelIds].some(channelId=>!normalizedChannelIds.has(channelId))||value.channels.some(channel=>isObjectRecord(channel)&&channel.disabled===true!==disabledChannelIds.has(String(channel.channelId)))){return null}if(!hasCanonicalChannelReferences(value.credentialBindings)||!hasMatchingAgentRenderEntries(value.agentRender,value.agent)||!hasCanonicalChannelReferences(value.agentRender)||!hasCanonicalChannelReferences(value.buildSteps)||!hasCanonicalChannelReferences(value.stateUpdates)||!hasCanonicalChannelReferences(value.healthChecks)||!hasCanonicalNetworkPolicyReferences(value.networkPolicy)||!hasCanonicalRuntimeSetupReferences(value.runtimeSetup)){return null}return cloneSandboxMessagingPlan(normalizePersistedSandboxMessagingPlanShape(value,options.environment))}function hasMatchingAgentRenderEntries(value,agent){return!Array.isArray(value)||value.every(render=>isObjectRecord(render)&&render.agent===agent)}function cloneSandboxMessagingPlan(plan){return JSON.parse(JSON.stringify(plan))}function isOptionalObjectArray(value,key){if(!Object.hasOwn(value,key))return true;const entries=value[key];return Array.isArray(entries)&&entries.every(isObjectRecord)}function isHostForward(value){return isObjectRecord(value)&&typeof value.channelId==="string"&&typeof value.port==="number"&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&typeof value.label==="string"}function isRuntimeSetup(value){if(value===void 0)return true;return isObjectRecord(value)&&Array.isArray(value.nodePreloads)&&Array.isArray(value.envAliases)&&Array.isArray(value.secretScans)&&value.nodePreloads.every(isObjectRecord)&&value.envAliases.every(isObjectRecord)&&value.secretScans.every(isObjectRecord)}function isCanonicalMessagingChannelId(value){return typeof value==="string"&&value.length>0&&normalizeMessagingChannelId(value)===value}function hasCanonicalChannelReferences(value){return value===void 0||Array.isArray(value)&&value.every(entry=>isObjectRecord(entry)&&isCanonicalMessagingChannelId(entry.channelId))}function hasCanonicalNetworkPolicyReferences(value){if(!isObjectRecord(value)||!Object.hasOwn(value,"entries"))return true;return hasCanonicalChannelReferences(value.entries)}function hasCanonicalRuntimeSetupReferences(value){if(value===void 0)return true;if(!isObjectRecord(value))return false;return["nodePreloads","envAliases","secretScans"].every(field=>hasCanonicalChannelReferences(value[field]))}var import_node_buffer=require("node:buffer");var import_node_crypto2=require("node:crypto");var import_node_util=require("node:util");function listMessagingCredentialEnvAssignments(options={}){return selectManifests(options).flatMap(manifest=>{const credentialsByTemplate=new Map(manifest.credentials.map(credential=>[`{{credential.${credential.id}.placeholder}}`,credential]));const renderedAssignments=manifest.render.flatMap(render=>{if(options.agent&&render.agent!==options.agent)return[];if(render.kind!=="env-lines")return[];return render.lines.flatMap(line=>{const separator=line.indexOf("=");if(separator<=0)return[];const credential=credentialsByTemplate.get(line.slice(separator+1));if(!credential)return[];return[{channelId:manifest.id,agent:render.agent,sourceEnvKey:credential.providerEnvKey,targetEnvKey:line.slice(0,separator),placeholder:credential.placeholder}]})});const runtimeAssignments=["openclaw","hermes"].flatMap(agent=>{if(options.agent&&agent!==options.agent)return[];if(!manifest.supportedAgents.includes(agent))return[];return(manifest.runtime?.[agent]?.envAliases??[]).flatMap(alias=>{if(!alias.targetEnvKey)return[];const credential=manifest.credentials.find(candidate=>candidate.providerEnvKey===alias.envKey);if(!credential)return[];return[{channelId:manifest.id,agent,sourceEnvKey:alias.envKey,targetEnvKey:alias.targetEnvKey,placeholder:credential.placeholder}]})});return[...renderedAssignments,...runtimeAssignments]})}function selectManifests(options){const manifests=options.manifests??BUILT_IN_CHANNEL_MANIFESTS;const agent=options.agent;const selected=agent?manifests.filter(manifest=>manifest.supportedAgents.includes(agent)):manifests;return[...selected]}function authorizeMessagingManagedStartupFields(entry,section){if(section==="agentRender")return authorizeTeamsOpenClawWebhookField(entry);if(!isPlainDataObject3(entry))return[];const contract=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT;if(ownDataPropertyValue3(entry,"channelId")!==contract.channelId||ownDataPropertyValue3(entry,"hookId")!==contract.planHookId||ownDataPropertyValue3(entry,"handler")!==contract.handlerId||ownDataPropertyValue3(entry,"outputId")!==contract.outputId||ownDataPropertyValue3(entry,"kind")!==contract.kind||ownDataPropertyValue3(entry,"required")!==contract.required){return[]}return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue3(entry,"value")).map(authorization=>({...authorization,path:["value",...authorization.path]}))}function isPlainDataObject3(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue3(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}var DCODE_UPSTREAM_PROVIDER_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;function isValidDcodeUpstreamProvider(value){return DCODE_UPSTREAM_PROVIDER_RE.test(value)}var MANAGED_STARTUP_PROFILE_SCHEMA_VERSION=1;var MANAGED_STARTUP_PROFILE_MAX_BYTES=64*1024;var MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES=Math.ceil(MANAGED_STARTUP_PROFILE_MAX_BYTES/3)*4;var MAX_IDENTIFIER_BYTES=256;var MAX_MODEL_BYTES=1024;var MAX_URL_BYTES=2048;var MAX_LIST_ITEMS=128;var MAX_JSON_NODES=4096;var MAX_JSON_DEPTH=32;var MAX_TUNING_INTEGER=1e9;var MIN_HERMES_CONTEXT_WINDOW=64e3;var SHA256_RE=/^[a-f0-9]{64}$/;var CONTROL_CHARACTER_RE=/[\u0000-\u001f\u007f-\u009f]/u;var BASE64URL_RE=/^[A-Za-z0-9_-]+$/;var RAW_CA_PEM_RE=/-----BEGIN (?:TRUSTED )?CERTIFICATE-----/iu;var RAW_CA_PEM_BASE64_RE=/^LS0tLS1CRUdJTi(?:BDRVJUSUZJQ0FURS0tLS0t|BUlVTVEVEIENFUlRJRklDQVRFLS0tLS0)/u;var RAW_CA_DER_BASE64_RE=/^MII[A-Za-z0-9+/=\r\n]{253,}$/u;var RAW_CA_DATA_URI_RE=/data:application\/(?:pkix-cert|x-x509-ca-cert);base64,MII[A-Za-z0-9+/=]{253,}/iu;var URL_CANDIDATE_RE=/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;var UTF8_DECODER=new import_node_util.TextDecoder("utf-8",{fatal:true});var CREDENTIAL_SHAPED_NAME_PATTERN=/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/iu;var CREDENTIAL_COMPOUND_NAME_PATTERN=/^(?:access|refresh|client|bearer|auth|api|private|signing|session|bot|app|resolved)(?:token|key|secret|password)$/iu;var CREDENTIAL_CAMEL_SUFFIX_PATTERN=/(?:apiKey|accessKey|secretKey|authToken|refreshToken|accessToken|clientSecret|privateKey|passcode|password|passwd|passphrase|bearerToken|botToken|appToken|sessionToken|signingKey|secretPublicKey|personalAccessToken|connectionString|webhookUrl)$/iu;var CREDENTIAL_CAMEL_BOUNDARY_PATTERN=/[a-z0-9](?:Token|Key|Secret|Password|Passphrase|Pat)$/u;var CREDENTIAL_ENV_NAME_PATTERN=/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/u;var CREDENTIAL_HEADER_NAME_PATTERN=/^(?:authorization|proxy-authorization|cookie|set-cookie|.+-(?:key|token|secret|password|passphrase|credential|auth)s?)$/iu;var PUBLIC_KEY_NAME_PATTERN=/^public[-_]?keys?$/iu;var PASS_CREDENTIAL_NAME_PATTERN=/(?:^|[-_])pass(?:wd)?$/iu;var NON_SECRET_KEY_METADATA_NAMES=new Set(["envKey","installCacheEnvKey","providerEnvKey","stateKey","targetEnvKey"]);var MESSAGING_CREDENTIAL_PLACEHOLDER_RE=/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;var MESSAGING_CREDENTIAL_ENV_ALIASES=new Set(listMessagingCredentialEnvAssignments().filter(({sourceEnvKey,targetEnvKey})=>sourceEnvKey!==targetEnvKey).map(({agent,sourceEnvKey,targetEnvKey})=>`${agent}\0${sourceEnvKey}\0${targetEnvKey}`));var MESSAGING_CREDENTIAL_RUNTIME_ENV_ALIASES=new Set(listMessagingCredentialEnvAssignments().filter(({sourceEnvKey,targetEnvKey})=>sourceEnvKey!==targetEnvKey).map(({agent,channelId,sourceEnvKey,targetEnvKey})=>`${agent}\0${channelId}\0${sourceEnvKey}\0${targetEnvKey}`));var JSON_ARRAY_INDEX_SEGMENT_RE=/^\[(?:0|[1-9][0-9]*)\]$/u;var SECRET_VALUE_PATTERNS=[/nvapi-[A-Za-z0-9_-]{10,}/u,/nvcf-[A-Za-z0-9_-]{10,}/u,/ghp_[A-Za-z0-9_-]{10,}/u,/github_pat_[A-Za-z0-9_]{30,}/u,/sk-(?:proj-|ant-)?[A-Za-z0-9_-]{10,}/u,/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/u,/A(?:K|S)IA[A-Z0-9]{16}/u,/hf_[A-Za-z0-9]{10,}/u,/glpat-[A-Za-z0-9_-]{10,}/u,/gsk_[A-Za-z0-9]{10,}/u,/pypi-[A-Za-z0-9_-]{10,}/u,/tvly-[A-Za-z0-9_-]{10,}/u,/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/u,/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/u,/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/u,/\bBearer\s+[A-Za-z0-9_.+/=-]{10,}/iu,/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u];var MANAGED_STARTUP_INFERENCE_APIS=["openai-completions","openai-responses","anthropic-messages"];var MANAGED_STARTUP_REASONING_EFFORTS=["default","low","medium","high"];var MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES=["disabled","thread-opt-in"];var MANAGED_STARTUP_HERMES_TOOL_GATEWAYS=["nous-web","nous-image","nous-audio","nous-browser","nous-code"];var MANAGED_STARTUP_AGENTS=["openclaw","hermes","langchain-deepagents-code","pi"];var MANAGED_STARTUP_MESSAGING_AGENTS=["openclaw","hermes"];function freezeAgentCapabilities(capabilities){return Object.freeze({...capabilities,inferenceApis:Object.freeze([...capabilities.inferenceApis]),dashboardModes:Object.freeze([...capabilities.dashboardModes]),inputModalities:Object.freeze([...capabilities.inputModalities]),webSearchProviders:Object.freeze([...capabilities.webSearchProviders]),toolGateways:Object.freeze([...capabilities.toolGateways]),tuningFields:Object.freeze([...capabilities.tuningFields])})}var PROFILE_CAPABILITIES={openclaw:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["loopback","remote"],inputModalities:["text","image"],webSearchProviders:["brave","tavily"],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning","reasoningEffort"],supportsMessaging:true,supportsInferenceCompatibility:true,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:true,supportsAgentTimeout:true,supportsHeartbeat:true,supportsExtraAgents:true,supportsDeviceAuth:true,observability:"openclaw-otel",supportsMinimalBootstrap:true},hermes:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["disabled","loopback-forwarded"],inputModalities:[],webSearchProviders:["tavily"],toolGateways:[...MANAGED_STARTUP_HERMES_TOOL_GATEWAYS],tuningFields:["contextWindow"],supportsMessaging:true,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false},"langchain-deepagents-code":{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["reasoningEffort"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:true,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"dcode-marker",supportsMinimalBootstrap:false},pi:{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false}};for(const agent of MANAGED_STARTUP_AGENTS){Object.defineProperty(PROFILE_CAPABILITIES,agent,{configurable:false,enumerable:true,value:freezeAgentCapabilities(PROFILE_CAPABILITIES[agent]),writable:false})}var MANAGED_STARTUP_PROFILE_CAPABILITIES=Object.freeze(PROFILE_CAPABILITIES);function affordance(input,profilePath,source="docker-arg",representation="value"){return{input,profilePath,source,representation}}var HOST_PROXY_AFFORDANCES=[affordance("HTTP_PROXY","proxy.hostHttpUrl","runtime-env"),affordance("http_proxy","proxy.hostHttpUrl","runtime-env","derived"),affordance("HTTPS_PROXY","proxy.hostHttpsUrl","runtime-env"),affordance("https_proxy","proxy.hostHttpsUrl","runtime-env","derived"),affordance("NO_PROXY","proxy.hostNoProxy","runtime-env"),affordance("no_proxy","proxy.hostNoProxy","runtime-env","derived")];var MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY={openclaw:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_PRIMARY_MODEL_REF","inference.primaryModelRef"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_INFERENCE_COMPAT_B64","inference.compatibility"),affordance("NEMOCLAW_INFERENCE_INPUTS","inference.inputModalities"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_AGENT_TIMEOUT","agentConfig.agentTimeoutSeconds"),affordance("NEMOCLAW_AGENT_HEARTBEAT_EVERY","agentConfig.heartbeatEvery"),affordance("NEMOCLAW_EXTRA_AGENTS_JSON_B64","agentConfig.extraAgents"),affordance("NEMOCLAW_DISABLE_DEVICE_AUTH","agentConfig.deviceAuth.disabled"),affordance("NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE","agentConfig.deviceAuth.optOutSource"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_OPENCLAW_OTEL","agentConfig.otel.enabled"),affordance("NEMOCLAW_OPENCLAW_OTEL_ENDPOINT","agentConfig.otel.endpointUrl"),affordance("NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME","agentConfig.otel.serviceName"),affordance("NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE","agentConfig.otel.sampleRate"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_BIND","dashboard.bindAddress"),affordance("NEMOCLAW_WSL_DASHBOARD_EXPOSURE","dashboard.wslExposure"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.port","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("NEMOCLAW_MINIMAL_BOOTSTRAP","agentConfig.minimalBootstrap","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],hermes:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER","tools.enabledGateways","docker-arg","derived"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64","tools.enabledGateways"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD","dashboard.mode","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT","dashboard.internalPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_TUI","dashboard.tuiEnabled","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost","runtime-env"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],"langchain-deepagents-code":[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_UPSTREAM_ENDPOINT_URL","inference.upstreamEndpointUrl"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_DCODE_AUTO_APPROVAL","agentConfig.autoApprovalMode"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_OBSERVABILITY","agentConfig.observabilityEnabled","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],pi:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES]};function deferredRuntimeInput(input,owner,reason,admission="managed-launch-forwarded"){return Object.freeze({input,owner,admission,reason})}var MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS=Object.freeze({openclaw:Object.freeze([deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_SHADOW_DIAGNOSTICS","application-environment","operator shadow-diagnostics tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS","application-environment","operator MCP discovery timeout tuning is applied by the application environment transaction"),deferredRuntimeInput("OPENCLAW_HOME","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_STATE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_WORKSPACE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),hermes:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),"langchain-deepagents-code":Object.freeze([deferredRuntimeInput("NEMOCLAW_SANDBOX_NAME","engine-identity","the lifecycle engine owns instance identity outside reusable startup intent"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),pi:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")])});function runtimeCleanupObligation(input,emittedFor,supportedFor,reason){return Object.freeze({input,emittedFor:Object.freeze([...emittedFor]),supportedFor:Object.freeze([...supportedFor]),owner:"application-environment",reason})}var MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS=Object.freeze([runtimeCleanupObligation("NEMOCLAW_DASHBOARD_BIND",["hermes"],["openclaw"],"generic managed-dashboard construction currently emits the OpenClaw-only bind control for Hermes"),runtimeCleanupObligation("NEMOCLAW_MINIMAL_BOOTSTRAP",["hermes","langchain-deepagents-code"],["openclaw"],"generic host-proxy construction currently emits the OpenClaw-only bootstrap control for other agents")]);var ManagedStartupProfileError=class extends Error{constructor(message){super(`Invalid managed startup profile: ${message}`);this.name="ManagedStartupProfileError"}};var PROFILE_KEYS=new Set(["schemaVersion","agent","agentConfig","inference","proxy","dashboard","tools","messaging","tuning","corporateCa"]);var INFERENCE_KEYS=new Set(["routeProvider","upstreamProvider","model","routedBaseUrl","upstreamEndpointUrl","api","primaryModelRef","compatibility","inputModalities"]);var PROXY_KEYS=new Set(["managedHost","managedPort","hostHttpUrl","hostHttpsUrl","hostNoProxy"]);var OPENCLAW_DASHBOARD_KEYS=new Set(["agent","mode","url","port","bindAddress","wslExposure"]);var HERMES_DASHBOARD_KEYS=new Set(["agent","mode","url","publicPort","internalPort","tuiEnabled"]);var DCODE_DASHBOARD_KEYS=new Set(["agent","mode"]);var TOOLS_KEYS=new Set(["disclosure","enabledGateways"]);var MESSAGING_KEYS=new Set(["plan"]);var TUNING_FIELD_ORDER=["contextWindow","maxTokens","reasoning","reasoningEffort"];var TUNING_KEYS=new Set(TUNING_FIELD_ORDER);var CORPORATE_CA_KEYS=new Set(["bundleSha256"]);var OPENCLAW_CONFIG_KEYS=new Set(["agent","webSearch","otel","agentTimeoutSeconds","heartbeatEvery","extraAgents","deviceAuth","minimalBootstrap"]);var HERMES_CONFIG_KEYS=new Set(["agent","webSearch"]);var DCODE_CONFIG_KEYS=new Set(["agent","autoApprovalMode","observabilityEnabled"]);var PI_CONFIG_KEYS=new Set(["agent"]);var PI_DASHBOARD_KEYS=new Set(["agent","mode"]);var WEB_SEARCH_KEYS=new Set(["enabled","provider"]);var OTEL_KEYS=new Set(["enabled","endpointUrl","serviceName","sampleRate"]);var DEVICE_AUTH_KEYS=new Set(["disabled","optOutSource"]);var EXTRA_AGENTS_KEYS=new Set(["agents","defaults","main"]);var MANAGED_STARTUP_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DCODE_AUTO_APPROVAL_MODE_SET=new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES);var REASONING_EFFORT_SET=new Set(MANAGED_STARTUP_REASONING_EFFORTS);var HERMES_INTERNAL_API_PORT=18642;var HERMES_API_PORT_RANGE_START=8642;var HERMES_API_PORT_RANGE_END=8652;function isHermesApiPort(port){return port>=HERMES_API_PORT_RANGE_START&&port<=HERMES_API_PORT_RANGE_END}function isHermesReservedApiPort(port){return port===HERMES_INTERNAL_API_PORT||isHermesApiPort(port)}var HERMES_RESERVED_API_PORT_LABEL=`${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END} or ${HERMES_INTERNAL_API_PORT}`;function isPlainObject(value){if(typeof value!=="object"||value===null||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function isCredentialShapedName(name){if(PUBLIC_KEY_NAME_PATTERN.test(name)||NON_SECRET_KEY_METADATA_NAMES.has(name))return false;return CREDENTIAL_SHAPED_NAME_PATTERN.test(name)||CREDENTIAL_COMPOUND_NAME_PATTERN.test(name)||CREDENTIAL_CAMEL_SUFFIX_PATTERN.test(name)||CREDENTIAL_CAMEL_BOUNDARY_PATTERN.test(name)||CREDENTIAL_ENV_NAME_PATTERN.test(name)||CREDENTIAL_HEADER_NAME_PATTERN.test(name)||PASS_CREDENTIAL_NAME_PATTERN.test(name)}function valueLooksLikeSecret(value){for(let index=0;index=5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="agentRender"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value";const isAuthorizedBuildStepPlaceholder=allowedBuildStepPlaceholders.has(buildStepPlaceholderKey(path5,value));return isCredentialBindingPlaceholder||isAgentRenderValuePlaceholder||isAuthorizedBuildStepPlaceholder}function requiresMessagingSchemaFieldAuthorization(path5){const fieldName=path5[path5.length-1];return fieldName==="webhook"}function messagingAuthorizedFieldKey(path5){return JSON.stringify(path5)}function buildStepPlaceholderKey(path5,value){return JSON.stringify([path5,value])}function messagingCredentialPlaceholderEnvKey(value){if(!MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value))return null;const marker=value.startsWith("openshell:resolve:env:")?"openshell:resolve:env:":"-OPENSHELL-RESOLVE-ENV-";const key=value.slice(value.indexOf(marker)+marker.length);return key.replace(/^v[0-9]+_/u,"")}function containsMessagingCredentialPlaceholder(value){return value.includes("openshell:resolve:env:")||value.includes("-OPENSHELL-RESOLVE-ENV-")}function isMessagingCredentialPlaceholderAssignment(selectedAgent,path5,value){if(path5.length!==6||path5[0]!=="messaging"||path5[1]!=="plan"||path5[2]!=="agentRender"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")||path5[4]!=="lines"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[5]??"")){return false}const separator=value.indexOf("=");if(separator<=0||value.indexOf("=",separator+1)!==-1)return false;const envKey=value.slice(0,separator);const placeholder=value.slice(separator+1);const placeholderEnvKey=messagingCredentialPlaceholderEnvKey(placeholder);return CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&placeholderEnvKey!==null&&(envKey===placeholderEnvKey||typeof selectedAgent==="string"&&MESSAGING_CREDENTIAL_ENV_ALIASES.has(`${selectedAgent}\0${placeholderEnvKey}\0${envKey}`))}function isMessagingRuntimeEnvAliasPath(path5){return path5.length===5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[4]??"")}function ownDataPropertyValue4(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function isCanonicalMessagingRuntimeEnvAlias(selectedAgent,path5,value){if(!isMessagingRuntimeEnvAliasPath(path5))return false;const channelId=ownDataPropertyValue4(value,"channelId");const envKey=ownDataPropertyValue4(value,"envKey");const targetEnvKey=ownDataPropertyValue4(value,"targetEnvKey");const match=ownDataPropertyValue4(value,"match");const placeholder=ownDataPropertyValue4(value,"value");const expectedMatch=targetEnvKey===void 0?`^openshell:resolve:env:(v[0-9]+_)?${envKey}$`:`^openshell:resolve:env:v[0-9]+_${envKey}$`;return typeof envKey==="string"&&CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&match===expectedMatch&&typeof placeholder==="string"&&messagingCredentialPlaceholderEnvKey(placeholder)===envKey&&(targetEnvKey===void 0||typeof selectedAgent==="string"&&typeof channelId==="string"&&typeof targetEnvKey==="string"&&CREDENTIAL_ENV_NAME_PATTERN.test(targetEnvKey)&&MESSAGING_CREDENTIAL_RUNTIME_ENV_ALIASES.has(`${selectedAgent}\0${channelId}\0${envKey}\0${targetEnvKey}`))}function isAllowedMessagingRuntimeAliasStringPath(path5,allowedAliasIndexes){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&allowedAliasIndexes.has(path5[4]??"")&&(path5[5]==="match"||path5[5]==="value"||path5[5]==="targetEnvKey")}function isMessagingPackagePin(path5,value){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="buildSteps"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value"&&path5[5]==="pin"&&typeof value==="boolean"}function containsUrlWithCredentialMaterial(value){const candidates=value.match(URL_CANDIDATE_RE)??[];for(let index=0;index{if(isCredentialShapedName(key))credentialQuery=true});const fragment=url.hash.startsWith("#")?url.hash.slice(1):url.hash;const queryStart=fragment.indexOf("?");const fragmentParameters=new URLSearchParams(queryStart>=0?fragment.slice(queryStart+1):fragment);let credentialFragment=false;fragmentParameters.forEach((_fragmentValue,key)=>{if(isCredentialShapedName(key))credentialFragment=true});if(url.username||url.password||credentialQuery||credentialFragment)return true}catch{}}return false}function invalid(reason){throw new ManagedStartupProfileError(reason)}function payloadPath(path5){return path5.reduce((result,segment)=>segment.startsWith("[")?`${result}${segment}`:`${result}${result?".":""}${segment}`,"")}function mapArrayByIndex(values,mapper){const mapped=[];for(let index=0;index0&&values[insertion-1]>selected){Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:values[insertion-1],writable:true});insertion-=1}Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:selected,writable:true})}return values}function requireRecord(value,where){if(!isPlainObject(value))invalid(`${where} must be an object`);return value}function rejectUnknownKeys(value,allowed,where){const keys=Object.keys(value);for(let index=0;indexmaxBytes||CONTROL_CHARACTER_RE.test(value)){invalid(`${where} must be a bounded, non-empty string without control characters`)}return value}function requireStringEnum(value,allowed,where){const normalized=requireBoundedString(value,where);if(!allowed.has(normalized))invalid(`${where} is not supported`);return normalized}function requireNullablePositiveInteger(value,where){if(value===null)return null;if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>MAX_TUNING_INTEGER){invalid(`${where} must be null or a bounded positive integer`)}return value}function requirePositiveInteger(value,where,maximum=MAX_TUNING_INTEGER){if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>maximum){invalid(`${where} must be a bounded positive integer`)}return value}function requirePort(value,where,minimum=1){if(typeof value!=="number"||!Number.isInteger(value)||value<1||value>65535){invalid(`${where} must be a valid TCP port`)}if(valueMAX_LIST_ITEMS){invalid(`${where} must be a bounded string list`)}const items=mapArrayByIndex(value,item=>requireBoundedString(item,`${where} item`));const unique2=new Set;for(let index=0;index{if(depth>MAX_JSON_DEPTH)invalid(`${where} exceeds the JSON depth limit`);if(current===null||typeof current==="string"||typeof current==="boolean"){return current}if(typeof current==="number"){if(!Number.isFinite(current))invalid(`${where} contains a non-finite number`);return current}if(Array.isArray(current)){return mapArrayByIndex(current,item=>clone(item,depth+1))}if(!isPlainObject(current))invalid(`${where} contains a non-JSON value`);const result=options.nullPrototypeObjects?Object.create(null):{};const keys=Object.getOwnPropertyNames(current);for(let index=0;indexMAX_IDENTIFIER_BYTES||CONTROL_CHARACTER_RE.test(key)){invalid(`${where} contains an invalid object key`)}const descriptor=Object.getOwnPropertyDescriptor(current,key);if(!descriptor||!("value"in descriptor)){invalid(`${where} contains a non-JSON value`)}Object.defineProperty(result,key,{configurable:true,enumerable:true,value:clone(descriptor.value,depth+1),writable:true})}return result};return clone(value,0)}function requireJsonObjectOrNull(value,where){if(value===null)return null;if(!isPlainObject(value))invalid(`${where} must be null or a plain JSON object`);return cloneJsonValue(value,where,{nullPrototypeObjects:true})}function requireJsonObject(value,where){const object=requireJsonObjectOrNull(value,where);if(object===null)invalid(`${where} must be a plain JSON object`);return object}function requireHttpUrl(value,where){const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) URL`)}if(parsed.protocol!=="http:"&&parsed.protocol!=="https:"||parsed.username||parsed.password||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) URL without query or fragment data`)}const pathname=parsed.pathname.replace(/\/+$/u,"");return pathname===""?parsed.origin:`${parsed.origin}${pathname}`}function requireProxyUrl(value,allowedSchemes,where){if(value===null)return null;const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) proxy URL`)}if(!allowedSchemes.has(parsed.protocol)||parsed.username||parsed.password||parsed.pathname!=="/"||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) proxy origin`)}return parsed.origin}function requireManagedProxyHost(value,where){const host=requireBoundedString(value,where);if(!/^[A-Za-z0-9._-]+$/u.test(host)){invalid(`${where} must be a hostname or IPv4 address without a scheme or separators`)}return host}function isLoopbackUrl(value){const hostname=new URL(value).hostname.toLowerCase();return hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="[::1]"}function configuredDashboardPort(value){const explicit=new URL(value).port;return explicit===""?18789:Number(explicit)}function requireSampleRate(value,where){if(typeof value!=="number"||!Number.isFinite(value)||value<0||value>1){invalid(`${where} must be a number between 0 and 1`)}return value}function assertPayloadStructureAndCredentialShapes(root){const pending=[{value:root,depth:0,path:[]}];const allowedRuntimeAliasIndexes=new Set;const allowedMessagingCredentialFields=new Set;const allowedBuildStepPlaceholders=new Set;const selectedAgent=isPlainObject(root)?ownDataPropertyValue4(root,"agent"):void 0;let discoveredNodes=1;let observedBytes=0;const observeText=value=>{observedBytes+=import_node_buffer.Buffer.byteLength(value,"utf8");if(observedBytes>MANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}};const reserveNode=depth=>{discoveredNodes+=1;if(discoveredNodes>MAX_JSON_NODES||depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}observedBytes+=1};while(pending.length>0){const current=pending.pop();if(!current)break;if(current.depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}if(typeof current.value==="string"){observeText(current.value);if(!isAllowedMessagingRuntimeAliasStringPath(current.path,allowedRuntimeAliasIndexes)&&!isMessagingCredentialPlaceholder(current.path,current.value,allowedBuildStepPlaceholders,allowedMessagingCredentialFields)&&!isMessagingCredentialPlaceholderAssignment(selectedAgent,current.path,current.value)&&(valueLooksLikeSecret(current.value)||containsMessagingCredentialPlaceholder(current.value))){invalid(`payload field ${payloadPath(current.path)} contains credential-shaped string data`)}if(RAW_CA_PEM_RE.test(current.value)||RAW_CA_PEM_BASE64_RE.test(current.value)||RAW_CA_DER_BASE64_RE.test(current.value)||RAW_CA_DATA_URI_RE.test(current.value)){invalid(`payload field ${payloadPath(current.path)} contains raw certificate data; provide only the CA SHA-256 digest`)}if(containsUrlWithCredentialMaterial(current.value)){invalid(`payload field ${payloadPath(current.path)} contains a URL with embedded credentials`)}continue}if(Array.isArray(current.value)){if(Object.getPrototypeOf(current.value)!==Array.prototype){invalid("payload arrays must use the standard JSON prototype")}if("toJSON"in current.value){invalid("payload must not define a custom JSON serializer")}if(Object.getOwnPropertySymbols(current.value).length>0||Object.getOwnPropertyNames(current.value).length!==current.value.length+1){invalid("payload arrays must contain only indexed JSON values")}for(let index=0;index0||discoveredNodes+keys.length>MAX_JSON_NODES){invalid("payload structure exceeds the complexity limit")}for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}}function validateWebSearch(value,agent){const webSearch=requireRecord(value,"agentConfig.webSearch");rejectUnknownKeys(webSearch,WEB_SEARCH_KEYS,"agentConfig.webSearch");const provider=requireStringEnum(webSearch.provider,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].webSearchProviders),"agentConfig.webSearch.provider");return{enabled:requireBoolean(webSearch.enabled,"agentConfig.webSearch.enabled"),provider}}function validateOpenClawOtel(value){const otel=requireRecord(value,"agentConfig.otel");rejectUnknownKeys(otel,OTEL_KEYS,"agentConfig.otel");return{enabled:requireBoolean(otel.enabled,"agentConfig.otel.enabled"),endpointUrl:requireHttpUrl(otel.endpointUrl,"agentConfig.otel.endpointUrl"),serviceName:requireBoundedString(otel.serviceName,"agentConfig.otel.serviceName",MAX_IDENTIFIER_BYTES),sampleRate:requireSampleRate(otel.sampleRate,"agentConfig.otel.sampleRate")}}function validateExtraAgents(value){const extraAgents=requireRecord(value,"agentConfig.extraAgents");rejectUnknownKeys(extraAgents,EXTRA_AGENTS_KEYS,"agentConfig.extraAgents");if(!Array.isArray(extraAgents.agents)||extraAgents.agents.length>MAX_LIST_ITEMS){invalid("agentConfig.extraAgents.agents must be a bounded JSON object list")}return{agents:mapArrayByIndex(extraAgents.agents,(agent,index)=>requireJsonObject(agent,`agentConfig.extraAgents.agents[${String(index)}]`)),defaults:requireJsonObject(extraAgents.defaults,"agentConfig.extraAgents.defaults"),main:requireJsonObject(extraAgents.main,"agentConfig.extraAgents.main")}}function validateDeviceAuth(value){const deviceAuth=requireRecord(value,"agentConfig.deviceAuth");rejectUnknownKeys(deviceAuth,DEVICE_AUTH_KEYS,"agentConfig.deviceAuth");return{disabled:requireBoolean(deviceAuth.disabled,"agentConfig.deviceAuth.disabled"),optOutSource:requireStringEnum(deviceAuth.optOutSource,new Set(["operator","managed-onboard"]),"agentConfig.deviceAuth.optOutSource")}}function validateAgentConfig(value,expectedAgent){const config=requireRecord(value,"agentConfig");const agent=requireStringEnum(config.agent,MANAGED_STARTUP_AGENT_SET,"agentConfig.agent");if(agent!==expectedAgent)invalid("agentConfig.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(config,OPENCLAW_CONFIG_KEYS,"agentConfig");const heartbeatEvery=config.heartbeatEvery===null?null:requireBoundedString(config.heartbeatEvery,"agentConfig.heartbeatEvery",MAX_IDENTIFIER_BYTES);if(heartbeatEvery!==null&&!/^\d+(?:s|m|h)$/u.test(heartbeatEvery)){invalid("agentConfig.heartbeatEvery must be null or a duration ending in s, m, or h")}return{agent,webSearch:validateWebSearch(config.webSearch,agent),otel:validateOpenClawOtel(config.otel),agentTimeoutSeconds:requirePositiveInteger(config.agentTimeoutSeconds,"agentConfig.agentTimeoutSeconds"),heartbeatEvery,extraAgents:validateExtraAgents(config.extraAgents),deviceAuth:validateDeviceAuth(config.deviceAuth),minimalBootstrap:requireBoolean(config.minimalBootstrap,"agentConfig.minimalBootstrap")}}if(agent==="hermes"){rejectUnknownKeys(config,HERMES_CONFIG_KEYS,"agentConfig");return{agent,webSearch:validateWebSearch(config.webSearch,agent)}}if(agent==="pi"){rejectUnknownKeys(config,PI_CONFIG_KEYS,"agentConfig");return{agent}}rejectUnknownKeys(config,DCODE_CONFIG_KEYS,"agentConfig");return{agent,autoApprovalMode:requireStringEnum(config.autoApprovalMode,DCODE_AUTO_APPROVAL_MODE_SET,"agentConfig.autoApprovalMode"),observabilityEnabled:requireBoolean(config.observabilityEnabled,"agentConfig.observabilityEnabled")}}function validateDashboard(value,expectedAgent){const dashboard=requireRecord(value,"dashboard");const agent=requireStringEnum(dashboard.agent,MANAGED_STARTUP_AGENT_SET,"dashboard.agent");if(agent!==expectedAgent)invalid("dashboard.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(dashboard,OPENCLAW_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");const bindAddress=requireStringEnum(dashboard.bindAddress,new Set(["127.0.0.1","0.0.0.0"]),"dashboard.bindAddress");const wslExposure=requireBoolean(dashboard.wslExposure,"dashboard.wslExposure");const hasRemoteExposure=!isLoopbackUrl(url)||bindAddress==="0.0.0.0"||wslExposure;if(mode==="remote"!==hasRemoteExposure){invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure")}const port=requirePort(dashboard.port,"dashboard.port",1024);if(isHermesApiPort(port))invalid(`OpenClaw dashboard.port must not use a reserved Hermes API port (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`);if(configuredDashboardPort(url)!==port){invalid("OpenClaw dashboard.port must match dashboard.url")}return{agent,mode,url,port,bindAddress,wslExposure}}if(agent==="hermes"){rejectUnknownKeys(dashboard,HERMES_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");if(!isLoopbackUrl(url)){invalid("Hermes dashboard.url must remain loopback; OpenShell owns the host forward")}if(mode==="disabled"){if(dashboard.publicPort!==null||dashboard.internalPort!==null||dashboard.tuiEnabled!==false){invalid("disabled Hermes dashboard must not configure ports or TUI")}return{agent,mode,url,publicPort:null,internalPort:null,tuiEnabled:false}}const publicPort=requirePort(dashboard.publicPort,"dashboard.publicPort",1024);const internalPort=requirePort(dashboard.internalPort,"dashboard.internalPort",1024);if(publicPort===internalPort){invalid("Hermes dashboard publicPort and internalPort must differ")}if(isHermesReservedApiPort(publicPort)||isHermesReservedApiPort(internalPort)){invalid(`Hermes dashboard ports must not use reserved API ports ${HERMES_RESERVED_API_PORT_LABEL}`)}if(configuredDashboardPort(url)!==publicPort){invalid("Hermes dashboard.publicPort must match dashboard.url")}return{agent,mode,url,publicPort,internalPort,tuiEnabled:requireBoolean(dashboard.tuiEnabled,"dashboard.tuiEnabled")}}if(agent==="pi"){rejectUnknownKeys(dashboard,PI_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled")invalid("pi dashboard.mode must be disabled");return{agent,mode:"disabled"}}rejectUnknownKeys(dashboard,DCODE_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled"){invalid("langchain-deepagents-code dashboard.mode must be disabled")}return{agent,mode:"disabled"}}function validateInference(value,agent){const inference=requireRecord(value,"inference");rejectUnknownKeys(inference,INFERENCE_KEYS,"inference");const routeProvider=requireBoundedString(inference.routeProvider,"inference.routeProvider");const upstreamProvider=requireBoundedString(inference.upstreamProvider,"inference.upstreamProvider");const model=requireBoundedString(inference.model,"inference.model",MAX_MODEL_BYTES);const api=requireStringEnum(inference.api,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inferenceApis),"inference.api");const upstreamEndpointUrl=inference.upstreamEndpointUrl===null?null:requireHttpUrl(inference.upstreamEndpointUrl,"inference.upstreamEndpointUrl");const primaryModelRef=inference.primaryModelRef===null?null:requireBoundedString(inference.primaryModelRef,"inference.primaryModelRef",MAX_MODEL_BYTES);const compatibility=requireJsonObjectOrNull(inference.compatibility,"inference.compatibility");const inputModalities=inference.inputModalities===null?null:requireEnumList(inference.inputModalities,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inputModalities),"inference.inputModalities",{allowEmpty:false});if(upstreamEndpointUrl!==null&&!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsUpstreamEndpoint){invalid(`inference.upstreamEndpointUrl must be null for ${agent}`)}if(agent==="openclaw"){if(primaryModelRef===null||inputModalities===null){invalid("openclaw requires primaryModelRef and inputModalities")}if(primaryModelRef!==`${routeProvider}/${model}`){invalid("openclaw primaryModelRef must match routeProvider and model")}}else{if(primaryModelRef!==null||compatibility!==null||inputModalities!==null){invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`)}if(agent==="langchain-deepagents-code"&&!isValidDcodeUpstreamProvider(upstreamProvider)){invalid("inference.upstreamProvider must start with an ASCII letter or digit and contain 1-64 ASCII letters, digits, dots, underscores, or hyphens for DCode")}}return{routeProvider,upstreamProvider,model,routedBaseUrl:requireHttpUrl(inference.routedBaseUrl,"inference.routedBaseUrl"),upstreamEndpointUrl,api,primaryModelRef,compatibility,inputModalities}}function validateProxy(value,agent){const proxy=requireRecord(value,"proxy");rejectUnknownKeys(proxy,PROXY_KEYS,"proxy");const hostHttpUrl=requireProxyUrl(proxy.hostHttpUrl,new Set(["http:"]),"proxy.hostHttpUrl");const hostHttpsUrl=requireProxyUrl(proxy.hostHttpsUrl,new Set(["http:","https:"]),"proxy.hostHttpsUrl");const hostNoProxy=requireStringList(proxy.hostNoProxy,"proxy.hostNoProxy");if(!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsHostProxyIntent&&(hostHttpUrl!==null||hostHttpsUrl!==null||hostNoProxy.length>0)){invalid(`${agent} rejects host proxy intent and accepts only its root-owned managed route`)}return{managedHost:requireManagedProxyHost(proxy.managedHost,"proxy.managedHost"),managedPort:requirePort(proxy.managedPort,"proxy.managedPort"),hostHttpUrl,hostHttpsUrl,hostNoProxy}}function validateTools(value,agent){const tools=requireRecord(value,"tools");rejectUnknownKeys(tools,TOOLS_KEYS,"tools");const enabledGateways=requireEnumList(tools.enabledGateways,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].toolGateways),"tools.enabledGateways",{allowEmpty:true});return{disclosure:requireStringEnum(tools.disclosure,new Set(["progressive","direct"]),"tools.disclosure"),enabledGateways}}function validateTuning(value,agent){const tuning=requireRecord(value,"tuning");rejectUnknownKeys(tuning,TUNING_KEYS,"tuning");const result={contextWindow:requireNullablePositiveInteger(tuning.contextWindow,"tuning.contextWindow"),maxTokens:requireNullablePositiveInteger(tuning.maxTokens,"tuning.maxTokens"),reasoning:requireNullableBoolean(tuning.reasoning,"tuning.reasoning"),reasoningEffort:tuning.reasoningEffort===null?null:requireStringEnum(tuning.reasoningEffort,REASONING_EFFORT_SET,"tuning.reasoningEffort")};const advertised=new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].tuningFields);const unsupported=TUNING_FIELD_ORDER.filter(field=>result[field]!==null&&!advertised.has(field));if(unsupported.length>0){invalid(`${agent} does not support startup tuning fields: ${unsupported.join(", ")}`)}if(agent==="openclaw"){const missing=TUNING_FIELD_ORDER.filter(field=>advertised.has(field)&&result[field]===null);if(missing.length>0){invalid(`openclaw requires ${missing.join(", ")} tuning`)}}if(agent==="hermes"&&result.contextWindow!==null&&result.contextWindowcanonicalizeJson(item));if(!isPlainObject(value))return value;const result={};const keys=sortStrings(Object.keys(value));for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`canonical payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}return serialized}function decodeManagedStartupProfile(encoded){if(typeof encoded!=="string"||encoded.length===0||import_node_buffer.Buffer.byteLength(encoded,"ascii")>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES||!BASE64URL_RE.test(encoded)||encoded.length%4===1){invalid("encoded payload is malformed or exceeds the size limit")}const bytes=import_node_buffer.Buffer.from(encoded,"base64url");if(bytes.length===0||bytes.length>MANAGED_STARTUP_PROFILE_MAX_BYTES||bytes.toString("base64url")!==encoded){invalid("encoded payload is malformed or exceeds the size limit")}let raw;try{raw=UTF8_DECODER.decode(bytes)}catch{invalid("payload is not valid UTF-8")}let parsed;try{parsed=JSON.parse(raw)}catch{invalid("payload is not valid JSON")}const profile=validateManagedStartupProfile(parsed);if(serializeManagedStartupProfile(profile)!==raw){invalid("payload is not in canonical form")}return profile}function fingerprintManagedStartupProfile(profile){return(0,import_node_crypto2.createHash)("sha256").update(serializeManagedStartupProfile(profile),"utf8").digest("hex")}var ManagedStartupAgentEnvironmentError=class extends Error{constructor(message){super(`Cannot map managed startup profile: ${message}`);this.name="ManagedStartupAgentEnvironmentError"}};var EMPTY_APPLICATION_ENVIRONMENT=Object.freeze({});var OPENCLAW_APPLICATION_RUNTIME_INPUTS=Object.freeze([["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","positive-safe-integer"],["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","positive-finite-seconds"]]);function booleanFlag(value){return value?"1":"0"}function canonicalizeJson2(value){if(Array.isArray(value))return value.map(item=>canonicalizeJson2(item));if(value===null||typeof value!=="object")return value;const record=value;return Object.fromEntries(Object.keys(record).sort().map(key=>[key,canonicalizeJson2(record[key])]))}function encodeCanonicalJson(value){return import_node_buffer2.Buffer.from(JSON.stringify(canonicalizeJson2(value)),"utf8").toString("base64")}function sortedEnvironment(environment){return Object.freeze(Object.fromEntries(Object.entries(environment).sort(([left],[right])=>leftright?1:0)))}function canonicalApplicationRuntimeValue(name,raw,kind){if(raw.includes("\0")||/[\r\n]/u.test(raw)){throw new ManagedStartupAgentEnvironmentError(`${name} must be single-line text`)}const value=Number(raw.trim());const valid=kind==="positive-safe-integer"?Number.isSafeInteger(value)&&value>0:Number.isFinite(value)&&value>0;if(!valid){throw new ManagedStartupAgentEnvironmentError(`${name} must be ${kind==="positive-safe-integer"?"a positive safe integer":"finite positive seconds"}`)}return String(value)}function applicationRuntimePlan(profile,environment){const exportEnvironment={};if(profile.agent==="openclaw"){for(const[name,kind]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){const raw=environment[name];if(raw!==void 0){exportEnvironment[name]=canonicalApplicationRuntimeValue(name,raw,kind)}}}const unsetEnvironment=new Set(MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter(({supportedFor})=>!supportedFor.includes(profile.agent)).map(({input})=>input));if(profile.agent!=="openclaw"){for(const[name]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){unsetEnvironment.add(name)}}return Object.freeze({exportEnvironment:sortedEnvironment(exportEnvironment),unsetEnvironment:Object.freeze([...unsetEnvironment].sort())})}function commonConfigurationEnvironment(profile){return{NEMOCLAW_INFERENCE_API:profile.inference.api,NEMOCLAW_INFERENCE_BASE_URL:profile.inference.routedBaseUrl,NEMOCLAW_INFERENCE_PROVIDER_ID:profile.inference.routeProvider,NEMOCLAW_MODEL:profile.inference.model,NEMOCLAW_TOOL_DISCLOSURE:profile.tools.disclosure,NEMOCLAW_UPSTREAM_PROVIDER:profile.inference.upstreamProvider}}function appendHostProxyEnvironment(environment,profile,options={}){if(options.preserveAmbientWhenAbsent===true&&profile.proxy.hostHttpUrl===null&&profile.proxy.hostHttpsUrl===null&&profile.proxy.hostNoProxy.length===0){return}const httpProxy=profile.proxy.hostHttpUrl??"";const httpsProxy=profile.proxy.hostHttpsUrl??"";const noProxy=profile.proxy.hostNoProxy.join(",");environment.HTTP_PROXY=httpProxy;environment.HTTPS_PROXY=httpsProxy;environment.NO_PROXY=noProxy;environment.http_proxy=httpProxy;environment.https_proxy=httpsProxy;environment.no_proxy=noProxy}function messagingEnvironment(profile,expectedAgent){if(profile.messaging.plan===null)return{};const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:expectedAgent});if(!plan){throw new ManagedStartupAgentEnvironmentError(`messaging.plan must contain a validated ${expectedAgent} messaging plan`)}const{workflow:_workflow,...imageBuildPlan}=plan;return{NEMOCLAW_MESSAGING_PLAN_B64:encodeCanonicalJson(imageBuildPlan)}}function corporateCaMaterial(profile){return Object.freeze({kind:"corporate-ca-handoff",legacyInput:"NEMOCLAW_CORPORATE_CA_B64",expectedSha256:profile.corporateCa.bundleSha256})}function rootOwnedFile(legacyInput,path5,value){return Object.freeze({kind:"root-owned-file",legacyInput,path:path5,contents:`${value} +var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var image_runtime_exports={};__export(image_runtime_exports,{applyManagedBootstrapEnvelope:()=>applyManagedBootstrapEnvelope,main:()=>main2,managedBootstrapEnvelopeClaimPaths:()=>managedBootstrapEnvelopeClaimPaths,readManagedBootstrapEnvelope:()=>readManagedBootstrapEnvelope,recoverManagedBootstrapEnvelopeClaim:()=>recoverManagedBootstrapEnvelopeClaim,verifyManagedBootstrapImageCompletion:()=>verifyManagedBootstrapImageCompletion,waitForManagedBootstrapImageCompletion:()=>waitForManagedBootstrapImageCompletion});module.exports=__toCommonJS(image_runtime_exports);var import_node_fs4=__toESM(require("node:fs"));var import_node_path4=__toESM(require("node:path"));var import_node_child_process=require("node:child_process");var import_node_crypto6=require("node:crypto");var import_node_fs3=__toESM(require("node:fs"));var import_node_path3=__toESM(require("node:path"));var MAX_CORPORATE_CA_BYTES=128*1024;var PEM_CERTIFICATE_RE_GLOBAL=/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;var import_node_buffer2=require("node:buffer");function isObjectRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}var ChannelManifestRegistry=class{manifests=new Map;constructor(manifests=[]){for(const manifest of manifests){this.register(manifest)}}register(manifest){if(this.manifests.has(manifest.id)){throw new Error(`Duplicate channel manifest id '${manifest.id}'`)}this.manifests.set(manifest.id,manifest);return this}get(channelId){return this.manifests.get(channelId)}list(){return Array.from(this.manifests.values())}listAvailable(ctx={}){const supportedChannelIds=Array.isArray(ctx.supportedChannelIds)?new Set(ctx.supportedChannelIds):null;return this.list().filter(manifest=>{if(ctx.agent&&!manifest.supportedAgents.includes(ctx.agent)){return false}if(supportedChannelIds&&!supportedChannelIds.has(manifest.id)){return false}return true})}};function createChannelManifestRegistry(manifests=[]){return new ChannelManifestRegistry(manifests)}var discordManifest={schemaVersion:1,id:"discord",displayName:"Discord",description:"Discord bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"DISCORD_BOT_TOKEN",prompt:{label:"Discord Bot Token",help:"Discord Developer Portal \u2192 Applications \u2192 Bot \u2192 Reset/Copy Token."}},{id:"serverId",kind:"config",required:false,envKey:"DISCORD_SERVER_ID",statePath:"discordGuilds.serverId",prompt:{label:"Discord Server ID (for guild workspace access)",help:"Enable Developer Mode in Discord, then right-click your server and copy the Server ID.",emptyValueMessage:"guild channels stay disabled"}},{id:"requireMention",kind:"config",required:false,envKey:"DISCORD_REQUIRE_MENTION",statePath:"discordGuilds.requireMention",promptWhenInput:"serverId",validValues:["0","1"],defaultValue:"1",prompt:{label:"Discord mention mode",help:"Choose whether the bot should reply only when @mentioned or to all messages in this server."}},{id:"userId",kind:"config",required:false,envKey:"DISCORD_USER_ID",statePath:"discordGuilds.userIds",promptWhenInput:"serverId",prompt:{label:"Discord User ID (optional guild allowlist)",help:"Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.",emptyValueMessage:"any member in the configured server can message the bot"}}],credentials:[{id:"discordBotToken",sourceInput:"botToken",providerName:"{sandboxName}-discord-bridge",providerEnvKey:"DISCORD_BOT_TOKEN",placeholder:"openshell:resolve:env:DISCORD_BOT_TOKEN"}],policyPresets:[{name:"discord",requiredAtCreate:true,validationWarningLines:["For Discord preset validation, do not use curl as the success signal:","curl is not in the preset binary allowlist, so curl probes can fail even","when the policy is working. Use Node HTTPS against","https://discord.com/api/v10/gateway or validate the configured",'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',"can also be inconclusive behind a proxy."]}],render:[{id:"discord-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.discord",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false},proxy:"{{discordProxyUrl}}",dmPolicy:"{{discord.allowedUsers.dmPolicy}}",allowFrom:"{{discord.allowedUsers.values}}"}}}}},{id:"discord-openclaw-guilds",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{discord.hasGuilds}}",fragment:{path:"channels.discord",value:{groupPolicy:"allowlist",guilds:"{{discord.guilds}}"}}},{id:"discord-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.discord",value:{enabled:true}}},{id:"discord-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}","DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}","DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}"]},{id:"discord-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"discord",value:{require_mention:"{{discord.requireMention}}",free_response_channels:"",allowed_channels:"",auto_thread:true,reactions:true,channel_prompts:{}}}},{id:"discord-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.discord",value:{enabled:true}}}],runtime:{openclaw:{channelName:"discord",visibility:{configKeys:["discord"],logPatterns:["discord"]}}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/discord@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"},required:true}],hooks:[{id:"discord-openclaw-bridge-health",phase:"health-check",handler:"discord.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"discord-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"discord-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"serverId",kind:"config"},{id:"requireMention",kind:"config"},{id:"userId",kind:"config"}]}]};var googlechatManifest={schemaVersion:1,id:"googlechat",displayName:"Google Chat",description:"Google Chat (Chat API) bot messaging (experimental)",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"serviceAccount",kind:"secret",required:true,envKey:"GOOGLECHAT_SERVICE_ACCOUNT",maskCap:40,formatHint:"Paste the entire service-account JSON key on one line (minified) \u2014 the whole downloaded JSON file.",maxTokenAttempts:3,prompt:{label:"Google Chat service account JSON",help:["\u2503 GOOGLE CHAT \u2014 service account key","\u2503","\u2503 Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts","\u2503 \u2192 your bot's SA \u2192 Keys \u2192 Add key \u2192 Create new key \u2192 JSON","\u2503","\u2503 A .json file downloads. Paste its contents below as ONE line (minified).",""].join("\n")}},{id:"audienceType",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE_TYPE",statePath:"googlechatConfig.audienceType",validValues:["app-url","project-number"],defaultValue:"app-url"},{id:"audience",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE",statePath:"googlechatConfig.audience",prompt:{label:"Google Chat webhook audience",help:"Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",emptyValueMessage:"inbound webhook verification will be unconfigured"}},{id:"appPrincipal",kind:"config",required:false,envKey:"GOOGLECHAT_APP_PRINCIPAL",statePath:"googlechatConfig.appPrincipal",formatPattern:"^[0-9]{6,32}$",formatHint:"appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",prompt:{label:"Google Chat appPrincipal",help:[" Workspace account \u2192 leave blank, done."," Personal Gmail \u2192 needs the add-on's ~21-digit ID (not an email), stable across rebuilds.",""," If you already know it, paste it at the prompt and you're done."," If not, leave it blank \u2014 the first DM reveals it once the sandbox is live:",""," 1. Watch the gateway log:",' nemoclaw logs --follow | grep "unexpected add-on principal"'," 2. DM the bot once \u2014 it won't reply yet, that's expected. The log prints:"," unexpected add-on principal: "," 3. Save that and rebuild:"," GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat"," nemoclaw rebuild --yes"].join("\n"),emptyValueMessage:"Workspace accounts do not need it; personal accounts must set it later"}},{id:"allowFrom",kind:"config",required:false,envKey:"GOOGLECHAT_ALLOWED_USERS",statePath:"allowedIds.googlechat",prompt:{label:"Google Chat DM allowlist (comma-separated)",help:["Optional: restrict who can DM the bot."," OpenClaw: users/NNN (emails ignored)"," Hermes: email (users/NNN ignored)"," Blank: pairing mode (recommended) \u2014 OpenClaw's pairing reply shows your users/NNN"," Filling this switches DM policy to allowlist \u2014 a wrong-form entry is dropped silently, with no pairing code."].join("\n"),emptyValueMessage:"bot will require manual pairing"}},{id:"projectId",kind:"config",required:false,envKey:"GOOGLE_CHAT_PROJECT_ID",statePath:"googlechatConfig.projectId",prompt:{label:"Google Chat GCP project ID (Hermes Pub/Sub pull)",help:"The Google Cloud project that owns the Pub/Sub subscription Hermes pulls Chat events from. OpenClaw ignores this.",emptyValueMessage:"required for the Hermes Google Chat channel"}},{id:"subscriptionName",kind:"config",required:false,envKey:"GOOGLE_CHAT_SUBSCRIPTION_NAME",statePath:"googlechatConfig.subscriptionName",prompt:{label:"Google Chat Pub/Sub subscription (projects/

/subscriptions/)",help:["The pull subscription bound to the Chat events topic. Hermes pulls from it over the Pub/Sub REST API; the gateway-minted token is scoped to both chat.bot and pubsub."," Its topic must grant roles/pubsub.publisher to the app's push account:"," Interactive features service-@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"," Classic bot chat-api-push@system.gserviceaccount.com"," Shown at Chat API \u2192 Configuration \u2192 Connection settings"," Missing it channel connects, no event arrives, Chat says the bot is not responding"].join("\n"),emptyValueMessage:"required for the Hermes Google Chat channel"}}],credentials:[],policyPresets:[{name:"googlechat",policyKeys:["googlechat"],agentPolicyKeys:{hermes:["googlechat_hermes"]}}],render:[{id:"googlechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.googlechat",value:{enabled:true,serviceAccountFile:"/nonexistent/googlechat-gateway-minted-no-service-account-file",audienceType:"{{googlechatConfig.audienceType}}",audience:"{{googlechatConfig.audience}}",appPrincipal:"{{googlechatConfig.appPrincipal}}",webhookPath:"/googlechat",healthMonitor:{enabled:false},dm:{policy:"{{allowedIds.googlechat.dmPolicy}}",allowFrom:"{{allowedIds.googlechat.values}}"}}}},{id:"googlechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.googlechat",value:{enabled:true}}},{id:"googlechat-openclaw-gateway-reload-off",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"gateway.reload",value:{mode:"off"}}},{id:"googlechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["GOOGLE_CHAT_PROJECT_ID={{googlechatConfig.projectId}}","GOOGLE_CHAT_SUBSCRIPTION_NAME={{googlechatConfig.subscriptionName}}","GOOGLE_CHAT_ALLOWED_USERS={{allowedIds.googlechat.csv}}"]},{id:"googlechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.google_chat",value:{enabled:true}}}],runtime:{openclaw:{channelName:"googlechat",visibility:{configKeys:["googlechat"],logPatterns:["googlechat"]},nodePreloads:[{module:"googlechat-trusted-proxy-fetch",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",installedMessage:"[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)"},{module:"googlechat-outbound-auth",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",installedMessage:"[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"-----BEGIN (?:RSA )?PRIVATE KEY-----",message:"[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",exitCode:78}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/googlechat@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-Dv0xOmcxAThEr6hoK+ioofHNu18hfbIceQrEHX3AHZPpOUiTJvToVpA5eX87NQINewwfSJf0gVhE6kSbSk2Aew=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.7.1.tgz"},required:true},{id:"hermesGooglePubsubPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-cloud-pubsub==2.39.0",required:true},{id:"hermesGoogleApiClientPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-api-python-client==2.194.0",required:true},{id:"hermesGoogleAuthPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-auth==2.55.1",required:true}],hooks:[{id:"googlechat-tunnel-audience-gate",phase:"enroll",handler:"googlechat.tunnelAudienceGate",agents:["openclaw"],inputs:["audienceType","audience"],outputs:[{id:"audience",kind:"config"}],onFailure:"skip-channel"},{id:"googlechat-service-account",phase:"enroll",handler:"googlechat.tokenPaste",outputs:[{id:"serviceAccount",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"googlechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowFrom",kind:"config"}]},{id:"googlechat-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"appPrincipal",kind:"config"}]},{id:"googlechat-hermes-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"projectId",kind:"config"},{id:"subscriptionName",kind:"config"}]}]};var slackManifest={schemaVersion:1,id:"slack",displayName:"Slack",description:"Slack bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"SLACK_BOT_TOKEN",formatPattern:"^xoxb-[A-Za-z0-9_-]+$",formatHint:"Slack bot tokens start with 'xoxb-' (e.g. xoxb---).",prompt:{label:"Slack Bot Token",help:"Slack API \u2192 Your Apps \u2192 OAuth & Permissions \u2192 Bot User OAuth Token (xoxb-...)."}},{id:"appToken",kind:"secret",required:true,envKey:"SLACK_APP_TOKEN",formatPattern:"^xapp-[A-Za-z0-9_-]+$",formatHint:"Slack app tokens start with 'xapp-' (e.g. xapp----).",prompt:{label:"Slack App Token (Socket Mode)",help:"Slack API \u2192 Your Apps \u2192 Basic Information \u2192 App-Level Tokens (xapp-...)."}},{id:"allowedUsers",kind:"config",required:false,envKey:"SLACK_ALLOWED_USERS",statePath:"allowedIds.slack",prompt:{label:"Slack Member IDs (comma-separated allowlist)",help:"In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.",emptyValueMessage:"bot will require manual pairing"}},{id:"allowedChannels",kind:"config",required:false,envKey:"SLACK_ALLOWED_CHANNELS",statePath:"slackConfig.allowedChannels",prompt:{label:"Slack Channel IDs (comma-separated allowlist)",help:"Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.",emptyValueMessage:"channel @mentions stay unrestricted by channel ID"}}],credentials:[{id:"slackBotToken",sourceInput:"botToken",providerName:"{sandboxName}-slack-bridge",providerEnvKey:"SLACK_BOT_TOKEN",placeholder:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",primary:true},{id:"slackAppToken",sourceInput:"appToken",providerName:"{sandboxName}-slack-app",providerEnvKey:"SLACK_APP_TOKEN",placeholder:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"}],policyPresets:[{name:"slack",requiredAtCreate:true}],render:[{id:"slack-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.slack",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false},dmPolicy:"{{allowedIds.slack.dmPolicy}}",allowFrom:"{{allowedIds.slack.values}}",groupPolicy:"{{allowedIds.slack.groupPolicy}}",channels:"{{allowedIds.slack.channels}}"}}}}},{id:"slack-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.slack",value:{enabled:true}}},{id:"slack-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}","SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}"]},{id:"slack-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.slack",value:{enabled:true,extra:{rich_blocks:true}}}}],runtime:{openclaw:{channelName:"slack",visibility:{configKeys:["slack"],logPatterns:["slack"]},nodePreloads:[{module:"slack-channel-guard",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Slack channel guard (unhandled-rejection safety net)",installedMessage:"[channels] Slack channel guard installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)",message:"[SECURITY] Slack token leaked into {path} - refusing to serve",exitCode:78}]},hermes:{}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/slack@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"},required:true}],hooks:[{id:"slack-socket-mode-gateway-conflict",phase:"pre-enable",handler:"slack.socketModeGatewayConflict",onFailure:"abort"},{id:"slack-openclaw-bridge-health",phase:"health-check",handler:"slack.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"slack-socket-mode-gateway-status",phase:"status",handler:"slack.socketModeGatewayStatus",outputs:[{id:"gatewayOverlaps",kind:"status"}]},{id:"slack-status-health",phase:"status",handler:"slack.statusHealth",providesReadiness:true,agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]},{id:"slack-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true},{id:"appToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"slack-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedUsers",kind:"config"},{id:"allowedChannels",kind:"config"}]},{id:"slack-credential-validation",phase:"reachability-check",handler:"slack.validateCredentials",inputs:["botToken","appToken"],onFailure:"skip-channel"}]};var TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT={channelId:"teams",renderId:"teams-openclaw-channel",hookId:"teams-openclaw-channel",handlerId:"common.staticOutputs",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",configPath:"channels.msteams",webhookPath:"/api/messages"};function authorizeTeamsOpenClawWebhookField(entry){if(!isPlainDataObject(entry))return[];const contract=TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT;if(ownDataPropertyValue(entry,"channelId")!==contract.channelId||ownDataPropertyValue(entry,"renderId")!==contract.renderId||ownDataPropertyValue(entry,"hookId")!==contract.hookId||ownDataPropertyValue(entry,"handler")!==contract.handlerId||ownDataPropertyValue(entry,"kind")!==contract.kind||ownDataPropertyValue(entry,"agent")!==contract.agent||ownDataPropertyValue(entry,"target")!==contract.target||ownDataPropertyValue(entry,"path")!==contract.configPath){return[]}const value=ownDataPropertyValue(entry,"value");if(!isPlainDataObject(value))return[];const webhook=ownDataPropertyValue(value,"webhook");if(!isPlainDataObject(webhook)||!hasExactlyOwnDataProperties(webhook,["path","port"])||!isTcpPort(ownDataPropertyValue(webhook,"port"))||ownDataPropertyValue(webhook,"path")!==contract.webhookPath){return[]}return[{path:["value","webhook"],value:webhook}]}function isPlainDataObject(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function hasExactlyOwnDataProperties(value,expected){const actual=Object.getOwnPropertyNames(value).sort();return actual.length===expected.length&&actual.every((key,index)=>key===expected[index])}function isTcpPort(value){return Number.isInteger(value)&&value>=1&&value<=65535}var teamsManifest={schemaVersion:1,id:"teams",displayName:"Microsoft Teams",description:"Microsoft Teams bot messaging (experimental)",enrollmentNotes:["Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.","Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"appId",kind:"config",required:true,envKey:"MSTEAMS_APP_ID",statePath:"teamsConfig.appId",prompt:{label:"Microsoft Teams Client ID",help:"Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID."}},{id:"clientSecret",kind:"secret",required:true,envKey:"MSTEAMS_APP_PASSWORD",prompt:{label:"Microsoft Teams Client Secret",help:"Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost."}},{id:"tenantId",kind:"config",required:true,envKey:"MSTEAMS_TENANT_ID",statePath:"teamsConfig.tenantId",prompt:{label:"Microsoft Teams Tenant ID",help:"Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`."}},{id:"allowedUsers",kind:"config",required:false,envKey:"TEAMS_ALLOWED_USERS",statePath:"allowedIds.teams",prompt:{label:"Microsoft Teams AAD Object IDs (comma-separated allowlist)",help:"Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot."}},{id:"webhookPort",kind:"config",required:false,envKey:"MSTEAMS_PORT",statePath:"teamsConfig.webhookPort",defaultValue:"3978",prompt:{label:"Microsoft Teams webhook port",help:"Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages."}},{id:"requireMention",kind:"config",required:false,envKey:"TEAMS_REQUIRE_MENTION",statePath:"teamsConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Microsoft Teams mention mode",help:"Controls OpenClaw group and channel behavior only. Direct messages are unaffected."}}],credentials:[{id:"teamsClientSecret",sourceInput:"clientSecret",providerName:"{sandboxName}-teams-bridge",providerEnvKey:"MSTEAMS_APP_PASSWORD",placeholder:"openshell:resolve:env:MSTEAMS_APP_PASSWORD",primary:true}],policyPresets:[{name:"teams",policyKeys:["teams"],requiredAtCreate:true}],hostForward:{port:"{{teamsConfig.webhookPort}}",label:"Microsoft Teams webhook"},render:[{id:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.renderId,kind:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.kind,agent:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.agent,target:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.target,fragment:{path:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.configPath,value:{enabled:true,appId:"{{teamsConfig.appId}}",tenantId:"{{teamsConfig.tenantId}}",webhook:{port:"{{teamsConfig.webhookPort}}",path:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.webhookPath},healthMonitor:{enabled:false},streaming:{mode:"off"},dmPolicy:"{{allowedIds.teams.dmPolicy}}",allowFrom:"{{allowedIds.teams.values}}",groupPolicy:"open",requireMention:"{{teamsConfig.requireMention}}"}}},{id:"teams-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.msteams",value:{enabled:true}}},{id:"teams-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TEAMS_CLIENT_ID={{teamsConfig.appId}}","TEAMS_TENANT_ID={{teamsConfig.tenantId}}","TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}","TEAMS_PORT={{teamsConfig.webhookPort}}"]},{id:"teams-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.teams",value:{enabled:true}}}],runtime:{openclaw:{channelName:"msteams",visibility:{configKeys:["msteams"],logPatterns:["msteams","teams"]},nodePreloads:[{module:"msteams-message-hints",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Microsoft Teams message hint patch (native mentions)",installedMessage:"[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)"}]},hermes:{envAliases:[{envKey:"MSTEAMS_APP_PASSWORD",targetEnvKey:"TEAMS_CLIENT_SECRET",match:"^openshell:resolve:env:v[0-9]+_MSTEAMS_APP_PASSWORD$",value:"openshell:resolve:env:MSTEAMS_APP_PASSWORD"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/msteams@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"},required:true},{id:"hermesTeamsAppsPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"microsoft-teams-apps==2.0.13.4",required:true}],hooks:[{id:"teams-host-forward-port-conflict",phase:"pre-enable",handler:"teams.hostForwardPortConflict",inputs:["webhookPort"],onFailure:"abort"},{id:"teams-host-forward-port-status",phase:"status",handler:"teams.hostForwardPortStatus",outputs:[{id:"hostForwardPortOverlaps",kind:"status"}]},{id:"teams-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"clientSecret",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"teams-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appId",kind:"config",required:true},{id:"tenantId",kind:"config",required:true},{id:"allowedUsers",kind:"config"},{id:"webhookPort",kind:"config"},{id:"requireMention",kind:"config"}]}]};var telegramManifest={schemaVersion:1,id:"telegram",displayName:"Telegram",description:"Telegram bot messaging",diagnosticsProbe:"log-tail",enrollmentNotes:["For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).","After changing privacy mode, remove and re-add the bot to each group before testing @mentions."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"TELEGRAM_BOT_TOKEN",prompt:{label:"Telegram Bot Token",help:"Create a bot via @BotFather on Telegram, then copy the token."}},{id:"allowedIds",kind:"config",required:false,envKey:"TELEGRAM_ALLOWED_IDS",statePath:"allowedIds.telegram",prompt:{label:"Telegram User ID (for DM access)",help:"Send /start to @userinfobot on Telegram to get your numeric user ID.",emptyValueMessage:"bot will require manual pairing"}},{id:"requireMention",kind:"config",required:false,envKey:"TELEGRAM_REQUIRE_MENTION",statePath:"telegramConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Telegram group mention mode",help:"Controls Telegram group-chat behavior only \u2014 reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS."}},{id:"groupPolicy",kind:"config",required:false,envKey:"TELEGRAM_GROUP_POLICY",statePath:"telegramConfig.groupPolicy",validValues:["open","allowlist","disabled"],defaultValue:"open",prompt:{label:"Telegram group policy",help:"Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy."}}],credentials:[{id:"telegramBotToken",sourceInput:"botToken",providerName:"{sandboxName}-telegram-bridge",providerEnvKey:"TELEGRAM_BOT_TOKEN",placeholder:"openshell:resolve:env:TELEGRAM_BOT_TOKEN"}],policyPresets:[{name:"telegram",requiredAtCreate:true,policyKeys:["telegram_bot"],agentPolicyKeys:{hermes:["telegram"]}}],render:[{id:"telegram-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.telegram",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false},proxy:"{{proxyUrl}}",groupPolicy:"{{telegramConfig.groupPolicy}}",dmPolicy:"{{allowedIds.telegram.dmPolicy}}",allowFrom:"{{allowedIds.telegram.values}}"}}}}},{id:"telegram-openclaw-groups",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{telegramConfig.openclawGroups}}",fragment:{path:"channels.telegram.groups",value:"{{telegramConfig.openclawGroups}}"}},{id:"telegram-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.telegram",value:{enabled:true}}},{id:"telegram-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}"]},{id:"telegram-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"telegram",value:{require_mention:"{{telegramConfig.requireMention}}"}}},{id:"telegram-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.telegram",value:{enabled:true}}}],runtime:{openclaw:{channelName:"telegram",visibility:{configKeys:["telegram"],logPatterns:["telegram"]},nodePreloads:[{module:"telegram-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Telegram diagnostics (provider readiness + inference errors)",installedMessage:"[channels] Telegram diagnostics installed (NODE_OPTIONS updated)"}]}},hooks:[{id:"telegram-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"telegram-allowlist-aliases",phase:"enroll",handler:"telegram.allowlistAliases",outputs:[{id:"allowedIds",kind:"config"}]},{id:"telegram-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"requireMention",kind:"config"},{id:"allowedIds",kind:"config"}]},{id:"telegram-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"groupPolicy",kind:"config"}]},{id:"telegram-get-me-reachability",phase:"reachability-check",handler:"telegram.getMeReachability",inputs:["botToken"],onFailure:"skip-channel"},{id:"telegram-openclaw-bridge-health",phase:"health-check",handler:"telegram.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"telegram-gateway-conflict-status",phase:"status",handler:"telegram.gatewayConflictStatus",outputs:[{id:"bridgeHealth",kind:"status"}]},{id:"telegram-status-health",phase:"status",handler:"telegram.statusHealth",agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]}]};var WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT={channelId:"wechat",planHookId:"wechat-seed-openclaw-account",handlerId:"wechat.seedOpenClawAccount",outputId:"openclawWeixinAccountFile",kind:"build-file",required:true,mode:"0600"};var WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId;var WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId;var WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId;var WECHAT_TOKEN_PLACEHOLDER="openshell:resolve:env:WECHAT_BOT_TOKEN";function authorizeWechatAccountFilePlaceholders(value){const content=isPlainDataObject2(value)?ownDataPropertyValue2(value,"content"):void 0;if(!isPlainDataObject2(value)||!hasExactlyOwnDataProperties2(value,["content","mode","path"])||!isWechatAccountFilePath(ownDataPropertyValue2(value,"path"))||ownDataPropertyValue2(value,"mode")!==WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.mode||!isPlainDataObject2(content)||!hasOnlyOwnDataProperties(content,["baseUrl","savedAt","token","userId"])||!hasOwnDataProperty(content,"savedAt")||!hasOwnDataProperty(content,"token")||ownDataPropertyValue2(content,"token")!==WECHAT_TOKEN_PLACEHOLDER||!isNonEmptyString(ownDataPropertyValue2(content,"savedAt"))||!isOptionalNonEmptyString(content,"baseUrl")||!isOptionalNonEmptyString(content,"userId")){return[]}return[{path:["content","token"],value:WECHAT_TOKEN_PLACEHOLDER}]}function isWechatAccountFilePath(value){if(typeof value!=="string")return false;const prefix="openclaw-weixin/accounts/";const suffix=".json";if(!value.startsWith(prefix)||!value.endsWith(suffix))return false;const accountId=value.slice(prefix.length,-suffix.length);return accountId===accountId.trim()&&isSafeWechatAccountId(accountId)}function isSafeWechatAccountId(accountId){return accountId.length>0&&accountId!=="."&&accountId!==".."&&!/[\\/\0-\x1F\x7F]/.test(accountId)&&!accountId.includes("..")}function isPlainDataObject2(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue2(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function hasOwnDataProperty(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor!==void 0&&"value"in descriptor}function hasExactlyOwnDataProperties2(value,expected){const actual=Object.getOwnPropertyNames(value).sort();return actual.length===expected.length&&actual.every((key,index)=>key===expected[index])}function hasOnlyOwnDataProperties(value,allowed){return Object.getOwnPropertyNames(value).every(key=>allowed.includes(key))}function isNonEmptyString(value){return typeof value==="string"&&value.length>0}function isOptionalNonEmptyString(value,key){return!hasOwnDataProperty(value,key)||isNonEmptyString(ownDataPropertyValue2(value,key))}var wechatManifest={schemaVersion:1,id:"wechat",displayName:"WeChat",description:"WeChat (personal) bot messaging",enrollmentHelp:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only.",supportedAgents:["openclaw","hermes"],auth:{mode:"host-qr"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"WECHAT_BOT_TOKEN",prompt:{label:"WeChat Bot Token",help:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only."}},{id:"accountId",kind:"config",required:true,envKey:"WECHAT_ACCOUNT_ID",statePath:"wechatConfig.accountId"},{id:"baseUrl",kind:"config",required:false,envKey:"WECHAT_BASE_URL",statePath:"wechatConfig.baseUrl"},{id:"userId",kind:"config",required:false,envKey:"WECHAT_USER_ID",statePath:"wechatConfig.userId"},{id:"allowedIds",kind:"config",required:false,envKey:"WECHAT_ALLOWED_IDS",statePath:"allowedIds.wechat",prompt:{label:"WeChat User ID(s) (DM allowlist)",help:"Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.",emptyValueMessage:"bot will require manual pairing"}}],credentials:[{id:"wechatBotToken",sourceInput:"botToken",providerName:"{sandboxName}-wechat-bridge",providerEnvKey:"WECHAT_BOT_TOKEN",placeholder:"openshell:resolve:env:WECHAT_BOT_TOKEN"}],policyPresets:[{name:"wechat",policyKeys:["wechat_bridge"],requiredAtCreate:true}],render:[{id:"wechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.openclaw-weixin",value:{enabled:true}}},{id:"wechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.openclaw-weixin",value:{enabled:true}}},{id:"wechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}","WEIXIN_BASE_URL={{wechatConfig.baseUrl}}","WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}"]},{id:"wechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.weixin",value:{enabled:true}}}],runtime:{openclaw:{channelName:"openclaw-weixin",visibility:{configKeys:["openclaw-weixin"],logPatterns:["wechat","openclaw-weixin"]},nodePreloads:[{module:"wechat-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing WeChat diagnostics (provider readiness + inference errors)",installedMessage:"[channels] WeChat diagnostics installed (NODE_OPTIONS updated)"}]},hermes:{envAliases:[{envKey:"WECHAT_BOT_TOKEN",targetEnvKey:"WEIXIN_TOKEN",match:"^openshell:resolve:env:v[0-9]+_WECHAT_BOT_TOKEN$",value:"openshell:resolve:env:WECHAT_BOT_TOKEN"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@tencent-weixin/openclaw-weixin@2.4.3",pin:true,integrity:"sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",tarballUrl:"https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz",runtimeLock:{cachePath:"/usr/local/share/nemoclaw/wechat-npm-cache",installCacheEnvKey:"NEMOCLAW_WECHAT_NPM_INSTALL_CACHE",lockFile:"/usr/local/lib/nemoclaw/wechat-runtime/package-lock.json",projectsRoot:"/sandbox/.openclaw/npm/projects",verifierPath:"/usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts",offline:true,legacyPeerDeps:true},required:true}],hooks:[{id:"wechat-host-qr",phase:"enroll",handler:"wechat.ilinkLogin",inputs:["allowedIds"],outputs:[{id:"botToken",kind:"secret",required:true},{id:"accountId",kind:"config",required:true},{id:"baseUrl",kind:"config"},{id:"userId",kind:"config"},{id:"allowedIds",kind:"config"}],onFailure:"skip-channel"},{id:"wechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedIds",kind:"config"}]},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId,phase:"post-agent-install",handler:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId,agents:["openclaw"],inputs:["wechatConfig.accountId","wechatConfig.baseUrl","wechatConfig.userId","credential.wechatBotToken.placeholder"],outputs:[{id:"openclawWeixinAccountsIndex",kind:"build-file",required:true},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId,kind:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.kind,required:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.required},{id:"openclawConfigPatch",kind:"build-file",required:true}],onFailure:"abort"},{id:"wechat-health-check",phase:"health-check",handler:"wechat.healthCheck",inputs:["wechatConfig.accountId"],onFailure:"abort"}]};var whatsappManifest={schemaVersion:1,id:"whatsapp",displayName:"WhatsApp",description:"WhatsApp Web messaging (QR pairing)",enrollmentHelp:"WhatsApp Web pairs via QR code scanned with your phone \u2014 no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.",enrollmentNotes:["After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics."],supportedAgents:["openclaw","hermes"],auth:{mode:"in-sandbox-qr"},inputs:[{id:"mode",kind:"config",required:false,envKey:"WHATSAPP_MODE",statePath:"whatsappConfig.mode",validValues:["self-chat","bot"],defaultValue:"self-chat",prompt:{label:"WhatsApp reply mode",help:"self-chat replies only to messages the paired account sends to itself. bot replies to other senders and stops replying to that self-chat: an unknown sender receives a pairing code you approve with `hermes pairing approve whatsapp `, unless you set WHATSAPP_ALLOWED_IDS to a fixed sender list before this command.",emptyValueMessage:"the sandbox replies only in your own self-chat"}},{id:"allowedIds",kind:"config",required:false,envKey:"WHATSAPP_ALLOWED_IDS",statePath:"allowedIds.whatsapp"}],credentials:[],policyPresets:["whatsapp"],render:[{id:"whatsapp-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.whatsapp",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false}}}}}},{id:"whatsapp-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.whatsapp",value:{enabled:true}}},{id:"whatsapp-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WHATSAPP_ENABLED=true","WHATSAPP_MODE={{whatsappConfig.mode}}","WHATSAPP_DM_POLICY={{whatsappConfig.dmPolicy}}","WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}"]},{id:"whatsapp-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.whatsapp",value:{enabled:true}}}],runtime:{openclaw:{channelName:"whatsapp",visibility:{configKeys:["whatsapp"],logPatterns:["whatsapp"]},nodePreloads:[{module:"whatsapp-qr-compact",injectInto:["connect"],optional:true,installMessage:"[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/whatsapp@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"},required:true}],hooks:[{id:"whatsapp-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"mode",kind:"config"}]},{id:"whatsapp-status-health",phase:"status",handler:"whatsapp.statusHealth",agents:["openclaw","hermes"],outputs:[{id:"channelHealth",kind:"status"}]}]};var BUILT_IN_CHANNEL_MANIFESTS=[telegramManifest,discordManifest,wechatManifest,slackManifest,whatsappManifest,teamsManifest,googlechatManifest];function createBuiltInChannelManifestRegistry(){return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS)}var EXACT_TEMPLATE_PATTERN=/^\{\{\s*([^}]+?)\s*\}\}$/;var TEMPLATE_REFERENCE_PATTERN=/\{\{\s*([^}]+?)\s*\}\}/g;function resolvedRenderTemplateReference(value){return{matched:true,value}}function resolveSandboxNameTemplate(value,sandboxName){return value.replaceAll("{sandboxName}",sandboxName)}function resolveRenderTemplatesInValue(value,context){if(typeof value==="string")return resolveRenderTemplatesInString(value,context);if(Array.isArray(value)){if(value.length===0)return value;const resolved=value.map(entry=>resolveRenderTemplatesInValue(entry,context)).filter(entry=>entry!==void 0);return resolved.length>0?resolved:void 0}if(value&&typeof value==="object"){const sourceEntries=Object.entries(value);if(sourceEntries.length===0)return value;const entries=sourceEntries.map(([key,entry])=>[key,resolveRenderTemplatesInValue(entry,context)]).filter(entry=>entry[1]!==void 0);return entries.length>0?Object.fromEntries(entries):void 0}return value}function isTruthyRenderTemplate(value,context){if(!value)return true;const resolved=resolveRenderTemplatesInString(value,context);if(resolved===void 0||resolved===null||resolved===false)return false;if(Array.isArray(resolved))return resolved.length>0;if(typeof resolved==="object")return Object.keys(resolved).length>0;if(typeof resolved==="string")return resolved.trim().length>0;return true}function resolveRenderTemplatesInString(value,context){const exact=value.match(EXACT_TEMPLATE_PATTERN);if(exact?.[1])return resolveTemplateReference(exact[1].trim(),context);let omitted=false;const resolved=value.replace(TEMPLATE_REFERENCE_PATTERN,(match,reference)=>{const replacement=resolveTemplateReference(reference.trim(),context);if(replacement===void 0||replacement===null){omitted=true;return""}if(Array.isArray(replacement))return replacement.map(String).join(",");if(typeof replacement==="object")return JSON.stringify(replacement);return String(replacement)});return omitted?void 0:resolved}function resolveTemplateReference(reference,context){const resolved=context.referenceResolver?.(reference,context);return resolved?.matched?resolved.value:"{{"+reference+"}}"}function allowedIds(context,channel){return parseList(stateValue(context,`allowedIds.${channel}`))}function stateValue(context,path5){const stateInput=context.inputs.find(input=>input.statePath===path5);if(stateInput?.value!==void 0)return stateInput.value;const inputId=path5.split(".").at(-1);return context.inputs.find(input=>input.inputId===inputId)?.value}function parseList(value){if(Array.isArray(value))return unique(value.map(String).map(cleanString).filter(Boolean));const text=cleanString(value);if(!text)return[];return unique(text.split(",").map(cleanString).filter(Boolean))}function parseBoolean(value){if(typeof value==="boolean")return value;const text=cleanString(value)?.toLowerCase();if(text==="1"||text==="true"||text==="yes"||text==="on")return true;if(text==="0"||text==="false"||text==="no"||text==="off")return false;return void 0}function nonEmptyString(value){return cleanString(value)||void 0}function cleanString(value){const text=String(value??"");if(/[\r\n]/.test(text)){throw new Error("Messaging template values must not contain line breaks.")}return text.trim()}function nonEmptyArray(values){return values.length>0?[...values]:void 0}function nonEmptyCsv(values){return values.length>0?values.join(","):void 0}function nonEmptyObject(value){return Object.keys(value).length>0?value:void 0}function unique(values){return[...new Set(values)]}var resolveDiscordTemplateReference=(reference,context)=>{if(reference==="discordProxyUrl")return resolvedRenderTemplateReference(void 0);switch(reference){case"discord.guilds":return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context)));case"discord.hasGuilds":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0);case"discord.guildIds.csv":return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context))));case"discord.allowedUsers.values":return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context)));case"discord.allowedUsers.csv":return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context)));case"discord.allowedUsers.dmPolicy":return resolvedRenderTemplateReference(discordAllowedUsers(context).length>0?"allowlist":void 0);case"discord.allowAllUsers":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0&&discordAllowedUsers(context).length===0?true:void 0);case"discord.requireMention":return resolvedRenderTemplateReference(discordRequireMention(context));default:return void 0}};function discordGuilds(context){const serverIds=parseList(stateValue(context,"discordGuilds.serverId"));if(serverIds.length===0)return{};const users=parseList(stateValue(context,"discordGuilds.userIds"));const requireMention=parseBoolean(stateValue(context,"discordGuilds.requireMention"))??true;return Object.fromEntries(serverIds.map(serverId=>[serverId,{requireMention,...users.length>0?{users}:{}}]))}function discordAllowedUsers(context){const users=new Set(allowedIds(context,"discord"));for(const guild of Object.values(discordGuilds(context))){for(const user of guild.users??[])users.add(String(user))}return[...users]}function discordRequireMention(context){for(const guild of Object.values(discordGuilds(context))){if(typeof guild.requireMention==="boolean")return guild.requireMention}return true}var DEFAULT_AUDIENCE_TYPE="app-url";var APP_PRINCIPAL_DISCOVERY_SENTINEL="000000000000000000000";var resolveGooglechatTemplateReference=(reference,context)=>{switch(reference){case"googlechatConfig.audienceType":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audienceType"))??DEFAULT_AUDIENCE_TYPE);case"googlechatConfig.audience":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audience")));case"googlechatConfig.appPrincipal":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.appPrincipal"))??APP_PRINCIPAL_DISCOVERY_SENTINEL);case"googlechatConfig.projectId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.projectId")));case"googlechatConfig.subscriptionName":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.subscriptionName")));default:break}const allowReference=reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy|csv)$/);if(!allowReference?.[1])return void 0;const ids=allowedIds(context,"googlechat");switch(allowReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"csv":return resolvedRenderTemplateReference(ids.length>0?ids.join(","):void 0);default:return void 0}};var resolveSlackTemplateReference=(reference,context)=>{if(reference==="slackConfig.allowedChannels.csv"){return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context)))}const allowedIdsReference=reference.match(/^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"slack");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"groupPolicy":return resolvedRenderTemplateReference(ids.length>0||slackAllowedChannels(context).length>0?"allowlist":void 0);case"channels":return resolvedRenderTemplateReference(slackChannelConfig(context,ids));default:return void 0}};function slackChannelConfig(context,users){const allowedChannels=slackAllowedChannels(context);const entry={enabled:true,requireMention:true,...users.length>0?{users:[...users]}:{}};if(allowedChannels.length>0){return Object.fromEntries(allowedChannels.map(channelId=>[channelId,{...entry}]))}return users.length>0?{"*":entry}:void 0}function slackAllowedChannels(context){return parseList(stateValue(context,"slackConfig.allowedChannels"))}var DEFAULT_TEAMS_WEBHOOK_PORT=3978;var resolveTeamsTemplateReference=(reference,context)=>{switch(reference){case"teamsConfig.appId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.appId")));case"teamsConfig.tenantId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.tenantId")));case"teamsConfig.webhookPort":return resolvedRenderTemplateReference(teamsWebhookPort(context));case"teamsConfig.requireMention":return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"teamsConfig.requireMention")));default:break}const allowedIdsReference=reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"teams");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function teamsWebhookPort(context){const raw=nonEmptyString(stateValue(context,"teamsConfig.webhookPort"));if(!raw)return DEFAULT_TEAMS_WEBHOOK_PORT;const port=Number(raw);if(!Number.isInteger(port)||port<1||port>65535){throw new Error("Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.")}return port}var DEFAULT_PROXY_HOST="10.200.0.1";var DEFAULT_PROXY_PORT="3128";var DEFAULT_TELEGRAM_GROUP_POLICY="open";var TELEGRAM_GROUP_POLICIES=new Set(["open","allowlist","disabled"]);var resolveTelegramTemplateReference=(reference,context)=>{if(reference==="proxyUrl")return resolvedRenderTemplateReference(proxyUrl(context.env));if(reference==="telegramConfig.groupPolicy"){return resolvedRenderTemplateReference(telegramGroupPolicy(context))}if(reference==="telegramConfig.openclawGroups"){return resolvedRenderTemplateReference(telegramOpenClawGroups(context))}if(reference==="telegramConfig.requireMention"){return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"telegramConfig.requireMention")))}const allowedIdsReference=reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"telegram");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function proxyUrl(env){const host=nonEmptyString(env?.NEMOCLAW_PROXY_HOST)??DEFAULT_PROXY_HOST;const port=nonEmptyString(env?.NEMOCLAW_PROXY_PORT)??DEFAULT_PROXY_PORT;return`http://${host}:${port}`}function telegramGroupPolicy(context){const value=nonEmptyString(stateValue(context,"telegramConfig.groupPolicy"));return value&&TELEGRAM_GROUP_POLICIES.has(value)?value:DEFAULT_TELEGRAM_GROUP_POLICY}function telegramOpenClawGroups(context){if(telegramGroupPolicy(context)!=="open")return void 0;const requireMention=parseBoolean(stateValue(context,"telegramConfig.requireMention"));return requireMention===true?{"*":{requireMention:true}}:void 0}var WECHAT_ILINK_HOSTS=new Set(["ilinkai.weixin.qq.com","ilinkai.wechat.com"]);var WECHAT_ILINK_IDC_HOST_PATTERN=/^idc-[0-9]+[.]weixin[.]qq[.]com$/;function normalizeWechatIlinkBaseUrl(value){const raw=String(value??"");if(/[\r\n]/.test(raw)){throw new Error("WeChat baseUrl must not contain line breaks.")}const text=raw.trim();if(!text)return void 0;let url;try{url=new URL(text)}catch{throw new Error("WeChat baseUrl must be a valid URL.")}if(url.protocol!=="https:"){throw new Error("WeChat baseUrl must use HTTPS.")}if(url.username||url.password){throw new Error("WeChat baseUrl must not include credentials.")}if(!isWechatIlinkHost(url.hostname)){throw new Error("WeChat baseUrl must use an expected iLink host.")}if(url.pathname&&url.pathname!=="/"||url.search||url.hash){throw new Error("WeChat baseUrl must be an iLink origin URL.")}return url.origin}function isWechatIlinkHost(hostname){const normalized=hostname.toLowerCase();return WECHAT_ILINK_HOSTS.has(normalized)||WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized)}var resolveWechatTemplateReference=(reference,context)=>{const wechatConfig=reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/);if(wechatConfig?.[1]){if(wechatConfig[1]==="baseUrl"){return resolvedRenderTemplateReference(normalizeWechatIlinkBaseUrl(stateValue(context,"wechatConfig.baseUrl")))}return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"wechatConfig."+wechatConfig[1])))}const allowedIdsReference=reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=wechatAllowedIds(context);switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function wechatAllowedIds(context){const ids=allowedIds(context,"wechat");const userId=nonEmptyString(stateValue(context,"wechatConfig.userId"));return userId&&!ids.includes(userId)?[userId,...ids]:ids}var DEFAULT_WHATSAPP_MODE="self-chat";var BOT_WHATSAPP_MODE="bot";var WHATSAPP_MODES=new Set([DEFAULT_WHATSAPP_MODE,BOT_WHATSAPP_MODE]);var resolveWhatsappTemplateReference=(reference,context)=>{if(reference==="whatsappConfig.mode"){return resolvedRenderTemplateReference(whatsappMode(context))}if(reference==="whatsappConfig.dmPolicy"){return resolvedRenderTemplateReference(whatsappDmPolicy(context))}const allowedIdsReference=reference.match(/^allowedIds[.]whatsapp[.](values|csv)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"whatsapp");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));default:return void 0}};function whatsappMode(context){const value=nonEmptyString(stateValue(context,"whatsappConfig.mode"));return value&&WHATSAPP_MODES.has(value)?value:DEFAULT_WHATSAPP_MODE}function whatsappDmPolicy(context){if(whatsappMode(context)!==BOT_WHATSAPP_MODE)return void 0;return allowedIds(context,"whatsapp").length>0?"allowlist":"pairing"}var BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS=[resolveTelegramTemplateReference,resolveDiscordTemplateReference,resolveWechatTemplateReference,resolveSlackTemplateReference,resolveWhatsappTemplateReference,resolveTeamsTemplateReference,resolveGooglechatTemplateReference];function createBuiltInRenderTemplateResolver(){return(reference,context)=>{for(const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS){const resolved=resolver(reference,context);if(resolved)return resolved}return void 0}}var import_node_crypto=__toESM(require("node:crypto"));function hashCredential(value){const normalized=String(value??"").trim();if(!normalized)return null;return import_node_crypto.default.createHash("sha256").update(normalized).digest("hex")}function planCredentialBindings(manifest,context,inputs,environment=process.env){return manifest.credentials.map(credential=>{const sourceInput=inputs.find(input=>input.inputId===credential.sourceInput);const credentialAvailable=sourceInput?.credentialAvailable===true||context.credentialAvailability?.[credential.id]===true||context.credentialAvailability?.[`${manifest.id}.${credential.id}`]===true;const envKey=sourceInput?.sourceEnv??credential.providerEnvKey;const credentialHash=credentialAvailable?hashCredential(environment[envKey])??void 0:void 0;return{channelId:manifest.id,credentialId:credential.id,sourceInput:credential.sourceInput,providerName:resolveSandboxNameTemplate(credential.providerName,context.sandboxName),providerEnvKey:credential.providerEnvKey,placeholder:credential.placeholder,credentialAvailable,...credentialHash!==void 0?{credentialHash}:{}}})}function planHostForward(manifest,inputs,active,referenceResolver,environment=process.env){if(!active||!manifest.hostForward)return void 0;const context={inputs,env:environment,referenceResolver};if(!isTruthyRenderTemplate(manifest.hostForward.when,context))return void 0;const portValue=resolveRenderTemplatesInValue(manifest.hostForward.port,context);const port=normalizeForwardPort(manifest.id,portValue);return{channelId:manifest.id,port,label:manifest.hostForward.label}}function normalizeForwardPort(channelId,value){const port=typeof value==="number"?value:Number(String(value??"").trim());if(!Number.isInteger(port)||port<1||port>65535){throw new Error(`Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`)}return port}var OPENSHELL_ENV_PLACEHOLDER_PREFIX="openshell:resolve:env:";var OPENSHELL_ALIAS_PLACEHOLDER_RE=/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/;function normalizeProviderPlaceholderForEnvKey(value,envKey){if(value.startsWith(OPENSHELL_ENV_PLACEHOLDER_PREFIX)){return placeholderSuffixMatchesEnvKey(value.slice(OPENSHELL_ENV_PLACEHOLDER_PREFIX.length),envKey)?`${OPENSHELL_ENV_PLACEHOLDER_PREFIX}${envKey}`:null}const aliasMatch=value.match(OPENSHELL_ALIAS_PLACEHOLDER_RE);if(!aliasMatch||!placeholderSuffixMatchesEnvKey(aliasMatch[1],envKey)){return null}return value.replace(/-OPENSHELL-RESOLVE-ENV-.+$/,`-OPENSHELL-RESOLVE-ENV-${envKey}`)}function placeholderSuffixMatchesEnvKey(suffix,envKey){if(suffix===envKey)return true;const revisionMatch=suffix.match(/^v[0-9]+_(.+)$/);return revisionMatch?.[1]===envKey}function hasFullPersistedCredentialBindingShape(binding){return typeof binding.channelId==="string"&&typeof binding.credentialId==="string"&&typeof binding.sourceInput==="string"&&typeof binding.providerName==="string"&&typeof binding.providerEnvKey==="string"&&typeof binding.placeholder==="string"&&typeof binding.credentialAvailable==="boolean"}function normalizeFullPersistedCredentialBindings(bindings){return bindings.map(binding=>({channelId:binding.channelId,credentialId:binding.credentialId,sourceInput:binding.sourceInput,providerName:binding.providerName,providerEnvKey:binding.providerEnvKey,placeholder:normalizeProviderPlaceholderForEnvKey(binding.placeholder,binding.providerEnvKey)??binding.placeholder,credentialAvailable:binding.credentialAvailable===true,...typeof binding.credentialHash==="string"?{credentialHash:binding.credentialHash}:{}}))}function normalizePersistedAgentCredentialPlaceholders(render,credentialBindings){const credentialEnvKeys=new Set(credentialBindings.map(binding=>binding.providerEnvKey).filter(Boolean));if(credentialEnvKeys.size===0)return[...render];return render.map(entry=>{if(entry.kind!=="env-lines")return entry;return{...entry,lines:entry.lines.map(line=>normalizeCredentialEnvLine(line,credentialEnvKeys))}})}function normalizeCredentialEnvLine(line,credentialEnvKeys){const index=line.indexOf("=");if(index<=0)return line;const envKey=line.slice(0,index).trim();if(!credentialEnvKeys.has(envKey))return line;const value=line.slice(index+1);const normalized=normalizeProviderPlaceholderForEnvKey(value,envKey);return normalized?`${envKey}=${normalized}`:line}function normalizePersistedSandboxMessagingPlanShape(plan,environment=process.env){const manifestRegistry=createBuiltInChannelManifestRegistry();const disabledChannels=plan.disabledChannels.filter(channelId=>typeof channelId==="string");const disabledSet=new Set(disabledChannels);const channels=plan.channels.map(channel=>normalizePersistedChannel(channel,disabledSet,manifestRegistry.get(channel.channelId),environment));const credentialBindings=normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment);const normalizedPlan={...plan,channels,disabledChannels,credentialBindings,networkPolicy:plan.networkPolicy&&Array.isArray(plan.networkPolicy.entries)?plan.networkPolicy:{presets:[],entries:[]},agentRender:normalizePersistedAgentCredentialPlaceholders(Array.isArray(plan.agentRender)?[...plan.agentRender]:[],credentialBindings),buildSteps:Array.isArray(plan.buildSteps)?[...plan.buildSteps]:[],...plan.runtimeSetup!==void 0?{runtimeSetup:normalizeRuntimeSetup(plan.runtimeSetup)}:{},stateUpdates:Array.isArray(plan.stateUpdates)?[...plan.stateUpdates]:[],healthChecks:Array.isArray(plan.healthChecks)?[...plan.healthChecks]:[]};return normalizedPlan}function normalizePersistedChannel(channel,disabledSet,manifest,environment){const disabled=channel.disabled??disabledSet.has(channel.channelId);const configured=channel.configured??true;const hasFullShape=hasFullChannelShape(channel);const inputs=hasFullShape?normalizeFullInputs(channel.channelId,channel.inputs??[]):normalizePersistedInputs(channel,manifest);const active=channel.active??(configured&&!disabled&&requiredInputsAvailable(manifest,inputs));const hostForward=manifest?planHostForward(manifest,inputs,active&&!disabled,createBuiltInRenderTemplateResolver(),environment):void 0;return{channelId:channel.channelId,displayName:channel.displayName??manifest?.displayName??channel.channelId,authMode:channel.authMode??manifest?.auth.mode??"none",active,selected:channel.selected??configured,configured,disabled,inputs,...hostForward?{hostForward}:{},hooks:Array.isArray(channel.hooks)?[...channel.hooks]:[]}}function normalizePersistedInputs(channel,manifest){const persistedById=new Map((channel.inputs??[]).filter(input=>typeof input.inputId==="string").map(input=>[input.inputId,input]));const fromManifest=(manifest?.inputs??[]).map(input=>inputReferenceFromManifest(channel.channelId,input,persistedById.get(input.id)));const manifestInputIds=new Set((manifest?.inputs??[]).map(input=>input.id));const unknownInputs=[...persistedById.values()].flatMap(input=>{if(!input.inputId||manifestInputIds.has(input.inputId))return[];return[normalizeUnknownInput(channel.channelId,input)]});return[...fromManifest,...unknownInputs]}function normalizeFullInputs(channelId,inputs){return inputs.filter(input=>typeof input.inputId==="string").map(input=>({channelId:typeof input.channelId==="string"?input.channelId:channelId,inputId:input.inputId,kind:input.kind==="secret"||input.kind==="config"?input.kind:"config",required:typeof input.required==="boolean"?input.required:false,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}))}function inputReferenceFromManifest(channelId,input,persisted){return{channelId,inputId:input.id,kind:input.kind,required:input.required,...input.envKey?{sourceEnv:input.envKey}:{},...input.kind==="config"&&input.statePath?{statePath:input.statePath}:{},...persisted?.credentialAvailable!==void 0?{credentialAvailable:persisted.credentialAvailable}:{},...persisted?.value!==void 0?{value:persisted.value}:{}}}function normalizeUnknownInput(channelId,input){const kind=input.kind==="secret"||input.kind==="config"?input.kind:"config";return{channelId,inputId:input.inputId,kind,required:input.required===true,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}}function requiredInputsAvailable(manifest,inputs){if(!manifest)return true;return manifest.inputs.every(manifestInput=>{if(!manifestInput.required)return true;const input=inputs.find(entry=>entry.inputId===manifestInput.id);if(!input)return false;if(input.kind==="secret")return input.credentialAvailable===true;if(input.value===void 0)return false;return typeof input.value==="string"?input.value.trim().length>0:true})}function normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment){const persisted=plan.credentialBindings??[];if(Array.isArray(plan.credentialBindings)&&plan.channels.every(hasFullChannelShape)&&persisted.every(hasFullPersistedCredentialBindingShape)){return normalizeFullPersistedCredentialBindings(persisted)}const manifests=channels.flatMap(channel=>{const manifest=manifestRegistry.get(channel.channelId);return manifest?[manifest]:[]});const planForBindings={...plan,channels,credentialBindings:[],networkPolicy:{presets:[],entries:[]},agentRender:[],buildSteps:[],runtimeSetup:{nodePreloads:[],envAliases:[],secretScans:[]},stateUpdates:[],healthChecks:[]};const generated=credentialBindingsFromManifests(planForBindings,manifests,new Map(channels.map(channel=>[channel.channelId,channel.inputs])),environment);return generated.map(binding=>overlayPersistedCredentialBinding(binding,persisted))}function credentialBindingsFromManifests(plan,manifests,inputRegistry,environment){const context=compilerContext(plan);return manifests.flatMap(manifest=>planCredentialBindings(manifest,context,inputRegistry.get(manifest.id)??[],environment).map(binding=>overlayPersistedCredentialBinding(binding,plan.credentialBindings)))}function overlayPersistedCredentialBinding(binding,persisted){const match=persisted.find(candidate=>credentialBindingMatches(binding,candidate));if(!match)return binding;return{...binding,credentialAvailable:typeof match.credentialAvailable==="boolean"?match.credentialAvailable:binding.credentialAvailable,...typeof match.credentialHash==="string"&&match.credentialHash.length>0?{credentialHash:match.credentialHash}:binding.credentialHash?{credentialHash:binding.credentialHash}:{}}}function credentialBindingMatches(binding,candidate){if(candidate.channelId&&candidate.channelId!==binding.channelId)return false;if(candidate.providerEnvKey&&candidate.providerEnvKey===binding.providerEnvKey)return true;if(candidate.credentialId&&candidate.credentialId===binding.credentialId)return true;if(candidate.sourceInput&&candidate.sourceInput===binding.sourceInput)return true;return false}function hasFullChannelShape(channel){return typeof channel.displayName==="string"&&typeof channel.authMode==="string"&&typeof channel.active==="boolean"&&typeof channel.selected==="boolean"&&typeof channel.configured==="boolean"&&typeof channel.disabled==="boolean"&&Array.isArray(channel.inputs)}function normalizeRuntimeSetup(setup){return{nodePreloads:Array.isArray(setup?.nodePreloads)?[...setup.nodePreloads]:[],envAliases:Array.isArray(setup?.envAliases)?[...setup.envAliases]:[],secretScans:Array.isArray(setup?.secretScans)?[...setup.secretScans]:[]}}function compilerContext(plan){return{sandboxName:plan.sandboxName,agent:plan.agent,workflow:plan.workflow,isInteractive:false,configuredChannels:plan.channels.map(channel=>channel.channelId),disabledChannels:plan.disabledChannels,credentialAvailability:credentialAvailabilityFromPlan(plan)}}function credentialAvailabilityFromPlan(plan){const availability={};for(const channel of plan.channels){for(const input of channel.inputs){if(input.kind!=="secret"||input.credentialAvailable!==true)continue;availability[`${channel.channelId}.${input.inputId}`]=true;if(input.sourceEnv)availability[input.sourceEnv]=true}}for(const credential of plan.credentialBindings){if(!credential.credentialAvailable)continue;availability[credential.credentialId]=true;availability[`${credential.channelId}.${credential.credentialId}`]=true;availability[`${credential.channelId}.${credential.sourceInput}`]=true;availability[credential.providerEnvKey]=true}return availability}function normalizeMessagingChannelId(channelId){return channelId.trim().toLowerCase()}function enabledPlanChannels(plan){const disabled=new Set((plan.disabledChannels??[]).map(normalizeMessagingChannelId).filter(Boolean));return plan.channels.filter(channel=>{const channelId=normalizeMessagingChannelId(channel.channelId);return channelId.length>0&&channel.active&&!channel.disabled&&!disabled.has(channelId)})}function selectActiveMessagingChannelIds(plan){const seen=new Set;const channels=[];for(const item of enabledPlanChannels(plan)){const channel=normalizeMessagingChannelId(item.channelId);if(!channel||seen.has(channel))continue;seen.add(channel);channels.push(channel)}return channels}function selectEnabledMessagingAgentRender(plan){const active=new Set(selectActiveMessagingChannelIds(plan));return plan.agentRender.filter(render=>render.agent===plan.agent&&active.has(normalizeMessagingChannelId(render.channelId)))}function selectEnabledPostAgentInstallBuildFiles(plan){const active=new Set(selectActiveMessagingChannelIds(plan));const channels=enabledPlanChannels(plan);return plan.buildSteps.filter(step=>{const channelId=normalizeMessagingChannelId(step.channelId);if(!active.has(channelId)||step.kind!=="build-file")return false;if(!step.hookId)return true;const matchingChannels=channels.filter(channel=>normalizeMessagingChannelId(channel.channelId)===channelId);if(matchingChannels.length!==1)return false;const matchedHook=matchingChannels[0]?.hooks?.find(hook=>hook.id===step.hookId);return matchedHook!==void 0&&matchedHook.phase==="post-agent-install"})}function parseSandboxMessagingPlan(value,options={}){if(!isObjectRecord(value)||value.schemaVersion!==1||typeof value.sandboxName!=="string"||typeof value.agent!=="string"||typeof value.workflow!=="string"||!Array.isArray(value.channels)||!Array.isArray(value.disabledChannels)||!isOptionalObjectArray(value,"credentialBindings")||Object.hasOwn(value,"networkPolicy")&&!isObjectRecord(value.networkPolicy)||!isOptionalObjectArray(value,"agentRender")||!isOptionalObjectArray(value,"buildSteps")||!isRuntimeSetup(value.runtimeSetup)||!isOptionalObjectArray(value,"stateUpdates")||!isOptionalObjectArray(value,"healthChecks")){return null}if(options.sandboxName&&value.sandboxName!==options.sandboxName)return null;if(options.agent&&value.agent!==options.agent)return null;const supported=Array.isArray(options.supportedChannelIds)?new Set(options.supportedChannelIds):null;const normalizedChannelIds=new Set;for(const channel of value.channels){if(!isObjectRecord(channel)||typeof channel.channelId!=="string")return null;const normalizedChannelId=normalizeMessagingChannelId(channel.channelId);if(!normalizedChannelId||normalizedChannelId!==channel.channelId||normalizedChannelIds.has(normalizedChannelId)){return null}if(Object.hasOwn(channel,"configured")&&typeof channel.configured!=="boolean"){return null}if(Object.hasOwn(channel,"active")&&typeof channel.active!=="boolean")return null;if(Object.hasOwn(channel,"disabled")&&typeof channel.disabled!=="boolean")return null;if(Object.hasOwn(channel,"inputs")&&!Array.isArray(channel.inputs))return null;if(Object.hasOwn(channel,"hostForward")&&!isHostForward(channel.hostForward))return null;if(Object.hasOwn(channel,"hooks")&&!Array.isArray(channel.hooks))return null;if(Array.isArray(channel.inputs)&&channel.inputs.some(input=>!isObjectRecord(input)||typeof input.inputId!=="string"||Object.hasOwn(input,"channelId")&&input.channelId!==normalizedChannelId)){return null}if(Array.isArray(channel.hooks)&&channel.hooks.some(hook=>!isObjectRecord(hook)||Object.hasOwn(hook,"channelId")&&hook.channelId!==normalizedChannelId)){return null}if(Object.hasOwn(channel,"hostForward")&&isObjectRecord(channel.hostForward)&&channel.hostForward.channelId!==normalizedChannelId){return null}if(supported&&!supported.has(channel.channelId))return null;normalizedChannelIds.add(normalizedChannelId)}if(!value.disabledChannels.every(isCanonicalMessagingChannelId))return null;const disabledChannelIds=new Set(value.disabledChannels);if(disabledChannelIds.size!==value.disabledChannels.length||[...disabledChannelIds].some(channelId=>!normalizedChannelIds.has(channelId))||value.channels.some(channel=>isObjectRecord(channel)&&channel.disabled===true!==disabledChannelIds.has(String(channel.channelId)))){return null}if(!hasCanonicalChannelReferences(value.credentialBindings)||!hasMatchingAgentRenderEntries(value.agentRender,value.agent)||!hasCanonicalChannelReferences(value.agentRender)||!hasCanonicalChannelReferences(value.buildSteps)||!hasCanonicalChannelReferences(value.stateUpdates)||!hasCanonicalChannelReferences(value.healthChecks)||!hasCanonicalNetworkPolicyReferences(value.networkPolicy)||!hasCanonicalRuntimeSetupReferences(value.runtimeSetup)){return null}return cloneSandboxMessagingPlan(normalizePersistedSandboxMessagingPlanShape(value,options.environment))}function hasMatchingAgentRenderEntries(value,agent){return!Array.isArray(value)||value.every(render=>isObjectRecord(render)&&render.agent===agent)}function hasCanonicalNetworkPolicyReferences(value){if(!isObjectRecord(value)||!Object.hasOwn(value,"entries"))return true;return hasCanonicalChannelReferences(value.entries)}function cloneSandboxMessagingPlan(plan){return JSON.parse(JSON.stringify(plan))}function isOptionalObjectArray(value,key){if(!Object.hasOwn(value,key))return true;const entries=value[key];return Array.isArray(entries)&&entries.every(isObjectRecord)}function isHostForward(value){return isObjectRecord(value)&&typeof value.channelId==="string"&&typeof value.port==="number"&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&typeof value.label==="string"}function isRuntimeSetup(value){if(value===void 0)return true;return isObjectRecord(value)&&Array.isArray(value.nodePreloads)&&Array.isArray(value.envAliases)&&Array.isArray(value.secretScans)&&value.nodePreloads.every(isObjectRecord)&&value.envAliases.every(isObjectRecord)&&value.secretScans.every(isObjectRecord)}function isCanonicalMessagingChannelId(value){return typeof value==="string"&&value.length>0&&normalizeMessagingChannelId(value)===value}function hasCanonicalChannelReferences(value){return value===void 0||Array.isArray(value)&&value.every(entry=>isObjectRecord(entry)&&isCanonicalMessagingChannelId(entry.channelId))}function hasCanonicalRuntimeSetupReferences(value){if(value===void 0)return true;if(!isObjectRecord(value))return false;return["nodePreloads","envAliases","secretScans"].every(field=>hasCanonicalChannelReferences(value[field]))}var import_node_buffer=require("node:buffer");var import_node_crypto2=require("node:crypto");var import_node_util=require("node:util");function listMessagingCredentialEnvAssignments(options={}){return selectManifests(options).flatMap(manifest=>{const credentialsByTemplate=new Map(manifest.credentials.map(credential=>[`{{credential.${credential.id}.placeholder}}`,credential]));const renderedAssignments=manifest.render.flatMap(render=>{if(options.agent&&render.agent!==options.agent)return[];if(render.kind!=="env-lines")return[];return render.lines.flatMap(line=>{const separator=line.indexOf("=");if(separator<=0)return[];const credential=credentialsByTemplate.get(line.slice(separator+1));if(!credential)return[];return[{channelId:manifest.id,agent:render.agent,sourceEnvKey:credential.providerEnvKey,targetEnvKey:line.slice(0,separator),placeholder:credential.placeholder}]})});const runtimeAssignments=["openclaw","hermes"].flatMap(agent=>{if(options.agent&&agent!==options.agent)return[];if(!manifest.supportedAgents.includes(agent))return[];return(manifest.runtime?.[agent]?.envAliases??[]).flatMap(alias=>{if(!alias.targetEnvKey)return[];const credential=manifest.credentials.find(candidate=>candidate.providerEnvKey===alias.envKey);if(!credential)return[];return[{channelId:manifest.id,agent,sourceEnvKey:alias.envKey,targetEnvKey:alias.targetEnvKey,placeholder:credential.placeholder}]})});return[...renderedAssignments,...runtimeAssignments]})}function selectManifests(options){const manifests=options.manifests??BUILT_IN_CHANNEL_MANIFESTS;const agent=options.agent;const selected=agent?manifests.filter(manifest=>manifest.supportedAgents.includes(agent)):manifests;return[...selected]}function authorizeMessagingManagedStartupFields(entry,section){if(section==="agentRender")return authorizeTeamsOpenClawWebhookField(entry);if(!isPlainDataObject3(entry))return[];const contract=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT;if(ownDataPropertyValue3(entry,"channelId")!==contract.channelId||ownDataPropertyValue3(entry,"hookId")!==contract.planHookId||ownDataPropertyValue3(entry,"handler")!==contract.handlerId||ownDataPropertyValue3(entry,"outputId")!==contract.outputId||ownDataPropertyValue3(entry,"kind")!==contract.kind||ownDataPropertyValue3(entry,"required")!==contract.required){return[]}return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue3(entry,"value")).map(authorization=>({...authorization,path:["value",...authorization.path]}))}function isPlainDataObject3(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue3(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}var DCODE_UPSTREAM_PROVIDER_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;function isValidDcodeUpstreamProvider(value){return DCODE_UPSTREAM_PROVIDER_RE.test(value)}var MANAGED_STARTUP_PROFILE_SCHEMA_VERSION=1;var MANAGED_STARTUP_PROFILE_MAX_BYTES=64*1024;var MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES=Math.ceil(MANAGED_STARTUP_PROFILE_MAX_BYTES/3)*4;var MAX_IDENTIFIER_BYTES=256;var MAX_MODEL_BYTES=1024;var MAX_URL_BYTES=2048;var MAX_LIST_ITEMS=128;var MAX_JSON_NODES=4096;var MAX_JSON_DEPTH=32;var MAX_TUNING_INTEGER=1e9;var MIN_HERMES_CONTEXT_WINDOW=64e3;var SHA256_RE=/^[a-f0-9]{64}$/;var CONTROL_CHARACTER_RE=/[\u0000-\u001f\u007f-\u009f]/u;var BASE64URL_RE=/^[A-Za-z0-9_-]+$/;var RAW_CA_PEM_RE=/-----BEGIN (?:TRUSTED )?CERTIFICATE-----/iu;var RAW_CA_PEM_BASE64_RE=/^LS0tLS1CRUdJTi(?:BDRVJUSUZJQ0FURS0tLS0t|BUlVTVEVEIENFUlRJRklDQVRFLS0tLS0)/u;var RAW_CA_DER_BASE64_RE=/^MII[A-Za-z0-9+/=\r\n]{253,}$/u;var RAW_CA_DATA_URI_RE=/data:application\/(?:pkix-cert|x-x509-ca-cert);base64,MII[A-Za-z0-9+/=]{253,}/iu;var URL_CANDIDATE_RE=/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;var UTF8_DECODER=new import_node_util.TextDecoder("utf-8",{fatal:true});var CREDENTIAL_SHAPED_NAME_PATTERN=/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/iu;var CREDENTIAL_COMPOUND_NAME_PATTERN=/^(?:access|refresh|client|bearer|auth|api|private|signing|session|bot|app|resolved)(?:token|key|secret|password)$/iu;var CREDENTIAL_CAMEL_SUFFIX_PATTERN=/(?:apiKey|accessKey|secretKey|authToken|refreshToken|accessToken|clientSecret|privateKey|passcode|password|passwd|passphrase|bearerToken|botToken|appToken|sessionToken|signingKey|secretPublicKey|personalAccessToken|connectionString|webhookUrl)$/iu;var CREDENTIAL_CAMEL_BOUNDARY_PATTERN=/[a-z0-9](?:Token|Key|Secret|Password|Passphrase|Pat)$/u;var CREDENTIAL_ENV_NAME_PATTERN=/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/u;var CREDENTIAL_HEADER_NAME_PATTERN=/^(?:authorization|proxy-authorization|cookie|set-cookie|.+-(?:key|token|secret|password|passphrase|credential|auth)s?)$/iu;var PUBLIC_KEY_NAME_PATTERN=/^public[-_]?keys?$/iu;var PASS_CREDENTIAL_NAME_PATTERN=/(?:^|[-_])pass(?:wd)?$/iu;var NON_SECRET_KEY_METADATA_NAMES=new Set(["envKey","installCacheEnvKey","providerEnvKey","stateKey","targetEnvKey"]);var MESSAGING_CREDENTIAL_PLACEHOLDER_RE=/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;var MESSAGING_CREDENTIAL_ENV_ALIASES=new Set(listMessagingCredentialEnvAssignments().filter(({sourceEnvKey,targetEnvKey})=>sourceEnvKey!==targetEnvKey).map(({agent,sourceEnvKey,targetEnvKey})=>`${agent}\0${sourceEnvKey}\0${targetEnvKey}`));var MESSAGING_CREDENTIAL_RUNTIME_ENV_ALIASES=new Set(listMessagingCredentialEnvAssignments().filter(({sourceEnvKey,targetEnvKey})=>sourceEnvKey!==targetEnvKey).map(({agent,channelId,sourceEnvKey,targetEnvKey})=>`${agent}\0${channelId}\0${sourceEnvKey}\0${targetEnvKey}`));var JSON_ARRAY_INDEX_SEGMENT_RE=/^\[(?:0|[1-9][0-9]*)\]$/u;var SECRET_VALUE_PATTERNS=[/nvapi-[A-Za-z0-9_-]{10,}/u,/nvcf-[A-Za-z0-9_-]{10,}/u,/ghp_[A-Za-z0-9_-]{10,}/u,/github_pat_[A-Za-z0-9_]{30,}/u,/sk-(?:proj-|ant-)?[A-Za-z0-9_-]{10,}/u,/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/u,/A(?:K|S)IA[A-Z0-9]{16}/u,/hf_[A-Za-z0-9]{10,}/u,/glpat-[A-Za-z0-9_-]{10,}/u,/gsk_[A-Za-z0-9]{10,}/u,/pypi-[A-Za-z0-9_-]{10,}/u,/tvly-[A-Za-z0-9_-]{10,}/u,/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/u,/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/u,/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/u,/\bBearer\s+[A-Za-z0-9_.+/=-]{10,}/iu,/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u];var MANAGED_STARTUP_INFERENCE_APIS=["openai-completions","openai-responses","anthropic-messages"];var MANAGED_STARTUP_REASONING_EFFORTS=["default","low","medium","high"];var MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES=["disabled","thread-opt-in"];var MANAGED_STARTUP_HERMES_TOOL_GATEWAYS=["nous-web","nous-image","nous-audio","nous-browser","nous-code"];var MANAGED_STARTUP_AGENTS=["openclaw","hermes","langchain-deepagents-code","pi"];var MANAGED_STARTUP_MESSAGING_AGENTS=["openclaw","hermes"];function freezeAgentCapabilities(capabilities){return Object.freeze({...capabilities,inferenceApis:Object.freeze([...capabilities.inferenceApis]),dashboardModes:Object.freeze([...capabilities.dashboardModes]),inputModalities:Object.freeze([...capabilities.inputModalities]),webSearchProviders:Object.freeze([...capabilities.webSearchProviders]),toolGateways:Object.freeze([...capabilities.toolGateways]),tuningFields:Object.freeze([...capabilities.tuningFields])})}var PROFILE_CAPABILITIES={openclaw:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["loopback","remote"],inputModalities:["text","image"],webSearchProviders:["brave","tavily"],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning","reasoningEffort"],supportsMessaging:true,supportsInferenceCompatibility:true,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:true,supportsAgentTimeout:true,supportsHeartbeat:true,supportsExtraAgents:true,supportsDeviceAuth:true,observability:"openclaw-otel",supportsMinimalBootstrap:true},hermes:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["disabled","loopback-forwarded"],inputModalities:[],webSearchProviders:["tavily"],toolGateways:[...MANAGED_STARTUP_HERMES_TOOL_GATEWAYS],tuningFields:["contextWindow"],supportsMessaging:true,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false},"langchain-deepagents-code":{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["reasoningEffort"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:true,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"dcode-marker",supportsMinimalBootstrap:false},pi:{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false}};for(const agent of MANAGED_STARTUP_AGENTS){Object.defineProperty(PROFILE_CAPABILITIES,agent,{configurable:false,enumerable:true,value:freezeAgentCapabilities(PROFILE_CAPABILITIES[agent]),writable:false})}var MANAGED_STARTUP_PROFILE_CAPABILITIES=Object.freeze(PROFILE_CAPABILITIES);function affordance(input,profilePath,source="docker-arg",representation="value"){return{input,profilePath,source,representation}}var HOST_PROXY_AFFORDANCES=[affordance("HTTP_PROXY","proxy.hostHttpUrl","runtime-env"),affordance("http_proxy","proxy.hostHttpUrl","runtime-env","derived"),affordance("HTTPS_PROXY","proxy.hostHttpsUrl","runtime-env"),affordance("https_proxy","proxy.hostHttpsUrl","runtime-env","derived"),affordance("NO_PROXY","proxy.hostNoProxy","runtime-env"),affordance("no_proxy","proxy.hostNoProxy","runtime-env","derived")];var MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY={openclaw:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_PRIMARY_MODEL_REF","inference.primaryModelRef"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_INFERENCE_COMPAT_B64","inference.compatibility"),affordance("NEMOCLAW_INFERENCE_INPUTS","inference.inputModalities"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_AGENT_TIMEOUT","agentConfig.agentTimeoutSeconds"),affordance("NEMOCLAW_AGENT_HEARTBEAT_EVERY","agentConfig.heartbeatEvery"),affordance("NEMOCLAW_EXTRA_AGENTS_JSON_B64","agentConfig.extraAgents"),affordance("NEMOCLAW_DISABLE_DEVICE_AUTH","agentConfig.deviceAuth.disabled"),affordance("NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE","agentConfig.deviceAuth.optOutSource"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_OPENCLAW_OTEL","agentConfig.otel.enabled"),affordance("NEMOCLAW_OPENCLAW_OTEL_ENDPOINT","agentConfig.otel.endpointUrl"),affordance("NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME","agentConfig.otel.serviceName"),affordance("NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE","agentConfig.otel.sampleRate"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_BIND","dashboard.bindAddress"),affordance("NEMOCLAW_WSL_DASHBOARD_EXPOSURE","dashboard.wslExposure"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.port","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("NEMOCLAW_MINIMAL_BOOTSTRAP","agentConfig.minimalBootstrap","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],hermes:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER","tools.enabledGateways","docker-arg","derived"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64","tools.enabledGateways"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD","dashboard.mode","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT","dashboard.internalPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_TUI","dashboard.tuiEnabled","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost","runtime-env"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],"langchain-deepagents-code":[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_UPSTREAM_ENDPOINT_URL","inference.upstreamEndpointUrl"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_DCODE_AUTO_APPROVAL","agentConfig.autoApprovalMode"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_OBSERVABILITY","agentConfig.observabilityEnabled","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],pi:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES]};function deferredRuntimeInput(input,owner,reason,admission="managed-launch-forwarded"){return Object.freeze({input,owner,admission,reason})}var MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS=Object.freeze({openclaw:Object.freeze([deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_SHADOW_DIAGNOSTICS","application-environment","operator shadow-diagnostics tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS","application-environment","operator MCP discovery timeout tuning is applied by the application environment transaction"),deferredRuntimeInput("OPENCLAW_HOME","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_STATE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_WORKSPACE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),hermes:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),"langchain-deepagents-code":Object.freeze([deferredRuntimeInput("NEMOCLAW_SANDBOX_NAME","engine-identity","the lifecycle engine owns instance identity outside reusable startup intent"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),pi:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")])});function runtimeCleanupObligation(input,emittedFor,supportedFor,reason){return Object.freeze({input,emittedFor:Object.freeze([...emittedFor]),supportedFor:Object.freeze([...supportedFor]),owner:"application-environment",reason})}var MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS=Object.freeze([runtimeCleanupObligation("NEMOCLAW_DASHBOARD_BIND",["hermes"],["openclaw"],"generic managed-dashboard construction currently emits the OpenClaw-only bind control for Hermes"),runtimeCleanupObligation("NEMOCLAW_MINIMAL_BOOTSTRAP",["hermes","langchain-deepagents-code"],["openclaw"],"generic host-proxy construction currently emits the OpenClaw-only bootstrap control for other agents")]);var ManagedStartupProfileError=class extends Error{constructor(message){super(`Invalid managed startup profile: ${message}`);this.name="ManagedStartupProfileError"}};var PROFILE_KEYS=new Set(["schemaVersion","agent","agentConfig","inference","proxy","dashboard","tools","messaging","tuning","corporateCa"]);var INFERENCE_KEYS=new Set(["routeProvider","upstreamProvider","model","routedBaseUrl","upstreamEndpointUrl","api","primaryModelRef","compatibility","inputModalities"]);var PROXY_KEYS=new Set(["managedHost","managedPort","hostHttpUrl","hostHttpsUrl","hostNoProxy"]);var OPENCLAW_DASHBOARD_KEYS=new Set(["agent","mode","url","port","bindAddress","wslExposure"]);var HERMES_DASHBOARD_KEYS=new Set(["agent","mode","url","publicPort","internalPort","tuiEnabled"]);var DCODE_DASHBOARD_KEYS=new Set(["agent","mode"]);var TOOLS_KEYS=new Set(["disclosure","enabledGateways"]);var MESSAGING_KEYS=new Set(["plan"]);var TUNING_FIELD_ORDER=["contextWindow","maxTokens","reasoning","reasoningEffort"];var TUNING_KEYS=new Set(TUNING_FIELD_ORDER);var CORPORATE_CA_KEYS=new Set(["bundleSha256"]);var OPENCLAW_CONFIG_KEYS=new Set(["agent","webSearch","otel","agentTimeoutSeconds","heartbeatEvery","extraAgents","deviceAuth","minimalBootstrap"]);var HERMES_CONFIG_KEYS=new Set(["agent","webSearch"]);var DCODE_CONFIG_KEYS=new Set(["agent","autoApprovalMode","observabilityEnabled"]);var PI_CONFIG_KEYS=new Set(["agent"]);var PI_DASHBOARD_KEYS=new Set(["agent","mode"]);var WEB_SEARCH_KEYS=new Set(["enabled","provider"]);var OTEL_KEYS=new Set(["enabled","endpointUrl","serviceName","sampleRate"]);var DEVICE_AUTH_KEYS=new Set(["disabled","optOutSource"]);var EXTRA_AGENTS_KEYS=new Set(["agents","defaults","main"]);var MANAGED_STARTUP_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DCODE_AUTO_APPROVAL_MODE_SET=new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES);var REASONING_EFFORT_SET=new Set(MANAGED_STARTUP_REASONING_EFFORTS);var HERMES_INTERNAL_API_PORT=18642;var HERMES_API_PORT_RANGE_START=8642;var HERMES_API_PORT_RANGE_END=8652;function isHermesApiPort(port){return port>=HERMES_API_PORT_RANGE_START&&port<=HERMES_API_PORT_RANGE_END}function isHermesReservedApiPort(port){return port===HERMES_INTERNAL_API_PORT||isHermesApiPort(port)}var HERMES_RESERVED_API_PORT_LABEL=`${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END} or ${HERMES_INTERNAL_API_PORT}`;function isPlainObject(value){if(typeof value!=="object"||value===null||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function isCredentialShapedName(name){if(PUBLIC_KEY_NAME_PATTERN.test(name)||NON_SECRET_KEY_METADATA_NAMES.has(name))return false;return CREDENTIAL_SHAPED_NAME_PATTERN.test(name)||CREDENTIAL_COMPOUND_NAME_PATTERN.test(name)||CREDENTIAL_CAMEL_SUFFIX_PATTERN.test(name)||CREDENTIAL_CAMEL_BOUNDARY_PATTERN.test(name)||CREDENTIAL_ENV_NAME_PATTERN.test(name)||CREDENTIAL_HEADER_NAME_PATTERN.test(name)||PASS_CREDENTIAL_NAME_PATTERN.test(name)}function valueLooksLikeSecret(value){for(let index=0;index=5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="agentRender"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value";const isAuthorizedBuildStepPlaceholder=allowedBuildStepPlaceholders.has(buildStepPlaceholderKey(path5,value));return isCredentialBindingPlaceholder||isAgentRenderValuePlaceholder||isAuthorizedBuildStepPlaceholder}function requiresMessagingSchemaFieldAuthorization(path5){const fieldName=path5[path5.length-1];return fieldName==="webhook"}function messagingAuthorizedFieldKey(path5){return JSON.stringify(path5)}function buildStepPlaceholderKey(path5,value){return JSON.stringify([path5,value])}function messagingCredentialPlaceholderEnvKey(value){if(!MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value))return null;const marker=value.startsWith("openshell:resolve:env:")?"openshell:resolve:env:":"-OPENSHELL-RESOLVE-ENV-";const key=value.slice(value.indexOf(marker)+marker.length);return key.replace(/^v[0-9]+_/u,"")}function containsMessagingCredentialPlaceholder(value){return value.includes("openshell:resolve:env:")||value.includes("-OPENSHELL-RESOLVE-ENV-")}function isMessagingCredentialPlaceholderAssignment(selectedAgent,path5,value){if(path5.length!==6||path5[0]!=="messaging"||path5[1]!=="plan"||path5[2]!=="agentRender"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")||path5[4]!=="lines"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[5]??"")){return false}const separator=value.indexOf("=");if(separator<=0||value.indexOf("=",separator+1)!==-1)return false;const envKey=value.slice(0,separator);const placeholder=value.slice(separator+1);const placeholderEnvKey=messagingCredentialPlaceholderEnvKey(placeholder);return CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&placeholderEnvKey!==null&&(envKey===placeholderEnvKey||typeof selectedAgent==="string"&&MESSAGING_CREDENTIAL_ENV_ALIASES.has(`${selectedAgent}\0${placeholderEnvKey}\0${envKey}`))}function isMessagingRuntimeEnvAliasPath(path5){return path5.length===5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[4]??"")}function ownDataPropertyValue4(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function isCanonicalMessagingRuntimeEnvAlias(selectedAgent,path5,value){if(!isMessagingRuntimeEnvAliasPath(path5))return false;const channelId=ownDataPropertyValue4(value,"channelId");const envKey=ownDataPropertyValue4(value,"envKey");const targetEnvKey=ownDataPropertyValue4(value,"targetEnvKey");const match=ownDataPropertyValue4(value,"match");const placeholder=ownDataPropertyValue4(value,"value");const expectedMatch=targetEnvKey===void 0?`^openshell:resolve:env:(v[0-9]+_)?${envKey}$`:`^openshell:resolve:env:v[0-9]+_${envKey}$`;return typeof envKey==="string"&&CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&match===expectedMatch&&typeof placeholder==="string"&&messagingCredentialPlaceholderEnvKey(placeholder)===envKey&&(targetEnvKey===void 0||typeof selectedAgent==="string"&&typeof channelId==="string"&&typeof targetEnvKey==="string"&&CREDENTIAL_ENV_NAME_PATTERN.test(targetEnvKey)&&MESSAGING_CREDENTIAL_RUNTIME_ENV_ALIASES.has(`${selectedAgent}\0${channelId}\0${envKey}\0${targetEnvKey}`))}function isAllowedMessagingRuntimeAliasStringPath(path5,allowedAliasIndexes){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&allowedAliasIndexes.has(path5[4]??"")&&(path5[5]==="match"||path5[5]==="value"||path5[5]==="targetEnvKey")}function isMessagingPackagePin(path5,value){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="buildSteps"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value"&&path5[5]==="pin"&&typeof value==="boolean"}function containsUrlWithCredentialMaterial(value){const candidates=value.match(URL_CANDIDATE_RE)??[];for(let index=0;index{if(isCredentialShapedName(key))credentialQuery=true});const fragment=url.hash.startsWith("#")?url.hash.slice(1):url.hash;const queryStart=fragment.indexOf("?");const fragmentParameters=new URLSearchParams(queryStart>=0?fragment.slice(queryStart+1):fragment);let credentialFragment=false;fragmentParameters.forEach((_fragmentValue,key)=>{if(isCredentialShapedName(key))credentialFragment=true});if(url.username||url.password||credentialQuery||credentialFragment)return true}catch{}}return false}function invalid(reason){throw new ManagedStartupProfileError(reason)}function payloadPath(path5){return path5.reduce((result,segment)=>segment.startsWith("[")?`${result}${segment}`:`${result}${result?".":""}${segment}`,"")}function mapArrayByIndex(values,mapper){const mapped=[];for(let index=0;index0&&values[insertion-1]>selected){Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:values[insertion-1],writable:true});insertion-=1}Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:selected,writable:true})}return values}function requireRecord(value,where){if(!isPlainObject(value))invalid(`${where} must be an object`);return value}function rejectUnknownKeys(value,allowed,where){const keys=Object.keys(value);for(let index=0;indexmaxBytes||CONTROL_CHARACTER_RE.test(value)){invalid(`${where} must be a bounded, non-empty string without control characters`)}return value}function requireStringEnum(value,allowed,where){const normalized=requireBoundedString(value,where);if(!allowed.has(normalized))invalid(`${where} is not supported`);return normalized}function requireNullablePositiveInteger(value,where){if(value===null)return null;if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>MAX_TUNING_INTEGER){invalid(`${where} must be null or a bounded positive integer`)}return value}function requirePositiveInteger(value,where,maximum=MAX_TUNING_INTEGER){if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>maximum){invalid(`${where} must be a bounded positive integer`)}return value}function requirePort(value,where,minimum=1){if(typeof value!=="number"||!Number.isInteger(value)||value<1||value>65535){invalid(`${where} must be a valid TCP port`)}if(valueMAX_LIST_ITEMS){invalid(`${where} must be a bounded string list`)}const items=mapArrayByIndex(value,item=>requireBoundedString(item,`${where} item`));const unique2=new Set;for(let index=0;index{if(depth>MAX_JSON_DEPTH)invalid(`${where} exceeds the JSON depth limit`);if(current===null||typeof current==="string"||typeof current==="boolean"){return current}if(typeof current==="number"){if(!Number.isFinite(current))invalid(`${where} contains a non-finite number`);return current}if(Array.isArray(current)){return mapArrayByIndex(current,item=>clone(item,depth+1))}if(!isPlainObject(current))invalid(`${where} contains a non-JSON value`);const result=options.nullPrototypeObjects?Object.create(null):{};const keys=Object.getOwnPropertyNames(current);for(let index=0;indexMAX_IDENTIFIER_BYTES||CONTROL_CHARACTER_RE.test(key)){invalid(`${where} contains an invalid object key`)}const descriptor=Object.getOwnPropertyDescriptor(current,key);if(!descriptor||!("value"in descriptor)){invalid(`${where} contains a non-JSON value`)}Object.defineProperty(result,key,{configurable:true,enumerable:true,value:clone(descriptor.value,depth+1),writable:true})}return result};return clone(value,0)}function requireJsonObjectOrNull(value,where){if(value===null)return null;if(!isPlainObject(value))invalid(`${where} must be null or a plain JSON object`);return cloneJsonValue(value,where,{nullPrototypeObjects:true})}function requireJsonObject(value,where){const object=requireJsonObjectOrNull(value,where);if(object===null)invalid(`${where} must be a plain JSON object`);return object}function requireHttpUrl(value,where){const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) URL`)}if(parsed.protocol!=="http:"&&parsed.protocol!=="https:"||parsed.username||parsed.password||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) URL without query or fragment data`)}const pathname=parsed.pathname.replace(/\/+$/u,"");return pathname===""?parsed.origin:`${parsed.origin}${pathname}`}function requireProxyUrl(value,allowedSchemes,where){if(value===null)return null;const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) proxy URL`)}if(!allowedSchemes.has(parsed.protocol)||parsed.username||parsed.password||parsed.pathname!=="/"||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) proxy origin`)}return parsed.origin}function requireManagedProxyHost(value,where){const host=requireBoundedString(value,where);if(!/^[A-Za-z0-9._-]+$/u.test(host)){invalid(`${where} must be a hostname or IPv4 address without a scheme or separators`)}return host}function isLoopbackUrl(value){const hostname=new URL(value).hostname.toLowerCase();return hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="[::1]"}function configuredDashboardPort(value){const explicit=new URL(value).port;return explicit===""?18789:Number(explicit)}function requireSampleRate(value,where){if(typeof value!=="number"||!Number.isFinite(value)||value<0||value>1){invalid(`${where} must be a number between 0 and 1`)}return value}function assertPayloadStructureAndCredentialShapes(root){const pending=[{value:root,depth:0,path:[]}];const allowedRuntimeAliasIndexes=new Set;const allowedMessagingCredentialFields=new Set;const allowedBuildStepPlaceholders=new Set;const selectedAgent=isPlainObject(root)?ownDataPropertyValue4(root,"agent"):void 0;let discoveredNodes=1;let observedBytes=0;const observeText=value=>{observedBytes+=import_node_buffer.Buffer.byteLength(value,"utf8");if(observedBytes>MANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}};const reserveNode=depth=>{discoveredNodes+=1;if(discoveredNodes>MAX_JSON_NODES||depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}observedBytes+=1};while(pending.length>0){const current=pending.pop();if(!current)break;if(current.depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}if(typeof current.value==="string"){observeText(current.value);if(!isAllowedMessagingRuntimeAliasStringPath(current.path,allowedRuntimeAliasIndexes)&&!isMessagingCredentialPlaceholder(current.path,current.value,allowedBuildStepPlaceholders,allowedMessagingCredentialFields)&&!isMessagingCredentialPlaceholderAssignment(selectedAgent,current.path,current.value)&&(valueLooksLikeSecret(current.value)||containsMessagingCredentialPlaceholder(current.value))){invalid(`payload field ${payloadPath(current.path)} contains credential-shaped string data`)}if(RAW_CA_PEM_RE.test(current.value)||RAW_CA_PEM_BASE64_RE.test(current.value)||RAW_CA_DER_BASE64_RE.test(current.value)||RAW_CA_DATA_URI_RE.test(current.value)){invalid(`payload field ${payloadPath(current.path)} contains raw certificate data; provide only the CA SHA-256 digest`)}if(containsUrlWithCredentialMaterial(current.value)){invalid(`payload field ${payloadPath(current.path)} contains a URL with embedded credentials`)}continue}if(Array.isArray(current.value)){if(Object.getPrototypeOf(current.value)!==Array.prototype){invalid("payload arrays must use the standard JSON prototype")}if("toJSON"in current.value){invalid("payload must not define a custom JSON serializer")}if(Object.getOwnPropertySymbols(current.value).length>0||Object.getOwnPropertyNames(current.value).length!==current.value.length+1){invalid("payload arrays must contain only indexed JSON values")}for(let index=0;index0||discoveredNodes+keys.length>MAX_JSON_NODES){invalid("payload structure exceeds the complexity limit")}for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}}function validateWebSearch(value,agent){const webSearch=requireRecord(value,"agentConfig.webSearch");rejectUnknownKeys(webSearch,WEB_SEARCH_KEYS,"agentConfig.webSearch");const provider=requireStringEnum(webSearch.provider,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].webSearchProviders),"agentConfig.webSearch.provider");return{enabled:requireBoolean(webSearch.enabled,"agentConfig.webSearch.enabled"),provider}}function validateOpenClawOtel(value){const otel=requireRecord(value,"agentConfig.otel");rejectUnknownKeys(otel,OTEL_KEYS,"agentConfig.otel");return{enabled:requireBoolean(otel.enabled,"agentConfig.otel.enabled"),endpointUrl:requireHttpUrl(otel.endpointUrl,"agentConfig.otel.endpointUrl"),serviceName:requireBoundedString(otel.serviceName,"agentConfig.otel.serviceName",MAX_IDENTIFIER_BYTES),sampleRate:requireSampleRate(otel.sampleRate,"agentConfig.otel.sampleRate")}}function validateExtraAgents(value){const extraAgents=requireRecord(value,"agentConfig.extraAgents");rejectUnknownKeys(extraAgents,EXTRA_AGENTS_KEYS,"agentConfig.extraAgents");if(!Array.isArray(extraAgents.agents)||extraAgents.agents.length>MAX_LIST_ITEMS){invalid("agentConfig.extraAgents.agents must be a bounded JSON object list")}return{agents:mapArrayByIndex(extraAgents.agents,(agent,index)=>requireJsonObject(agent,`agentConfig.extraAgents.agents[${String(index)}]`)),defaults:requireJsonObject(extraAgents.defaults,"agentConfig.extraAgents.defaults"),main:requireJsonObject(extraAgents.main,"agentConfig.extraAgents.main")}}function validateDeviceAuth(value){const deviceAuth=requireRecord(value,"agentConfig.deviceAuth");rejectUnknownKeys(deviceAuth,DEVICE_AUTH_KEYS,"agentConfig.deviceAuth");return{disabled:requireBoolean(deviceAuth.disabled,"agentConfig.deviceAuth.disabled"),optOutSource:requireStringEnum(deviceAuth.optOutSource,new Set(["operator","managed-onboard"]),"agentConfig.deviceAuth.optOutSource")}}function validateAgentConfig(value,expectedAgent){const config=requireRecord(value,"agentConfig");const agent=requireStringEnum(config.agent,MANAGED_STARTUP_AGENT_SET,"agentConfig.agent");if(agent!==expectedAgent)invalid("agentConfig.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(config,OPENCLAW_CONFIG_KEYS,"agentConfig");const heartbeatEvery=config.heartbeatEvery===null?null:requireBoundedString(config.heartbeatEvery,"agentConfig.heartbeatEvery",MAX_IDENTIFIER_BYTES);if(heartbeatEvery!==null&&!/^\d+(?:s|m|h)$/u.test(heartbeatEvery)){invalid("agentConfig.heartbeatEvery must be null or a duration ending in s, m, or h")}return{agent,webSearch:validateWebSearch(config.webSearch,agent),otel:validateOpenClawOtel(config.otel),agentTimeoutSeconds:requirePositiveInteger(config.agentTimeoutSeconds,"agentConfig.agentTimeoutSeconds"),heartbeatEvery,extraAgents:validateExtraAgents(config.extraAgents),deviceAuth:validateDeviceAuth(config.deviceAuth),minimalBootstrap:requireBoolean(config.minimalBootstrap,"agentConfig.minimalBootstrap")}}if(agent==="hermes"){rejectUnknownKeys(config,HERMES_CONFIG_KEYS,"agentConfig");return{agent,webSearch:validateWebSearch(config.webSearch,agent)}}if(agent==="pi"){rejectUnknownKeys(config,PI_CONFIG_KEYS,"agentConfig");return{agent}}rejectUnknownKeys(config,DCODE_CONFIG_KEYS,"agentConfig");return{agent,autoApprovalMode:requireStringEnum(config.autoApprovalMode,DCODE_AUTO_APPROVAL_MODE_SET,"agentConfig.autoApprovalMode"),observabilityEnabled:requireBoolean(config.observabilityEnabled,"agentConfig.observabilityEnabled")}}function validateDashboard(value,expectedAgent){const dashboard=requireRecord(value,"dashboard");const agent=requireStringEnum(dashboard.agent,MANAGED_STARTUP_AGENT_SET,"dashboard.agent");if(agent!==expectedAgent)invalid("dashboard.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(dashboard,OPENCLAW_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");const bindAddress=requireStringEnum(dashboard.bindAddress,new Set(["127.0.0.1","0.0.0.0"]),"dashboard.bindAddress");const wslExposure=requireBoolean(dashboard.wslExposure,"dashboard.wslExposure");const hasRemoteExposure=!isLoopbackUrl(url)||bindAddress==="0.0.0.0"||wslExposure;if(mode==="remote"!==hasRemoteExposure){invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure")}const port=requirePort(dashboard.port,"dashboard.port",1024);if(isHermesApiPort(port))invalid(`OpenClaw dashboard.port must not use a reserved Hermes API port (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`);if(configuredDashboardPort(url)!==port){invalid("OpenClaw dashboard.port must match dashboard.url")}return{agent,mode,url,port,bindAddress,wslExposure}}if(agent==="hermes"){rejectUnknownKeys(dashboard,HERMES_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");if(!isLoopbackUrl(url)){invalid("Hermes dashboard.url must remain loopback; OpenShell owns the host forward")}if(mode==="disabled"){if(dashboard.publicPort!==null||dashboard.internalPort!==null||dashboard.tuiEnabled!==false){invalid("disabled Hermes dashboard must not configure ports or TUI")}return{agent,mode,url,publicPort:null,internalPort:null,tuiEnabled:false}}const publicPort=requirePort(dashboard.publicPort,"dashboard.publicPort",1024);const internalPort=requirePort(dashboard.internalPort,"dashboard.internalPort",1024);if(publicPort===internalPort){invalid("Hermes dashboard publicPort and internalPort must differ")}if(isHermesReservedApiPort(publicPort)||isHermesReservedApiPort(internalPort)){invalid(`Hermes dashboard ports must not use reserved API ports ${HERMES_RESERVED_API_PORT_LABEL}`)}if(configuredDashboardPort(url)!==publicPort){invalid("Hermes dashboard.publicPort must match dashboard.url")}return{agent,mode,url,publicPort,internalPort,tuiEnabled:requireBoolean(dashboard.tuiEnabled,"dashboard.tuiEnabled")}}if(agent==="pi"){rejectUnknownKeys(dashboard,PI_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled")invalid("pi dashboard.mode must be disabled");return{agent,mode:"disabled"}}rejectUnknownKeys(dashboard,DCODE_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled"){invalid("langchain-deepagents-code dashboard.mode must be disabled")}return{agent,mode:"disabled"}}function validateInference(value,agent){const inference=requireRecord(value,"inference");rejectUnknownKeys(inference,INFERENCE_KEYS,"inference");const routeProvider=requireBoundedString(inference.routeProvider,"inference.routeProvider");const upstreamProvider=requireBoundedString(inference.upstreamProvider,"inference.upstreamProvider");const model=requireBoundedString(inference.model,"inference.model",MAX_MODEL_BYTES);const api=requireStringEnum(inference.api,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inferenceApis),"inference.api");const upstreamEndpointUrl=inference.upstreamEndpointUrl===null?null:requireHttpUrl(inference.upstreamEndpointUrl,"inference.upstreamEndpointUrl");const primaryModelRef=inference.primaryModelRef===null?null:requireBoundedString(inference.primaryModelRef,"inference.primaryModelRef",MAX_MODEL_BYTES);const compatibility=requireJsonObjectOrNull(inference.compatibility,"inference.compatibility");const inputModalities=inference.inputModalities===null?null:requireEnumList(inference.inputModalities,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inputModalities),"inference.inputModalities",{allowEmpty:false});if(upstreamEndpointUrl!==null&&!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsUpstreamEndpoint){invalid(`inference.upstreamEndpointUrl must be null for ${agent}`)}if(agent==="openclaw"){if(primaryModelRef===null||inputModalities===null){invalid("openclaw requires primaryModelRef and inputModalities")}if(primaryModelRef!==`${routeProvider}/${model}`){invalid("openclaw primaryModelRef must match routeProvider and model")}}else{if(primaryModelRef!==null||compatibility!==null||inputModalities!==null){invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`)}if(agent==="langchain-deepagents-code"&&!isValidDcodeUpstreamProvider(upstreamProvider)){invalid("inference.upstreamProvider must start with an ASCII letter or digit and contain 1-64 ASCII letters, digits, dots, underscores, or hyphens for DCode")}}return{routeProvider,upstreamProvider,model,routedBaseUrl:requireHttpUrl(inference.routedBaseUrl,"inference.routedBaseUrl"),upstreamEndpointUrl,api,primaryModelRef,compatibility,inputModalities}}function validateProxy(value,agent){const proxy=requireRecord(value,"proxy");rejectUnknownKeys(proxy,PROXY_KEYS,"proxy");const hostHttpUrl=requireProxyUrl(proxy.hostHttpUrl,new Set(["http:"]),"proxy.hostHttpUrl");const hostHttpsUrl=requireProxyUrl(proxy.hostHttpsUrl,new Set(["http:","https:"]),"proxy.hostHttpsUrl");const hostNoProxy=requireStringList(proxy.hostNoProxy,"proxy.hostNoProxy");if(!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsHostProxyIntent&&(hostHttpUrl!==null||hostHttpsUrl!==null||hostNoProxy.length>0)){invalid(`${agent} rejects host proxy intent and accepts only its root-owned managed route`)}return{managedHost:requireManagedProxyHost(proxy.managedHost,"proxy.managedHost"),managedPort:requirePort(proxy.managedPort,"proxy.managedPort"),hostHttpUrl,hostHttpsUrl,hostNoProxy}}function validateTools(value,agent){const tools=requireRecord(value,"tools");rejectUnknownKeys(tools,TOOLS_KEYS,"tools");const enabledGateways=requireEnumList(tools.enabledGateways,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].toolGateways),"tools.enabledGateways",{allowEmpty:true});return{disclosure:requireStringEnum(tools.disclosure,new Set(["progressive","direct"]),"tools.disclosure"),enabledGateways}}function validateTuning(value,agent){const tuning=requireRecord(value,"tuning");rejectUnknownKeys(tuning,TUNING_KEYS,"tuning");const result={contextWindow:requireNullablePositiveInteger(tuning.contextWindow,"tuning.contextWindow"),maxTokens:requireNullablePositiveInteger(tuning.maxTokens,"tuning.maxTokens"),reasoning:requireNullableBoolean(tuning.reasoning,"tuning.reasoning"),reasoningEffort:tuning.reasoningEffort===null?null:requireStringEnum(tuning.reasoningEffort,REASONING_EFFORT_SET,"tuning.reasoningEffort")};const advertised=new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].tuningFields);const unsupported=TUNING_FIELD_ORDER.filter(field=>result[field]!==null&&!advertised.has(field));if(unsupported.length>0){invalid(`${agent} does not support startup tuning fields: ${unsupported.join(", ")}`)}if(agent==="openclaw"){const missing=TUNING_FIELD_ORDER.filter(field=>advertised.has(field)&&result[field]===null);if(missing.length>0){invalid(`openclaw requires ${missing.join(", ")} tuning`)}}if(agent==="hermes"&&result.contextWindow!==null&&result.contextWindowcanonicalizeJson(item));if(!isPlainObject(value))return value;const result={};const keys=sortStrings(Object.keys(value));for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`canonical payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}return serialized}function decodeManagedStartupProfile(encoded){if(typeof encoded!=="string"||encoded.length===0||import_node_buffer.Buffer.byteLength(encoded,"ascii")>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES||!BASE64URL_RE.test(encoded)||encoded.length%4===1){invalid("encoded payload is malformed or exceeds the size limit")}const bytes=import_node_buffer.Buffer.from(encoded,"base64url");if(bytes.length===0||bytes.length>MANAGED_STARTUP_PROFILE_MAX_BYTES||bytes.toString("base64url")!==encoded){invalid("encoded payload is malformed or exceeds the size limit")}let raw;try{raw=UTF8_DECODER.decode(bytes)}catch{invalid("payload is not valid UTF-8")}let parsed;try{parsed=JSON.parse(raw)}catch{invalid("payload is not valid JSON")}const profile=validateManagedStartupProfile(parsed);if(serializeManagedStartupProfile(profile)!==raw){invalid("payload is not in canonical form")}return profile}function fingerprintManagedStartupProfile(profile){return(0,import_node_crypto2.createHash)("sha256").update(serializeManagedStartupProfile(profile),"utf8").digest("hex")}var ManagedStartupAgentEnvironmentError=class extends Error{constructor(message){super(`Cannot map managed startup profile: ${message}`);this.name="ManagedStartupAgentEnvironmentError"}};var EMPTY_APPLICATION_ENVIRONMENT=Object.freeze({});var OPENCLAW_APPLICATION_RUNTIME_INPUTS=Object.freeze([["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","positive-safe-integer"],["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","positive-finite-seconds"]]);function booleanFlag(value){return value?"1":"0"}function canonicalizeJson2(value){if(Array.isArray(value))return value.map(item=>canonicalizeJson2(item));if(value===null||typeof value!=="object")return value;const record=value;return Object.fromEntries(Object.keys(record).sort().map(key=>[key,canonicalizeJson2(record[key])]))}function encodeCanonicalJson(value){return import_node_buffer2.Buffer.from(JSON.stringify(canonicalizeJson2(value)),"utf8").toString("base64")}function sortedEnvironment(environment){return Object.freeze(Object.fromEntries(Object.entries(environment).sort(([left],[right])=>leftright?1:0)))}function canonicalApplicationRuntimeValue(name,raw,kind){if(raw.includes("\0")||/[\r\n]/u.test(raw)){throw new ManagedStartupAgentEnvironmentError(`${name} must be single-line text`)}const value=Number(raw.trim());const valid=kind==="positive-safe-integer"?Number.isSafeInteger(value)&&value>0:Number.isFinite(value)&&value>0;if(!valid){throw new ManagedStartupAgentEnvironmentError(`${name} must be ${kind==="positive-safe-integer"?"a positive safe integer":"finite positive seconds"}`)}return String(value)}function applicationRuntimePlan(profile,environment){const exportEnvironment={};if(profile.agent==="openclaw"){for(const[name,kind]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){const raw=environment[name];if(raw!==void 0){exportEnvironment[name]=canonicalApplicationRuntimeValue(name,raw,kind)}}}const unsetEnvironment=new Set(MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter(({supportedFor})=>!supportedFor.includes(profile.agent)).map(({input})=>input));if(profile.agent!=="openclaw"){for(const[name]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){unsetEnvironment.add(name)}}return Object.freeze({exportEnvironment:sortedEnvironment(exportEnvironment),unsetEnvironment:Object.freeze([...unsetEnvironment].sort())})}function commonConfigurationEnvironment(profile){return{NEMOCLAW_INFERENCE_API:profile.inference.api,NEMOCLAW_INFERENCE_BASE_URL:profile.inference.routedBaseUrl,NEMOCLAW_INFERENCE_PROVIDER_ID:profile.inference.routeProvider,NEMOCLAW_MODEL:profile.inference.model,NEMOCLAW_TOOL_DISCLOSURE:profile.tools.disclosure,NEMOCLAW_UPSTREAM_PROVIDER:profile.inference.upstreamProvider}}function appendHostProxyEnvironment(environment,profile,options={}){if(options.preserveAmbientWhenAbsent===true&&profile.proxy.hostHttpUrl===null&&profile.proxy.hostHttpsUrl===null&&profile.proxy.hostNoProxy.length===0){return}const httpProxy=profile.proxy.hostHttpUrl??"";const httpsProxy=profile.proxy.hostHttpsUrl??"";const noProxy=profile.proxy.hostNoProxy.join(",");environment.HTTP_PROXY=httpProxy;environment.HTTPS_PROXY=httpsProxy;environment.NO_PROXY=noProxy;environment.http_proxy=httpProxy;environment.https_proxy=httpsProxy;environment.no_proxy=noProxy}function messagingEnvironment(profile,expectedAgent){if(profile.messaging.plan===null)return{};const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:expectedAgent});if(!plan){throw new ManagedStartupAgentEnvironmentError(`messaging.plan must contain a validated ${expectedAgent} messaging plan`)}const{workflow:_workflow,...imageBuildPlan}=plan;return{NEMOCLAW_MESSAGING_PLAN_B64:encodeCanonicalJson(imageBuildPlan)}}function corporateCaMaterial(profile){return Object.freeze({kind:"corporate-ca-handoff",legacyInput:"NEMOCLAW_CORPORATE_CA_B64",expectedSha256:profile.corporateCa.bundleSha256})}function rootOwnedFile(legacyInput,path5,value){return Object.freeze({kind:"root-owned-file",legacyInput,path:path5,contents:`${value} `,owner:"root",group:"root",mode:292})}function dashboardAction(dashboard){return Object.freeze({kind:"configure-dashboard",dashboard:Object.freeze(structuredClone(dashboard))})}function applicationActions(profile,messagingAgent){const actions=[];if(messagingAgent!==null){actions.push(Object.freeze({kind:"apply-messaging-plan",agent:messagingAgent,mode:profile.messaging.plan===null?"clear":"apply",phase:"runtime-setup",runAs:"root"}))}actions.push(Object.freeze({kind:"generate-agent-config",agent:profile.agent,runAs:"sandbox"}));if(messagingAgent!==null){actions.push(Object.freeze({kind:"apply-messaging-plan",agent:messagingAgent,mode:profile.messaging.plan===null?"clear":"apply",phase:"post-agent-install",runAs:"sandbox"}))}actions.push(dashboardAction(profile.dashboard));return Object.freeze(actions)}function mapOpenClawProfile(profile,environment){if(profile.agent!=="openclaw"||profile.agentConfig.agent!=="openclaw"||profile.dashboard.agent!=="openclaw"||profile.inference.primaryModelRef===null||profile.inference.inputModalities===null||profile.tuning.contextWindow===null||profile.tuning.maxTokens===null||profile.tuning.reasoning===null||profile.tuning.reasoningEffort===null){throw new ManagedStartupAgentEnvironmentError("OpenClaw profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),...messagingEnvironment(profile,"openclaw"),CHAT_UI_URL:profile.dashboard.url,NEMOCLAW_AGENT_HEARTBEAT_EVERY:profile.agentConfig.heartbeatEvery??"",NEMOCLAW_AGENT_TIMEOUT:String(profile.agentConfig.agentTimeoutSeconds),NEMOCLAW_CONTEXT_WINDOW:String(profile.tuning.contextWindow),NEMOCLAW_DASHBOARD_BIND:profile.dashboard.bindAddress==="0.0.0.0"?profile.dashboard.bindAddress:"",NEMOCLAW_DISABLE_DEVICE_AUTH:booleanFlag(profile.agentConfig.deviceAuth.disabled),NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE:profile.agentConfig.deviceAuth.optOutSource,NEMOCLAW_EXTRA_AGENTS_JSON_B64:encodeCanonicalJson(profile.agentConfig.extraAgents),NEMOCLAW_INFERENCE_COMPAT_B64:encodeCanonicalJson(profile.inference.compatibility),NEMOCLAW_INFERENCE_INPUTS:profile.inference.inputModalities.join(","),NEMOCLAW_MAX_TOKENS:String(profile.tuning.maxTokens),NEMOCLAW_OPENCLAW_OTEL:booleanFlag(profile.agentConfig.otel.enabled),NEMOCLAW_OPENCLAW_OTEL_ENDPOINT:profile.agentConfig.otel.endpointUrl,NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE:String(profile.agentConfig.otel.sampleRate),NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME:profile.agentConfig.otel.serviceName,NEMOCLAW_PRIMARY_MODEL_REF:profile.inference.primaryModelRef,NEMOCLAW_PROXY_HOST:profile.proxy.managedHost,NEMOCLAW_PROXY_PORT:String(profile.proxy.managedPort),NEMOCLAW_REASONING:String(profile.tuning.reasoning),NEMOCLAW_REASONING_EFFORT:profile.tuning.reasoningEffort,NEMOCLAW_WEB_SEARCH_ENABLED:booleanFlag(profile.agentConfig.webSearch.enabled),NEMOCLAW_WEB_SEARCH_PROVIDER:profile.agentConfig.webSearch.provider,NEMOCLAW_WSL_DASHBOARD_EXPOSURE:booleanFlag(profile.dashboard.wslExposure)};const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64;runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT=String(profile.dashboard.port);runtimeEnvironment.NEMOCLAW_MINIMAL_BOOTSTRAP=booleanFlag(profile.agentConfig.minimalBootstrap);appendHostProxyEnvironment(runtimeEnvironment,profile,{preserveAmbientWhenAbsent:true});return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials:Object.freeze([corporateCaMaterial(profile)]),actions:applicationActions(profile,"openclaw")})}function mapHermesProfile(profile,environment){if(profile.agent!=="hermes"||profile.agentConfig.agent!=="hermes"||profile.dashboard.agent!=="hermes"){throw new ManagedStartupAgentEnvironmentError("Hermes profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),...messagingEnvironment(profile,"hermes"),CHAT_UI_URL:profile.dashboard.url,NEMOCLAW_CONTEXT_WINDOW:profile.tuning.contextWindow===null?"":String(profile.tuning.contextWindow),NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER:booleanFlag(profile.tools.enabledGateways.length>0),NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64:encodeCanonicalJson(profile.tools.enabledGateways),NEMOCLAW_WEB_SEARCH_ENABLED:booleanFlag(profile.agentConfig.webSearch.enabled),NEMOCLAW_WEB_SEARCH_PROVIDER:profile.agentConfig.webSearch.provider};const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64;runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT=profile.dashboard.publicPort===null?"":String(profile.dashboard.publicPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD=profile.dashboard.mode==="loopback-forwarded"?"1":"0";runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT=profile.dashboard.internalPort===null?"":String(profile.dashboard.internalPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_PORT=profile.dashboard.publicPort===null?"":String(profile.dashboard.publicPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_TUI=booleanFlag(profile.dashboard.tuiEnabled);runtimeEnvironment.NEMOCLAW_PROXY_HOST=profile.proxy.managedHost;runtimeEnvironment.NEMOCLAW_PROXY_PORT=String(profile.proxy.managedPort);appendHostProxyEnvironment(runtimeEnvironment,profile,{preserveAmbientWhenAbsent:true});return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials:Object.freeze([corporateCaMaterial(profile)]),actions:applicationActions(profile,"hermes")})}function mapDcodeProfile(profile,environment){if(profile.agent!=="langchain-deepagents-code"||profile.agentConfig.agent!=="langchain-deepagents-code"||profile.dashboard.agent!=="langchain-deepagents-code"||profile.messaging.plan!==null){throw new ManagedStartupAgentEnvironmentError("LangChain Deep Agents Code profile state is inconsistent")}const reasoningEffort=profile.tuning.reasoningEffort===null||profile.tuning.reasoningEffort==="default"?"":profile.tuning.reasoningEffort;const configurationEnvironment={...commonConfigurationEnvironment(profile),NEMOCLAW_REASONING_EFFORT:reasoningEffort,NEMOCLAW_UPSTREAM_ENDPOINT_URL:profile.inference.upstreamEndpointUrl??""};appendHostProxyEnvironment(configurationEnvironment,profile);const runtimeEnvironment={...configurationEnvironment,NEMOCLAW_OBSERVABILITY:booleanFlag(profile.agentConfig.observabilityEnabled)};delete runtimeEnvironment.NEMOCLAW_INFERENCE_BASE_URL;delete runtimeEnvironment.NEMOCLAW_REASONING_EFFORT;delete runtimeEnvironment.NEMOCLAW_UPSTREAM_PROVIDER;for(const name of["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","http_proxy","https_proxy","no_proxy"]){delete runtimeEnvironment[name]}const materials=Object.freeze([corporateCaMaterial(profile),rootOwnedFile("NEMOCLAW_DCODE_AUTO_APPROVAL","/usr/local/share/nemoclaw/dcode-auto-approval",profile.agentConfig.autoApprovalMode),rootOwnedFile("NEMOCLAW_INFERENCE_BASE_URL","/usr/local/share/nemoclaw/dcode-inference-base-url",profile.inference.routedBaseUrl),rootOwnedFile("NEMOCLAW_UPSTREAM_PROVIDER","/usr/local/share/nemoclaw/dcode-upstream-provider",profile.inference.upstreamProvider),rootOwnedFile("NEMOCLAW_PROXY_HOST","/usr/local/share/nemoclaw/dcode-proxy-host",profile.proxy.managedHost),rootOwnedFile("NEMOCLAW_PROXY_PORT","/usr/local/share/nemoclaw/dcode-proxy-port",String(profile.proxy.managedPort)),rootOwnedFile("NEMOCLAW_REASONING_EFFORT","/usr/local/share/nemoclaw/dcode-reasoning-effort",reasoningEffort)]);return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials,actions:applicationActions(profile,null)})}function mapPiProfile(profile,environment){if(profile.agent!=="pi"||profile.agentConfig.agent!=="pi"||profile.dashboard.agent!=="pi"||profile.messaging.plan!==null){throw new ManagedStartupAgentEnvironmentError("Pi profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),NEMOCLAW_CONTEXT_WINDOW:profile.tuning.contextWindow===null?"":String(profile.tuning.contextWindow),NEMOCLAW_MAX_TOKENS:profile.tuning.maxTokens===null?"":String(profile.tuning.maxTokens),NEMOCLAW_REASONING:profile.tuning.reasoning===null?"":String(profile.tuning.reasoning)};appendHostProxyEnvironment(configurationEnvironment,profile);const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_INFERENCE_BASE_URL;delete runtimeEnvironment.NEMOCLAW_CONTEXT_WINDOW;delete runtimeEnvironment.NEMOCLAW_MAX_TOKENS;delete runtimeEnvironment.NEMOCLAW_REASONING;for(const name of["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","http_proxy","https_proxy","no_proxy"]){delete runtimeEnvironment[name]}const materials=Object.freeze([corporateCaMaterial(profile),rootOwnedFile("NEMOCLAW_PROXY_HOST","/usr/local/share/nemoclaw/pi-proxy-host",profile.proxy.managedHost),rootOwnedFile("NEMOCLAW_PROXY_PORT","/usr/local/share/nemoclaw/pi-proxy-port",String(profile.proxy.managedPort))]);return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials,actions:applicationActions(profile,null)})}function mapManagedStartupProfileToAgentEnvironment(profile,environment=EMPTY_APPLICATION_ENVIRONMENT){const validated=validateManagedStartupProfile(profile);switch(validated.agent){case"openclaw":return mapOpenClawProfile(validated,environment);case"hermes":return mapHermesProfile(validated,environment);case"langchain-deepagents-code":return mapDcodeProfile(validated,environment);case"pi":return mapPiProfile(validated,environment)}}var import_node_buffer3=require("node:buffer");var import_node_crypto3=require("node:crypto");var import_node_fs=__toESM(require("node:fs"));var import_node_path=__toESM(require("node:path"));var import_node_util2=require("node:util");var MANAGED_STARTUP_APPLICATION_STATE_DIR="/var/lib/nemoclaw/startup-profile";var MANAGED_STARTUP_CA_MAX_BYTES=128*1024;var MANAGED_STARTUP_CA_MAX_CERTIFICATES=24;var STATE_SCHEMA_VERSION=1;var STATE_DIRECTORY_MODE=448;var STATE_FILE_MODE=384;var MAX_CONTROL_FILE_BYTES=512;var MAX_STATE_ENTRIES=32;var SHA256_RE2=/^[a-f0-9]{64}$/u;var GENERATION_RE=/^generation-([a-f0-9]{64})$/u;var PREPARE_TEMP_RE=/^\.prepare-[0-9]+-[a-f0-9]{24}$/u;var CONTROL_TEMP_RE=/^\.(?:committed|pending)\.json-[a-f0-9]{24}\.tmp$/u;var PEM_CERTIFICATE_RE=/-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/gu;var UTF8_DECODER2=new import_node_util2.TextDecoder("utf-8",{fatal:true});var DEFAULT_RUNTIME={rootUid:0,rootGid:0};var ManagedStartupApplicationError=class extends Error{constructor(message){super(`Managed startup application failed: ${message}`);this.name="ManagedStartupApplicationError"}};function fail(message){throw new ManagedStartupApplicationError(message)}function runtimeFor(override){return override??DEFAULT_RUNTIME}function requireContainerRoot(){if(process.geteuid?.()!==0){fail("the image-side applicator must run with effective uid 0")}}function modeOf(stat){return stat.mode&511}function requireOwner(stat,target,runtime){if(stat.uid!==runtime.rootUid||stat.gid!==runtime.rootGid){fail(`${target} must be owned by root:root`)}}function requireSecureDirectory(target,runtime,exactMode){let stat;try{stat=import_node_fs.default.lstatSync(target)}catch{fail(`state directory component is missing or unreadable: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail(`state directory component must be a real directory: ${target}`)}const runtimeOwned=stat.uid===runtime.rootUid&&stat.gid===runtime.rootGid;const systemRootOwned=stat.uid===0&&stat.gid===0;if(exactMode){requireOwner(stat,target,runtime)}else if(!runtimeOwned&&!systemRootOwned){fail(`state directory ancestor is not owned by a trusted identity: ${target}`)}const mode=modeOf(stat);const writableByUntrustedIdentity=(mode&18)!==0;const trustedStickyRoot=(stat.mode&512)!==0&&(runtimeOwned||systemRootOwned);if(exactMode&&mode!==STATE_DIRECTORY_MODE||!exactMode&&writableByUntrustedIdentity&&!trustedStickyRoot){fail(exactMode?`${target} must have mode 0700`:`${target} is a replaceable group- or world-writable ancestor`)}}function requireSecureAncestors(target,runtime){const root=import_node_path.default.parse(target).root;let current=root;requireSecureDirectory(current,runtime,false);for(const segment of import_node_path.default.relative(root,target).split(import_node_path.default.sep).filter(Boolean)){current=import_node_path.default.join(current,segment);let stat;try{stat=import_node_fs.default.lstatSync(current)}catch{fail(`state directory component is missing or unreadable: ${current}`)}if(stat.isSymbolicLink()){const runtimeOwned=stat.uid===runtime.rootUid&&stat.gid===runtime.rootGid;const systemRootOwned=stat.uid===0&&stat.gid===0;if(!runtimeOwned&&!systemRootOwned){fail(`state directory ancestor is a replaceable symlink: ${current}`)}let resolved;try{resolved=import_node_fs.default.realpathSync(current)}catch{fail(`state directory symlink is missing or unreadable: ${current}`)}requireSecureAncestors(resolved,runtime);continue}requireSecureDirectory(current,runtime,false)}}function ensureStateDirectory(rawStateDirectory,runtime){const stateDirectory=rawStateDirectory??MANAGED_STARTUP_APPLICATION_STATE_DIR;if(!import_node_path.default.isAbsolute(stateDirectory)||stateDirectory.includes("\0")){fail("stateDirectory must be an absolute path")}const normalized=import_node_path.default.resolve(stateDirectory);const parent=import_node_path.default.dirname(normalized);requireSecureAncestors(parent,runtime);try{import_node_fs.default.mkdirSync(normalized,{mode:STATE_DIRECTORY_MODE});import_node_fs.default.chownSync(normalized,runtime.rootUid,runtime.rootGid);import_node_fs.default.chmodSync(normalized,STATE_DIRECTORY_MODE)}catch(error){if(error.code!=="EEXIST"){fail(`could not create the managed startup state directory: ${normalized}`)}}requireSecureDirectory(normalized,runtime,true);return normalized}function requireSecureRegularFileStat(stat,target,runtime){if(!stat.isFile()||stat.isSymbolicLink()){fail(`${target} must be a regular file`)}if(stat.nlink!==1){fail(`${target} must not be hardlinked`)}requireOwner(stat,target,runtime);if(modeOf(stat)!==STATE_FILE_MODE){fail(`${target} must have mode 0600`)}}function readSecureFile(target,maxBytes,runtime){let descriptor;try{descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_RDONLY|import_node_fs.default.constants.O_NOFOLLOW)}catch{fail(`state file is missing, unreadable, or a symlink: ${target}`)}try{const stat=import_node_fs.default.fstatSync(descriptor);requireSecureRegularFileStat(stat,target,runtime);if(stat.size<1||stat.size>maxBytes){fail(`${target} is empty or exceeds its size limit`)}const content=import_node_fs.default.readFileSync(descriptor);if(content.length!==stat.size){fail(`${target} changed while it was being read`)}return content}finally{import_node_fs.default.closeSync(descriptor)}}function writeSecureNewFile(target,content,runtime){let descriptor;try{descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_CREAT|import_node_fs.default.constants.O_EXCL|import_node_fs.default.constants.O_WRONLY|import_node_fs.default.constants.O_NOFOLLOW,STATE_FILE_MODE)}catch{fail(`refused to replace an existing state file: ${target}`)}try{import_node_fs.default.fchownSync(descriptor,runtime.rootUid,runtime.rootGid);import_node_fs.default.fchmodSync(descriptor,STATE_FILE_MODE);import_node_fs.default.writeFileSync(descriptor,content);import_node_fs.default.fsyncSync(descriptor)}finally{import_node_fs.default.closeSync(descriptor)}}function syncDirectory(target){const descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_RDONLY);try{import_node_fs.default.fsyncSync(descriptor)}finally{import_node_fs.default.closeSync(descriptor)}}function randomToken(){return(0,import_node_crypto3.randomBytes)(12).toString("hex")}function stateControl(fingerprint){return{schemaVersion:STATE_SCHEMA_VERSION,fingerprint,generation:`generation-${fingerprint}`}}function serializeStateControl(control){return JSON.stringify({fingerprint:control.fingerprint,generation:control.generation,schemaVersion:control.schemaVersion})}function parseStateControl(target,runtime){const bytes=readSecureFile(target,MAX_CONTROL_FILE_BYTES,runtime);let raw;try{raw=UTF8_DECODER2.decode(bytes)}catch{fail(`${target} is not valid UTF-8`)}let parsed;try{parsed=JSON.parse(raw)}catch{fail(`${target} is not valid JSON`)}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail(`${target} does not contain a valid state control`)}const record=parsed;if(Object.keys(record).sort().join(",")!=="fingerprint,generation,schemaVersion"||record.schemaVersion!==STATE_SCHEMA_VERSION||typeof record.fingerprint!=="string"||!SHA256_RE2.test(record.fingerprint)||record.generation!==`generation-${record.fingerprint}`){fail(`${target} does not contain a valid state control`)}const control=stateControl(record.fingerprint);if(serializeStateControl(control)!==raw){fail(`${target} is not in canonical form`)}return control}function publishStateControlIfAbsent(stateDirectory,basename,control,runtime){const target=import_node_path.default.join(stateDirectory,basename);const temporary=import_node_path.default.join(stateDirectory,`.${basename}-${randomToken()}.tmp`);writeSecureNewFile(temporary,serializeStateControl(control),runtime);try{import_node_fs.default.linkSync(temporary,target)}catch(error){try{unlinkSecureControlOrTemp(temporary,runtime)}catch{}if(error.code==="EEXIST"){return{control:parseStateControl(target,runtime),created:false}}fail(`could not atomically publish ${basename}`)}try{import_node_fs.default.unlinkSync(temporary)}catch(error){if(error.code!=="ENOENT"){fail(`could not finalize atomic publication of ${basename}`)}}syncDirectory(stateDirectory);return{control,created:true}}function validateCorporateCaBytes(bytes){if(bytes.length<1||bytes.length>MANAGED_STARTUP_CA_MAX_BYTES){fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_BYTES)} bytes`)}let pem;try{pem=UTF8_DECODER2.decode(bytes)}catch{fail("corporate CA bundle must be valid UTF-8 PEM")}const matches=[...pem.matchAll(PEM_CERTIFICATE_RE)];if(matches.length<1||matches.length>MANAGED_STARTUP_CA_MAX_CERTIFICATES||matches[0]?.index!==0){fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_CERTIFICATES)} PEM CA certificates`)}let cursor=0;for(const match of matches){const index=match.index;if(index===void 0||!/^(?:\r?\n)+$/u.test(pem.slice(cursor,index))&&index!==0){fail("corporate CA bundle contains non-PEM material between certificates")}const block=match[0];let certificate;try{certificate=new import_node_crypto3.X509Certificate(block)}catch{fail("corporate CA bundle contains an invalid X.509 certificate")}if(!certificate.ca){fail("corporate CA bundle contains a certificate without basicConstraints CA:TRUE")}cursor=index+block.length}if(!/^(?:\r?\n)?$/u.test(pem.slice(cursor))){fail("corporate CA bundle contains trailing non-PEM material")}}function validateManagedStartupCorporateCaTransport(encoded,profile){const expectedDigest=profile.corporateCa.bundleSha256;if(expectedDigest===null){if(encoded!==void 0){fail("corporate CA transport must be absent when the profile has no CA digest")}return null}if(typeof encoded!=="string"||encoded.length===0||encoded.length>Math.ceil(MANAGED_STARTUP_CA_MAX_BYTES/3)*4||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)){fail("corporate CA transport must be canonical standard base64")}const bytes=import_node_buffer3.Buffer.from(encoded,"base64");if(bytes.toString("base64")!==encoded){fail("corporate CA transport must be canonical standard base64")}validateCorporateCaBytes(bytes);const actualDigest=(0,import_node_crypto3.createHash)("sha256").update(bytes).digest("hex");if(actualDigest!==expectedDigest){fail("corporate CA bundle does not match the profile SHA-256 digest")}return bytes}function readCanonicalProfile(profilePath,runtime){const bytes=readSecureFile(profilePath,MANAGED_STARTUP_PROFILE_MAX_BYTES,runtime);let raw;try{raw=UTF8_DECODER2.decode(bytes)}catch{fail(`${profilePath} is not valid UTF-8`)}let parsed;try{parsed=JSON.parse(raw)}catch{fail(`${profilePath} is not valid JSON`)}let profile;try{profile=validateManagedStartupProfile(parsed)}catch(error){fail(`${profilePath} is invalid: ${error.message}`)}if(serializeManagedStartupProfile(profile)!==raw){fail(`${profilePath} is not a canonical managed startup profile`)}return{profile,fingerprint:fingerprintManagedStartupProfile(profile)}}function validateGeneration(stateDirectory,control,runtime,expectedAgent){if(!GENERATION_RE.test(control.generation)){fail("state control names an invalid generation")}const directory=import_node_path.default.join(stateDirectory,control.generation);requireSecureDirectory(directory,runtime,true);const entries=import_node_fs.default.readdirSync(directory).sort();if(entries.some(entry=>entry!=="profile.json"&&entry!=="corporate-ca.pem")||!entries.includes("profile.json")){fail(`${directory} contains missing or unsupported state files`)}const profilePath=import_node_path.default.join(directory,"profile.json");const{profile,fingerprint}=readCanonicalProfile(profilePath,runtime);if(fingerprint!==control.fingerprint){fail(`${directory} does not match its recorded profile fingerprint`)}if(expectedAgent!==void 0&&profile.agent!==expectedAgent){fail(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`)}const caPath=import_node_path.default.join(directory,"corporate-ca.pem");let corporateCaPath=null;if(profile.corporateCa.bundleSha256===null){if(entries.includes("corporate-ca.pem")){fail(`${directory} contains a CA bundle that is absent from the profile`)}}else{if(!entries.includes("corporate-ca.pem")){fail(`${directory} is missing the CA bundle recorded by the profile`)}const caBytes=readSecureFile(caPath,MANAGED_STARTUP_CA_MAX_BYTES,runtime);validateCorporateCaBytes(caBytes);if((0,import_node_crypto3.createHash)("sha256").update(caBytes).digest("hex")!==profile.corporateCa.bundleSha256){fail(`${directory} contains a CA bundle with the wrong SHA-256 digest`)}corporateCaPath=caPath}return{directory,profilePath,corporateCaPath,profile,fingerprint}}function validateDisposableDirectory(target,runtime){requireSecureDirectory(target,runtime,true);const entries=import_node_fs.default.readdirSync(target);if(entries.length>2||entries.some(entry=>entry!=="profile.json"&&entry!=="corporate-ca.pem")){fail(`${target} is not a recognized disposable generation`)}for(const entry of entries){const file=import_node_path.default.join(target,entry);const stat=import_node_fs.default.lstatSync(file);requireSecureRegularFileStat(stat,file,runtime)}}function discardDirectory(target,runtime){validateDisposableDirectory(target,runtime);import_node_fs.default.rmSync(target,{recursive:true})}function discardDirectoryIfPresent(target,runtime){try{import_node_fs.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail(`could not inspect disposable generation ${target}`)}discardDirectory(target,runtime);return true}function unlinkSecureControlOrTemp(target,runtime){const stat=import_node_fs.default.lstatSync(target);requireSecureRegularFileStat(stat,target,runtime);if(stat.size>MAX_CONTROL_FILE_BYTES){fail(`${target} exceeds the state-control size limit`)}import_node_fs.default.unlinkSync(target)}function listStateEntries(stateDirectory){const entries=import_node_fs.default.readdirSync(stateDirectory).sort();if(entries.length>MAX_STATE_ENTRIES){fail(`state directory exceeds ${String(MAX_STATE_ENTRIES)} entries`)}return entries}function unlinkRecoverableControlTemp(stateDirectory,entry,runtime){const temporary=import_node_path.default.join(stateDirectory,entry);const stat=import_node_fs.default.lstatSync(temporary);if(stat.nlink===1){unlinkSecureControlOrTemp(temporary,runtime);return}const basename=entry.startsWith(".committed.json-")?"committed.json":entry.startsWith(".pending.json-")?"pending.json":null;const target=basename===null?null:import_node_path.default.join(stateDirectory,basename);let targetStat=null;try{targetStat=target===null?null:import_node_fs.default.lstatSync(target)}catch{fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`)}if(stat.nlink!==2||targetStat===null||stat.dev!==targetStat.dev||stat.ino!==targetStat.ino||!stat.isFile()||stat.isSymbolicLink()||modeOf(stat)!==STATE_FILE_MODE||stat.size<1||stat.size>MAX_CONTROL_FILE_BYTES){fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`)}requireOwner(stat,temporary,runtime);requireOwner(targetStat,target,runtime);import_node_fs.default.unlinkSync(temporary)}function cleanAtomicTemps(stateDirectory,entries,runtime){let changed=false;for(const entry of entries){const target=import_node_path.default.join(stateDirectory,entry);if(PREPARE_TEMP_RE.test(entry)){discardDirectory(target,runtime);changed=true}else if(CONTROL_TEMP_RE.test(entry)){unlinkRecoverableControlTemp(stateDirectory,entry,runtime);changed=true}}if(changed)syncDirectory(stateDirectory)}function requireKnownStateEntries(stateDirectory,entries){for(const entry of entries){if(entry==="committed.json"||entry==="pending.json"||GENERATION_RE.test(entry)||PREPARE_TEMP_RE.test(entry)||CONTROL_TEMP_RE.test(entry)){continue}fail(`${stateDirectory} contains unsupported state component ${entry}`)}}function discardGenerationsExcept(stateDirectory,keepGeneration,runtime){for(const entry of listStateEntries(stateDirectory)){if(GENERATION_RE.test(entry)&&entry!==keepGeneration){discardDirectoryIfPresent(import_node_path.default.join(stateDirectory,entry),runtime)}}}function optionalStateControl(stateDirectory,basename,runtime){const target=import_node_path.default.join(stateDirectory,basename);try{import_node_fs.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return null;fail(`could not inspect ${target}`)}return parseStateControl(target,runtime)}function removePendingControl(stateDirectory,runtime){try{unlinkSecureControlOrTemp(import_node_path.default.join(stateDirectory,"pending.json"),runtime)}catch(error){if(error.code==="ENOENT")return;throw error}syncDirectory(stateDirectory)}function stateControlsMatch(left,right){return left.fingerprint===right.fingerprint&&left.generation===right.generation}function recoverCommittedState(stateDirectory,committedControl,pendingControl,requested,expectedAgent,runtime){const committed=validateGeneration(stateDirectory,committedControl,runtime,expectedAgent);if(pendingControl)removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,committedControl.generation,runtime);syncDirectory(stateDirectory);if(!stateControlsMatch(committedControl,requested)){fail("a different startup profile is already committed; recreate the sandbox to change it")}return committed}function recoverState(stateDirectory,requested,expectedAgent,runtime){const initialEntries=listStateEntries(stateDirectory);requireKnownStateEntries(stateDirectory,initialEntries);cleanAtomicTemps(stateDirectory,initialEntries,runtime);const initiallyCommittedControl=optionalStateControl(stateDirectory,"committed.json",runtime);const pendingControl=optionalStateControl(stateDirectory,"pending.json",runtime);const committedAfterPendingRead=optionalStateControl(stateDirectory,"committed.json",runtime);const committedControl=committedAfterPendingRead??initiallyCommittedControl;if(committedControl){return{committed:recoverCommittedState(stateDirectory,committedControl,pendingControl,requested,expectedAgent,runtime),pending:null}}if(pendingControl){if(stateControlsMatch(pendingControl,requested)){const pending=validateGeneration(stateDirectory,pendingControl,runtime,expectedAgent);const committedAfterPendingValidation=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedAfterPendingValidation){return{committed:recoverCommittedState(stateDirectory,committedAfterPendingValidation,pendingControl,requested,expectedAgent,runtime),pending:null}}discardGenerationsExcept(stateDirectory,pendingControl.generation,runtime);return{committed:null,pending}}fail("a different startup profile is already pending; wait for it to commit or recreate")}return{committed:null,pending:null}}function createGeneration(stateDirectory,control,profileJson,corporateCa,runtime){const temporaryName=`.prepare-${String(process.pid)}-${randomToken()}`;const temporary=import_node_path.default.join(stateDirectory,temporaryName);const generation=import_node_path.default.join(stateDirectory,control.generation);let renameAttempted=false;try{import_node_fs.default.mkdirSync(temporary,{mode:STATE_DIRECTORY_MODE});import_node_fs.default.chownSync(temporary,runtime.rootUid,runtime.rootGid);import_node_fs.default.chmodSync(temporary,STATE_DIRECTORY_MODE);writeSecureNewFile(import_node_path.default.join(temporary,"profile.json"),profileJson,runtime);if(corporateCa){writeSecureNewFile(import_node_path.default.join(temporary,"corporate-ca.pem"),corporateCa,runtime)}syncDirectory(temporary);renameAttempted=true;import_node_fs.default.renameSync(temporary,generation);syncDirectory(stateDirectory)}catch(error){try{import_node_fs.default.lstatSync(temporary);discardDirectory(temporary,runtime)}catch{}if(error instanceof ManagedStartupApplicationError)throw error;if(renameAttempted&&(error.code==="EEXIST"||error.code==="ENOTEMPTY")){return validateGeneration(stateDirectory,control,runtime)}fail(`could not atomically prepare generation ${control.generation}`)}return validateGeneration(stateDirectory,control,runtime)}function toPrepared(status,stateDirectory,generation,expectedAgent){return{status,stateDirectory,generationDirectory:generation.directory,profilePath:generation.profilePath,corporateCaPath:generation.corporateCaPath,fingerprint:generation.fingerprint,expectedAgent,profile:generation.profile}}function prepareManagedStartupApplication(input,testRuntime){const runtime=runtimeFor(testRuntime);requireContainerRoot();let profile;try{profile=decodeManagedStartupProfile(input.encodedProfile)}catch(error){fail(error.message)}if(profile.agent!==input.expectedAgent){fail(`managed startup profile targets ${profile.agent}, expected ${input.expectedAgent}`)}const corporateCa=validateManagedStartupCorporateCaTransport(input.corporateCaB64,profile);const profileJson=serializeManagedStartupProfile(profile);const control=stateControl(fingerprintManagedStartupProfile(profile));const stateDirectory=ensureStateDirectory(input.stateDirectory,runtime);const recovered=recoverState(stateDirectory,control,input.expectedAgent,runtime);if(recovered.committed){return toPrepared("already-committed",stateDirectory,recovered.committed,input.expectedAgent)}if(recovered.pending){return toPrepared("prepared",stateDirectory,recovered.pending,input.expectedAgent)}const generation=createGeneration(stateDirectory,control,profileJson,corporateCa,runtime);const publication=publishStateControlIfAbsent(stateDirectory,"pending.json",control,runtime);if(publication.control.fingerprint!==control.fingerprint||publication.control.generation!==control.generation){discardDirectoryIfPresent(generation.directory,runtime);syncDirectory(stateDirectory);fail("a different startup profile won the pending-state transaction")}const committedAfterPublication=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedAfterPublication){if(committedAfterPublication.fingerprint!==control.fingerprint||committedAfterPublication.generation!==control.generation){if(publication.created){removePendingControl(stateDirectory,runtime);discardDirectoryIfPresent(generation.directory,runtime);syncDirectory(stateDirectory)}fail("a different startup profile committed during pending-state publication")}const committedGeneration=validateGeneration(stateDirectory,committedAfterPublication,runtime,input.expectedAgent);removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,committedAfterPublication.generation,runtime);return toPrepared("already-committed",stateDirectory,committedGeneration,input.expectedAgent)}const activeGeneration=publication.created?generation:validateGeneration(stateDirectory,publication.control,runtime,input.expectedAgent);return toPrepared("prepared",stateDirectory,activeGeneration,input.expectedAgent)}function validatePreparedHandle(handle){if(!import_node_path.default.isAbsolute(handle.stateDirectory)||!SHA256_RE2.test(handle.fingerprint)||handle.generationDirectory!==import_node_path.default.join(handle.stateDirectory,`generation-${handle.fingerprint}`)||handle.profilePath!==import_node_path.default.join(handle.generationDirectory,"profile.json")||handle.corporateCaPath!==null&&handle.corporateCaPath!==import_node_path.default.join(handle.generationDirectory,"corporate-ca.pem")){fail("prepared startup handle is malformed")}return stateControl(handle.fingerprint)}function commitManagedStartupApplication(prepared,testRuntime){const runtime=runtimeFor(testRuntime);requireContainerRoot();const requested=validatePreparedHandle(prepared);const stateDirectory=ensureStateDirectory(prepared.stateDirectory,runtime);const committedControl=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedControl){if(committedControl.fingerprint!==requested.fingerprint||committedControl.generation!==requested.generation){fail("a different startup profile is already committed")}const generation2=validateGeneration(stateDirectory,committedControl,runtime,prepared.expectedAgent);return{...toPrepared("already-committed",stateDirectory,generation2,prepared.expectedAgent),status:"committed"}}const pendingControl=optionalStateControl(stateDirectory,"pending.json",runtime);if(!pendingControl||pendingControl.fingerprint!==requested.fingerprint||pendingControl.generation!==requested.generation){fail("the prepared startup generation is not the active pending generation")}const generation=validateGeneration(stateDirectory,pendingControl,runtime,prepared.expectedAgent);const publication=publishStateControlIfAbsent(stateDirectory,"committed.json",pendingControl,runtime);if(publication.control.fingerprint!==requested.fingerprint||publication.control.generation!==requested.generation){fail("a different startup profile won the committed-state transaction")}removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,publication.control.generation,runtime);syncDirectory(stateDirectory);return{...toPrepared("already-committed",stateDirectory,generation,prepared.expectedAgent),status:"committed"}}var SHIPPED_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DEFAULT_DEPENDENCIES={prepareApplication:input=>prepareManagedStartupApplication(input),commitApplication:prepared=>commitManagedStartupApplication(prepared)};var ManagedStartupCoordinatorError=class extends Error{constructor(message){super(`Managed startup coordination failed: ${message}`);this.name="ManagedStartupCoordinatorError"}};function fail2(message){throw new ManagedStartupCoordinatorError(message)}function createAdapterRegistry(adapters2){const byAgent=new Map;for(const adapter of adapters2){if(typeof adapter!=="object"||adapter===null||!SHIPPED_AGENT_SET.has(adapter.agent)||typeof adapter.apply!=="function"){fail2("every adapter must identify one shipped agent and provide an apply function")}if(byAgent.has(adapter.agent)){fail2(`duplicate adapter registered for ${adapter.agent}`)}byAgent.set(adapter.agent,adapter)}const missing=MANAGED_STARTUP_AGENTS.filter(agent=>!byAgent.has(agent));if(missing.length>0){fail2(`missing adapter for ${missing.join(", ")}`)}if(byAgent.size!==MANAGED_STARTUP_AGENTS.length){fail2("adapter registry must contain exactly the shipped agents")}return Object.freeze(Object.fromEntries(MANAGED_STARTUP_AGENTS.map(agent=>{const adapter=byAgent.get(agent);if(!adapter)fail2(`missing adapter for ${agent}`);return[agent,adapter]})))}function requirePreparedIdentity(prepared,requestedAgent){if(prepared.expectedAgent!==requestedAgent||prepared.profile.agent!==requestedAgent){fail2(`prepared profile targets ${prepared.profile.agent}, expected ${requestedAgent}`)}}function adapterContext(prepared){return Object.freeze({agent:prepared.profile.agent,profile:prepared.profile,fingerprint:prepared.fingerprint,generationDirectory:prepared.generationDirectory,profilePath:prepared.profilePath,corporateCaPath:prepared.corporateCaPath})}async function coordinateManagedStartupApplication(input,adapters2,dependencies=DEFAULT_DEPENDENCIES){const registry=createAdapterRegistry(adapters2);const prepared=await dependencies.prepareApplication(input);requirePreparedIdentity(prepared,input.expectedAgent);if(prepared.status==="already-committed"){return{adapterApplied:false,application:await dependencies.commitApplication(prepared)}}const adapter=registry[prepared.profile.agent];if(adapter.agent!==prepared.profile.agent){fail2(`adapter registry cross-dispatch detected for ${prepared.profile.agent}`)}await adapter.apply(adapterContext(prepared));return{adapterApplied:true,application:await dependencies.commitApplication(prepared)}}var import_node_crypto4=require("node:crypto");var MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION=1;var MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES=320*1024;var MAX_CORPORATE_CA_ENCODED_BYTES=4*Math.ceil(128*1024/3);var SHA256_RE3=/^[a-f0-9]{64}$/u;var STANDARD_BASE64_RE=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;var MCP_SHADOW_DIAGNOSTICS_ENV="NEMOCLAW_MCP_SHADOW_DIAGNOSTICS";var MANAGED_STARTUP_APPLICATION_RUNTIME_ENV_KEYS=Object.freeze(MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS.openclaw.filter(({admission,owner})=>admission==="managed-launch-forwarded"&&owner==="application-environment").map(({input})=>input));function selectManagedStartupApplicationRuntimeEnvironment(environment){const selected={};for(const name of MANAGED_STARTUP_APPLICATION_RUNTIME_ENV_KEYS){const value=environment[name];if(name===MCP_SHADOW_DIAGNOSTICS_ENV){if(value?.trim()==="1")selected[name]="1";continue}if(value!==void 0)selected[name]=value}return Object.freeze(selected)}function fail3(message){throw new Error(`Managed startup root application request is invalid: ${message}`)}function isManagedStartupRootApplyAgent(value){return typeof value==="string"&&MANAGED_STARTUP_AGENTS.includes(value)}function exactAgent(value){if(isManagedStartupRootApplyAgent(value))return value;return fail3("agent is unsupported")}function createManagedStartupRootApplyRequest(input){const agent=exactAgent(input.agent);if(input.encodedProfile.length===0||input.encodedProfile.length>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES){fail3("encoded profile exceeds its bounded transport")}const profile=decodeManagedStartupProfile(input.encodedProfile);if(profile.agent!==agent){fail3(`profile targets ${profile.agent}, expected ${agent}`)}const corporateCaB64=input.corporateCaB64??null;if(corporateCaB64!==null&&(corporateCaB64.length===0||corporateCaB64.length>MAX_CORPORATE_CA_ENCODED_BYTES||!STANDARD_BASE64_RE.test(corporateCaB64)||Buffer.from(corporateCaB64,"base64").toString("base64")!==corporateCaB64)){fail3("corporate CA is not canonical bounded base64")}if(profile.corporateCa.bundleSha256!==null!==(corporateCaB64!==null)){fail3("corporate CA transport does not match the profile")}if(corporateCaB64!==null&&(0,import_node_crypto4.createHash)("sha256").update(Buffer.from(corporateCaB64,"base64")).digest("hex")!==profile.corporateCa.bundleSha256){fail3("corporate CA does not match the profile digest")}return Object.freeze({schemaVersion:MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION,agent,encodedProfile:input.encodedProfile,profileFingerprint:fingerprintManagedStartupProfile(profile),corporateCaB64})}function serializeManagedStartupRootApplyRequest(request){const normalized=createManagedStartupRootApplyRequest({agent:request.agent,encodedProfile:request.encodedProfile,...request.corporateCaB64===null?{}:{corporateCaB64:request.corporateCaB64}});if(request.schemaVersion!==MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION||request.profileFingerprint!==normalized.profileFingerprint||!SHA256_RE3.test(request.profileFingerprint)){fail3("schema version or profile fingerprint is invalid")}const serialized=`${JSON.stringify({agent:normalized.agent,corporateCaB64:normalized.corporateCaB64,encodedProfile:normalized.encodedProfile,profileFingerprint:normalized.profileFingerprint,schemaVersion:normalized.schemaVersion})} `;if(Buffer.byteLength(serialized,"utf8")>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail3("serialized request exceeds its bounded transport")}return serialized}function parseManagedStartupRootApplyRequest(text){if(text.length===0||Buffer.byteLength(text,"utf8")>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail3("serialized request is empty or too large")}let parsed;try{parsed=JSON.parse(text)}catch{fail3("serialized request is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail3("serialized request must be an object")}const record=parsed;const expectedKeys=["agent","corporateCaB64","encodedProfile","profileFingerprint","schemaVersion"];if(Object.keys(record).sort().join(",")!==expectedKeys.sort().join(",")||record.schemaVersion!==MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION||typeof record.encodedProfile!=="string"||typeof record.profileFingerprint!=="string"||record.corporateCaB64!==null&&typeof record.corporateCaB64!=="string"){fail3("serialized request has an invalid schema")}const request=createManagedStartupRootApplyRequest({agent:exactAgent(record.agent),encodedProfile:record.encodedProfile,...record.corporateCaB64===null?{}:{corporateCaB64:record.corporateCaB64}});if(record.profileFingerprint!==request.profileFingerprint||!SHA256_RE3.test(record.profileFingerprint)){fail3("profile fingerprint does not match the encoded profile")}if(serializeManagedStartupRootApplyRequest(request)!==text){fail3("serialized request is not canonical")}return request}var import_node_crypto5=require("node:crypto");var import_node_fs2=__toESM(require("node:fs"));var import_node_path2=__toESM(require("node:path"));var TRANSACTION_SCHEMA_VERSION=1;var MAX_TRANSACTION_FILES=128;var MAX_TRANSACTION_FILE_BYTES=8*1024*1024;var MAX_TRANSACTION_TOTAL_BYTES=32*1024*1024;var MAX_MANIFEST_BYTES=256*1024;var MAX_COMMIT_RECEIPT_BYTES=4096;var TRANSACTION_PARENT_DIRECTORY_MODE=493;var TRANSACTION_DIRECTORY_MODE=448;var TRANSACTION_FILE_MODE=256;var ATOMIC_TEMPORARY_FILE_MODE=384;var MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY="/var/lib/nemoclaw/managed-startup-shared-state-transaction-v1";var MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY="/run/nemoclaw/managed-startup-shared-rollback-receipt-v1";var MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY="/var/lib/nemoclaw/managed-startup-shared-state-commit-v1";var MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE="receipt.json";function fail4(message){throw new Error(`Managed startup shared-state transaction failed: ${message}`)}function resolveOptions(options={}){const sandboxRoot=import_node_path2.default.resolve(options.sandboxRoot??"/sandbox");const transactionDirectory=import_node_path2.default.resolve(options.transactionDirectory??MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY);const commitReceiptDirectory=import_node_path2.default.resolve(options.commitReceiptDirectory??(options.transactionDirectory?import_node_path2.default.join(import_node_path2.default.dirname(transactionDirectory),import_node_path2.default.basename(MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY)):MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY));if(transactionDirectory===sandboxRoot||transactionDirectory.startsWith(`${sandboxRoot}${import_node_path2.default.sep}`)||commitReceiptDirectory===sandboxRoot||commitReceiptDirectory.startsWith(`${sandboxRoot}${import_node_path2.default.sep}`)||import_node_path2.default.dirname(commitReceiptDirectory)!==import_node_path2.default.dirname(transactionDirectory)||commitReceiptDirectory===transactionDirectory){fail4("transaction and commit receipts require distinct paths outside sandbox-shared state")}const bootstrapIdentity=options.bootstrapIdentity??null;if(bootstrapIdentity!==null&&!/^[a-f0-9]{64}$/u.test(bootstrapIdentity)){fail4("bootstrap identity must encode 32 lowercase-hex bytes")}return{sandboxRoot,transactionParentDirectory:import_node_path2.default.dirname(transactionDirectory),transactionDirectory,backupDirectory:import_node_path2.default.join(transactionDirectory,"backups"),manifestFile:import_node_path2.default.join(transactionDirectory,"manifest.json"),commitReceiptDirectory,commitReceiptFile:import_node_path2.default.join(commitReceiptDirectory,MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE),trustedUid:options.trustedUid??0,trustedGid:options.trustedGid??0,readOnlyReceipt:options.readOnlyReceipt??false,bootstrapIdentity}}function modeOf2(stat){if(typeof stat.mode==="bigint"){return Number(stat.mode&0o7777n)}return stat.mode&4095}function requireTransactionIdentity(options){const expectedUid=options.readOnlyReceipt?0:options.trustedUid;const expectedGid=options.readOnlyReceipt?0:options.trustedGid;if(process.geteuid?.()!==expectedUid||process.getegid?.()!==expectedGid){fail4("transaction control requires the trusted effective identity")}}function pathExistsNoFollow(target){try{import_node_fs2.default.lstatSync(target);return true}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect ${target}`)}}function requireDirectory(target,options,expectedMode=null){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch{fail4(`required directory is missing: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`required directory is unsafe: ${target}`)}if(expectedMode!==null&&(stat.uid!==options.trustedUid||stat.gid!==options.trustedGid||modeOf2(stat)!==expectedMode)){fail4(`${target} must be ${options.trustedUid}:${options.trustedGid} mode ${expectedMode.toString(8)}`)}return stat}function requireTransactionBoundaries(options){requireDirectory(options.sandboxRoot,options);requireDirectory(options.transactionParentDirectory,options,TRANSACTION_PARENT_DIRECTORY_MODE)}function sameStableMetadata(left,right){return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.uid===right.uid&&left.gid===right.gid&&left.size===right.size&&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs}function readStableFile(target,maxBytes){const noFollow=import_node_fs2.default.constants.O_NOFOLLOW;if(typeof noFollow!=="number")fail4("O_NOFOLLOW is unavailable");let descriptor;try{descriptor=import_node_fs2.default.openSync(target,import_node_fs2.default.constants.O_RDONLY|noFollow)}catch{fail4(`could not safely open ${target}`)}try{const before=import_node_fs2.default.fstatSync(descriptor,{bigint:true});if(!before.isFile()||before.nlink!==1n||before.size<0n||before.size>BigInt(maxBytes)){fail4(`refusing unsafe or oversized transaction file ${target}`)}const bytes=Buffer.alloc(Number(before.size));let offset=0;while(offset!segment||segment==="."||segment==="..")){fail4(`unsafe transaction path ${JSON.stringify(value)}`)}return segments.join("/")}function absoluteTarget(relativePath,options){const safe=safeRelativePath(relativePath);const target=import_node_path2.default.resolve(options.sandboxRoot,safe);if(!target.startsWith(`${options.sandboxRoot}${import_node_path2.default.sep}`)){fail4(`transaction target escapes the sandbox root: ${relativePath}`)}return target}function relativeTarget(target,options){return safeRelativePath(import_node_path2.default.relative(options.sandboxRoot,target))}function validateExistingAncestors(target,expectedAgent,options){const relative=relativeTarget(target,options);const sandboxStat=requireDirectory(options.sandboxRoot,options);const outputRoot=agentRoot(expectedAgent,options.sandboxRoot);if(target!==outputRoot&&!target.startsWith(`${outputRoot}${import_node_path2.default.sep}`)){fail4(`transaction target escapes the ${expectedAgent} state root: ${target}`)}let current=options.sandboxRoot;let expectedDevice=sandboxStat.dev;const segments=relative.split("/").slice(0,-1);for(const segment of segments){current=import_node_path2.default.join(current,segment);let stat;try{stat=import_node_fs2.default.lstatSync(current)}catch(error){if(error.code==="ENOENT")return;fail4(`could not inspect transaction path ancestor ${current}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`transaction path ancestor is unsafe: ${current}`)}if(current===outputRoot&&expectedAgent==="hermes"){expectedDevice=stat.dev}else if(stat.dev!==expectedDevice){fail4(`transaction path crosses a nested filesystem mount: ${current}`)}}}function managedOutputDevice(expectedAgent,options){const sandboxStat=requireDirectory(options.sandboxRoot,options);const outputRoot=agentRoot(expectedAgent,options.sandboxRoot);let stat;try{stat=import_node_fs2.default.lstatSync(outputRoot)}catch(error){if(error.code==="ENOENT")return sandboxStat.dev;fail4(`could not inspect managed output root ${outputRoot}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed output root is unsafe: ${outputRoot}`)}if(expectedAgent!=="hermes"&&stat.dev!==sandboxStat.dev){fail4(`managed output root crosses a nested filesystem mount: ${outputRoot}`)}return stat.dev}function agentRoot(agent,sandboxRoot){switch(agent){case"openclaw":return import_node_path2.default.join(sandboxRoot,".openclaw");case"hermes":return import_node_path2.default.join(sandboxRoot,".hermes");case"langchain-deepagents-code":return import_node_path2.default.join(sandboxRoot,".deepagents");case"pi":return import_node_path2.default.join(sandboxRoot,".pi")}}function resolveUnderAgentRoot(root,relativePath){const safe=safeRelativePath(relativePath);const target=import_node_path2.default.resolve(root,safe);if(!target.startsWith(`${root}${import_node_path2.default.sep}`)){fail4(`managed output escapes the agent root: ${relativePath}`)}return target}function renderTarget(root,agent,target){if(agent==="openclaw"&&target==="openclaw.json"){return import_node_path2.default.join(root,"openclaw.json")}const prefix=agent==="openclaw"?"~/.openclaw/":agent==="hermes"?"~/.hermes/":null;if(!prefix||!target.startsWith(prefix)){fail4(`unsupported managed messaging render target ${JSON.stringify(target)}`)}return resolveUnderAgentRoot(root,target.slice(prefix.length))}function managedOutputTargets(profile,options){const root=agentRoot(profile.agent,options.sandboxRoot);const files=new Set;const directories=new Set([root]);switch(profile.agent){case"openclaw":files.add(import_node_path2.default.join(root,"openclaw.json"));files.add(import_node_path2.default.join(root,".config-hash"));break;case"hermes":files.add(import_node_path2.default.join(root,"config.yaml"));files.add(import_node_path2.default.join(root,".env"));files.add(import_node_path2.default.join(root,".config-hash"));break;case"langchain-deepagents-code":files.add(import_node_path2.default.join(root,"config.toml"));directories.add(import_node_path2.default.join(root,".state"));directories.add(import_node_path2.default.join(root,"skills"));break;case"pi":directories.add(import_node_path2.default.join(root,"agent"));files.add(import_node_path2.default.join(root,"agent","models.json"));break}if(profile.messaging.plan!==null){const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:profile.agent});if(!plan)fail4("managed messaging plan is invalid");for(const render of selectEnabledMessagingAgentRender(plan)){if(typeof render.target!=="string")continue;files.add(renderTarget(root,profile.agent,render.target))}for(const step of selectEnabledPostAgentInstallBuildFiles(plan)){if(typeof step.value!=="object"||step.value===null){continue}const outputPath=step.value.path;if(typeof outputPath==="string"){files.add(resolveUnderAgentRoot(root,outputPath))}}}for(const file of files){let parent=import_node_path2.default.dirname(file);while(parent!==options.sandboxRoot&&parent.startsWith(`${root}${import_node_path2.default.sep}`)){directories.add(parent);if(parent===root)break;parent=import_node_path2.default.dirname(parent)}}return{files:[...files].sort(),directories:[...directories].sort((left,right)=>left.split(import_node_path2.default.sep).length-right.split(import_node_path2.default.sep).length)}}function snapshotFile(target,index,expectedAgent,options){validateExistingAncestors(target,expectedAgent,options);let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT"){return{receipt:{path:relativeTarget(target,options),state:"absent"},bytes:null}}fail4(`could not inspect managed output ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1){fail4(`managed output is not a safe regular file: ${target}`)}if(stat.dev!==managedOutputDevice(expectedAgent,options)){fail4(`managed output crosses a nested filesystem mount: ${target}`)}const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);const size=Number(stable.stat.size);const backup=`${String(index).padStart(3,"0")}.bin`;return{receipt:{path:relativeTarget(target,options),state:"file",backup,sha256:(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex"),size,uid:Number(stable.stat.uid),gid:Number(stable.stat.gid),mode:Number(stable.stat.mode&0o7777n)},bytes:stable.bytes}}function snapshotDirectory(target,expectedAgent,options){validateExistingAncestors(import_node_path2.default.join(target,".receipt"),expectedAgent,options);let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT"){return{path:relativeTarget(target,options),state:"absent"}}fail4(`could not inspect managed output directory ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed output directory is unsafe: ${target}`)}if(stat.dev!==managedOutputDevice(expectedAgent,options)){fail4(`managed output directory crosses a nested filesystem mount: ${target}`)}return{path:relativeTarget(target,options),state:"directory",uid:stat.uid,gid:stat.gid,mode:modeOf2(stat)}}function atomicWriteTrustedFile(target,contents,mode,uid,gid){const parent=import_node_path2.default.dirname(target);const temporary=import_node_path2.default.join(parent,`.${import_node_path2.default.basename(target)}.${(0,import_node_crypto5.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs2.default.openSync(temporary,import_node_fs2.default.constants.O_CREAT|import_node_fs2.default.constants.O_EXCL|import_node_fs2.default.constants.O_WRONLY|import_node_fs2.default.constants.O_NOFOLLOW,384);import_node_fs2.default.writeFileSync(descriptor,contents);import_node_fs2.default.fchownSync(descriptor,uid,gid);import_node_fs2.default.fchmodSync(descriptor,mode);import_node_fs2.default.fsyncSync(descriptor);import_node_fs2.default.closeSync(descriptor);descriptor=void 0;import_node_fs2.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs2.default.closeSync(descriptor);try{import_node_fs2.default.unlinkSync(temporary)}catch{}fail4(`could not atomically write ${target}: ${error.message}`)}}function fsyncDirectory(directory){const descriptor=import_node_fs2.default.openSync(directory,import_node_fs2.default.constants.O_RDONLY);try{import_node_fs2.default.fsyncSync(descriptor)}finally{import_node_fs2.default.closeSync(descriptor)}}function canonicalManifest(manifest){return`${JSON.stringify(manifest,null,2)} `}function canonicalLegacyManifest(manifest){return`${JSON.stringify({schemaVersion:manifest.schemaVersion,agent:manifest.agent,profileFingerprint:manifest.profileFingerprint,files:manifest.files,directories:manifest.directories},null,2)}