From 2ce21b63e02202c2bf7b810f3adaaa817885a94d Mon Sep 17 00:00:00 2001 From: cjagwani Date: Wed, 8 Jul 2026 19:39:15 +0000 Subject: [PATCH 1/3] fix(onboard): diagnose incomplete custom plugin images Fresh verified-history replacement for #6250; preserves its reviewed net diff on current main. Signed-off-by: cjagwani Co-authored-by: Aaron Erickson Co-authored-by: Apurv Kumaria --- .github/workflows/e2e.yaml | 67 + .github/workflows/regression-e2e.yaml | 11 +- ci/test-file-size-budget.json | 4 +- docs/deployment/install-openclaw-plugins.mdx | 246 +- docs/get-started/quickstart.mdx | 3 +- docs/manage-sandboxes/lifecycle.mdx | 3 +- docs/reference/commands.mdx | 19 +- docs/reference/troubleshooting.mdx | 16 + ...eather-plugin-fixture-dependency-review.md | 56 + .../sandbox/rebuild-backup-phase.test.ts | 101 + .../actions/sandbox/rebuild-backup-phase.ts | 51 +- .../rebuild-dcode-pre-delete-drift.test.ts | 11 +- .../sandbox/rebuild-dcode-recovery.test.ts | 1 + .../rebuild-local-provider-recreate.test.ts | 1 + .../sandbox/rebuild-preflight-guards.ts | 20 + .../sandbox/rebuild-preflight-phase.ts | 35 +- .../sandbox/rebuild-prepared-recovery.test.ts | 2 + .../sandbox/rebuild-prepared-recovery.ts | 48 +- .../sandbox/rebuild-restore-phase.test.ts | 49 +- .../actions/sandbox/rebuild-restore-phase.ts | 11 +- src/lib/onboard.ts | 49 +- .../created-sandbox-finalization.test.ts | 264 +- .../onboard/created-sandbox-finalization.ts | 67 +- .../custom-openclaw-runtime-diagnosis.test.ts | 98 + .../custom-openclaw-runtime-diagnosis.ts | 109 + src/lib/onboard/not-ready-recreate.test.ts | 122 +- src/lib/onboard/not-ready-recreate.ts | 75 +- .../sandbox-backup-on-recreate.test.ts | 40 + src/lib/onboard/sandbox-backup-on-recreate.ts | 30 +- .../sandbox-recreate-protection.test.ts | 63 + .../onboard/sandbox-recreate-protection.ts | 59 + src/lib/onboard/sandbox-registration.test.ts | 16 + src/lib/onboard/sandbox-registration.ts | 10 + src/lib/state/openclaw-config-merge.test.ts | 262 + src/lib/state/openclaw-config-merge.ts | 188 +- .../openclaw-config-restore-input.test.ts | 48 + .../state/openclaw-config-restore-input.ts | 35 +- .../state/openclaw-managed-extensions.test.ts | 66 +- src/lib/state/openclaw-managed-extensions.ts | 54 +- src/lib/state/openclaw-plugin-restore.test.ts | 487 ++ src/lib/state/openclaw-plugin-restore.ts | 476 ++ src/lib/state/registry.ts | 9 + .../sandbox-openclaw-plugin-restore.test.ts | 116 + src/lib/state/sandbox.ts | 225 +- src/lib/verify-deployment.test.ts | 85 + src/lib/verify-deployment.ts | 45 +- test/e2e-fixture-dependency-review.test.ts | 83 + test/e2e/fixtures/plugins/weather/.gitignore | 5 + .../plugins/weather/openclaw.plugin.json | 19 + .../plugins/weather/package-lock.json | 5153 +++++++++++++++++ .../e2e/fixtures/plugins/weather/package.json | 41 + .../e2e/fixtures/plugins/weather/src/index.ts | 36 + .../fixtures/plugins/weather/src/version.ts | 4 + .../fixtures/plugins/weather/tsconfig.json | 15 + .../openclaw-plugin-runtime-exdev.test.ts | 1134 +++- test/e2e/support/e2e-workflow.test.ts | 4 + ...in-runtime-exdev-workflow-boundary.test.ts | 143 + ...platform-parity-cloud-experimental.test.ts | 30 + ...ad-e2e-artifacts-workflow-boundary.test.ts | 4 +- test/helpers/onboard-openshell-fixture.ts | 20 + test/helpers/rebuild-flow-harness.ts | 23 +- test/helpers/rebuild-flow-lifecycle-cases.ts | 71 + test/helpers/rebuild-flow-recovery-cases.ts | 34 + test/helpers/rebuild-flow-test-harness.ts | 76 +- test/helpers/rebuild-flow-test-support.ts | 3 + test/langchain-deepagents-code-image.test.ts | 1 - test/onboard-custom-dockerfile.test.ts | 19 +- test/onboard-installer-restore-intent.test.ts | 12 +- test/onboard-messaging.test.ts | 29 +- test/onboard.test.ts | 22 +- test/registry.test.ts | 41 + test/regression-e2e-workflow.test.ts | 10 +- test/security-sandbox-tar-traversal.test.ts | 34 +- test/shellquote-sandbox.test.ts | 7 +- ...apshot-openclaw-managed-extensions.test.ts | 323 ++ test/snapshot-recovery-validation.test.ts | 84 + test/snapshot.test.ts | 170 +- ...upload-e2e-artifacts-workflow-boundary.mts | 4 +- tsconfig.cli.json | 2 +- 79 files changed, 11007 insertions(+), 502 deletions(-) create mode 100644 docs/security/e2e-weather-plugin-fixture-dependency-review.md create mode 100644 src/lib/onboard/custom-openclaw-runtime-diagnosis.test.ts create mode 100644 src/lib/onboard/custom-openclaw-runtime-diagnosis.ts create mode 100644 src/lib/onboard/sandbox-recreate-protection.test.ts create mode 100644 src/lib/onboard/sandbox-recreate-protection.ts create mode 100644 src/lib/state/openclaw-plugin-restore.test.ts create mode 100644 src/lib/state/openclaw-plugin-restore.ts create mode 100644 src/lib/state/sandbox-openclaw-plugin-restore.test.ts create mode 100644 test/e2e-fixture-dependency-review.test.ts create mode 100644 test/e2e/fixtures/plugins/weather/.gitignore create mode 100644 test/e2e/fixtures/plugins/weather/openclaw.plugin.json create mode 100644 test/e2e/fixtures/plugins/weather/package-lock.json create mode 100644 test/e2e/fixtures/plugins/weather/package.json create mode 100644 test/e2e/fixtures/plugins/weather/src/index.ts create mode 100644 test/e2e/fixtures/plugins/weather/src/version.ts create mode 100644 test/e2e/fixtures/plugins/weather/tsconfig.json create mode 100644 test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts create mode 100644 test/helpers/onboard-openshell-fixture.ts create mode 100644 test/snapshot-openclaw-managed-extensions.test.ts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index c9735f19b2..0a62fff8ae 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -3732,6 +3732,72 @@ jobs: if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + # Scheduled coverage for #6108 / #3513 / #3127. This exercises the complete + # managed v0.0.71 runtime with a custom plugin, then proves restart/rebuild + # persistence and target-side runtime-dependency replacement across devices. + openclaw-plugin-runtime-exdev: + needs: generate-matrix + if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',openclaw-plugin-runtime-exdev,') || contains(format(',{0},', inputs.targets), ',openclaw-plugin-runtime-exdev,') }} + runs-on: ubuntu-latest + permissions: + contents: read + # Three bounded 25-minute onboards plus the 20-minute rebuild and 15-minute + # Vitest buffer need 110 minutes; allow 20 more for setup and teardown. + timeout-minutes: 130 + env: + E2E_JOB: "1" + E2E_TARGET_ID: "openclaw-plugin-runtime-exdev" + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_RUN_LIVE_E2E: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-openclaw-plugin-exdev" + OPENSHELL_GATEWAY: "nemoclaw" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - *dockerhub-auth + + - name: Pre-pull release-matched Docker Hub builder image + shell: bash + run: | + set -euo pipefail + docker pull node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d + + - name: Remove Docker auth before release-pinned fixture + if: always() + shell: bash + run: | + set -euo pipefail + bash .github/scripts/docker-auth-cleanup.sh + + - name: Prepare E2E workspace + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + + - name: Run OpenClaw custom-plugin lifecycle and runtime-deps EXDEV live test + run: | + set -euo pipefail + test -n "${DOCKER_CONFIG:-}" + test ! -e "${DOCKER_CONFIG}" + test -z "${DOCKERHUB_USERNAME:-}" + test -z "${DOCKERHUB_TOKEN:-}" + env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN \ + npx vitest run --project e2e-live \ + test/e2e/live/openclaw-plugin-runtime-exdev.test.ts \ + --silent=false --reporter=default + + - name: Upload OpenClaw plugin runtime-deps EXDEV artifacts + if: always() + uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + + - name: Clean up Docker auth + if: always() + shell: bash + run: bash .github/scripts/docker-auth-cleanup.sh + # The #2603/#3145 OpenClaw websocket protocol/history contract. openclaw-tui-chat-correlation: needs: generate-matrix @@ -4694,6 +4760,7 @@ jobs: diagnostics, snapshot-commands, gateway-drift-preflight, + openclaw-plugin-runtime-exdev, openclaw-tui-chat-correlation, gateway-guard-recovery, issue-4434-tui-unreachable-inference, diff --git a/.github/workflows/regression-e2e.yaml b/.github/workflows/regression-e2e.yaml index 5472018f37..5687c6da71 100644 --- a/.github/workflows/regression-e2e.yaml +++ b/.github/workflows/regression-e2e.yaml @@ -207,7 +207,6 @@ jobs: /tmp/nemoclaw-e2e-openshell-version-pin-downloads.log if-no-files-found: ignore - # ── Gateway drift preflight E2E ───────────────────────────── # Coverage guard for #3399 / #3423. A stale OpenShell gateway image can # make sandbox-state RPCs fail with protobuf invalid-wire decode errors. @@ -268,8 +267,8 @@ jobs: /tmp/nemoclaw-e2e-model-router-response.log if-no-files-found: ignore - # ── OpenClaw plugin runtime-deps EXDEV E2E ───────────────────── - # Coverage guard for #3513 / #3127. On Ubuntu/OpenShell sandbox layouts + # ── OpenClaw custom-plugin lifecycle and runtime-deps EXDEV E2E ─ + # Coverage guard for #6108 / #3513 / #3127. On Ubuntu/OpenShell sandbox layouts # where /tmp and /sandbox can live on different filesystems, OpenClaw's # first CLI bootstrap must not fail plugin runtime dependency installation # with EXDEV cross-device rename errors. @@ -281,7 +280,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - timeout-minutes: 45 + # Three bounded 25-minute onboards plus the 20-minute rebuild and 15-minute + # Vitest buffer need 110 minutes; allow 20 more for setup and teardown. + timeout-minutes: 130 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -300,7 +301,7 @@ jobs: - name: Build CLI run: npm run build:cli - - name: Run OpenClaw plugin runtime-deps EXDEV Vitest test + - name: Run OpenClaw custom-plugin lifecycle and runtime-deps EXDEV Vitest test env: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev NEMOCLAW_RUN_LIVE_E2E: "1" diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 5d201ce3f6..f84888777c 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,9 +8,9 @@ "test/generate-openclaw-config.test.ts": 1941, "test/install-preflight.test.ts": 3934, "test/nemoclaw-start.test.ts": 4826, - "test/onboard-messaging.test.ts": 2062, + "test/onboard-messaging.test.ts": 2049, "test/onboard-selection.test.ts": 4771, - "test/onboard.test.ts": 4043, + "test/onboard.test.ts": 4039, "test/policies.test.ts": 2243 } } diff --git a/docs/deployment/install-openclaw-plugins.mdx b/docs/deployment/install-openclaw-plugins.mdx index 472220b1b5..8185fdcf27 100644 --- a/docs/deployment/install-openclaw-plugins.mdx +++ b/docs/deployment/install-openclaw-plugins.mdx @@ -3,14 +3,22 @@ # SPDX-License-Identifier: Apache-2.0 title: "Install OpenClaw Plugins" sidebar-title: "Install OpenClaw Plugins" -description: "How to install OpenClaw plugins in a NemoClaw-managed sandbox today." -description-agent: "Explains the difference between OpenClaw plugins and agent skills, and shows the current Dockerfile-based workflow for baking a plugin into a NemoClaw sandbox, including `.dockerignore` handling for custom build contexts. Use when users ask how to install, build, or configure OpenClaw plugins under NemoClaw." +description: "How to install an OpenClaw plugin from a version-matched full NemoClaw runtime source context." +description-agent: "Explains the difference between OpenClaw plugins and agent skills, the complete-image contract of `nemoclaw onboard --from`, and the source-based workflow for adding a plugin without removing the managed runtime. Use when users ask how to install, build, or configure OpenClaw plugins under NemoClaw." keywords: ["nemoclaw plugins", "openclaw plugins", "install openclaw plugin", "nemoclaw onboard from dockerfile", "nemoclaw dockerignore"] content: type: "how_to" skill: priority: 20 --- + + +This is a source-based workaround until the managed plugin lifecycle in [issue #5998](https://github.com/NVIDIA/NemoClaw/issues/5998) is available. +It requires a source checkout and base image that exactly match your installed NemoClaw CLI. +The example below targets NemoClaw `v0.0.71` and its pinned OpenClaw `2026.5.27` runtime. +For another release, replace every `v0.0.71` occurrence and use the OpenClaw version pinned by that release wherever this example uses `2026.5.27`. + + OpenClaw plugins extend the OpenClaw runtime with hooks, services, tools, or provider integrations. They are different from NemoClaw-managed agent skills: @@ -18,58 +26,203 @@ They are different from NemoClaw-managed agent skills: - **Skills** are `SKILL.md` directories that teach an agent how to perform a task. - **Policy presets** are network-egress rules that control what sandboxed code can reach. -To install supported OpenClaw plugins under NemoClaw, bake the plugin into a custom sandbox image and onboard from that Dockerfile. +Until NemoClaw provides that managed lifecycle, bake the plugin into a version-matched full NemoClaw runtime image. + +## Understand the Custom Image Contract + +The `--from` option supplies the complete sandbox image definition. +It does not add your Dockerfile as a layer on top of NemoClaw's normal managed runtime. + + +Do not start a custom OpenClaw image from `ghcr.io/nvidia/nemoclaw/sandbox-base` alone. +That intermediate image contains Node.js, OpenClaw, and other runtime dependencies, but it does not contain `nemoclaw-start`, the generated `openclaw.json`, or the managed gateway health check. +A sandbox created from the base image alone can report a successful create while the gateway and dashboard remain unavailable. + + +`nemoclaw onboard --from ` uses the Dockerfile's parent directory as its Docker build context. +The workaround therefore starts with the complete source context for the installed NemoClaw release. + +For a first-class `add`, `list`, `status`, and `remove` lifecycle that does not require a source checkout, follow [issue #5998](https://github.com/NVIDIA/NemoClaw/issues/5998). + +## Prepare a Version-Matched Build Context -## Prepare a Build Directory +Use the same NemoClaw release for the installed CLI, source checkout, and `sandbox-base` image. +The following commands reproduce the workflow for NemoClaw `v0.0.71`, which includes OpenClaw `2026.5.27`. -Place the Dockerfile and everything it needs to `COPY` in one directory. -`nemoclaw onboard --from ` uses the Dockerfile's parent directory as the Docker build context. -Add a `.dockerignore` next to the Dockerfile to exclude local caches, generated artifacts, model files, or other paths that are not needed by the image build. -NemoClaw still applies its own secret-safety exclusions for credential-like paths such as `.env*`, `.ssh/`, `.aws/`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`, even if `.dockerignore` negates them. +```bash +nemoclaw --version +git clone --depth 1 --branch v0.0.71 https://github.com/NVIDIA/NemoClaw.git my-plugin-sandbox +cp -R /path/to/my-plugin ./my-plugin-sandbox/my-plugin +cd my-plugin-sandbox +``` + +For a different release, replace `v0.0.71` everywhere in this workflow and start from that release's stock Dockerfile. +Do not reuse this patch across releases because the managed image contract can change. + +The plugin directory must contain the inputs used by its build, including its manifest and dependency lockfile. +This example expects the following files: ```text -my-plugin-sandbox/ -├── Dockerfile -└── my-plugin/ - ├── package-lock.json - ├── package.json - └── src/ +my-plugin/ +├── openclaw.plugin.json +├── package-lock.json +├── package.json +├── src/ +└── tsconfig.json ``` The example Dockerfile uses `npm ci`, so `my-plugin/` must include `package-lock.json`. If your plugin does not have a lockfile yet, create one in the plugin project with `npm install --package-lock-only`. +OpenClaw `2026.5.27` also requires the plugin manifest to declare each registered tool in `contracts.tools`; for example, a weather plugin that registers `get_weather` must declare `"tools": ["get_weather"]`. + +Declare OpenClaw as both a runtime peer and an exact, release-matched development dependency. +The peer range expresses runtime compatibility, while the development dependency provides the plugin SDK during the build: + +```json +{ + "devDependencies": { + "openclaw": "2026.5.27" + }, + "peerDependencies": { + "openclaw": ">=2026.5.17" + } +} +``` -## Example Dockerfile +With current npm versions, `npm ci` installs peer dependencies automatically. +The build stage below therefore omits both development and peer dependencies after compilation so the staged plugin does not carry a private OpenClaw runtime. -Use the custom image to copy the plugin into the OpenClaw extensions directory. -Then let OpenClaw refresh its config before NemoClaw starts the sandbox. +## Extend the Full Managed Dockerfile -```dockerfile -ARG SANDBOX_BASE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest -FROM ${SANDBOX_BASE} +Make two changes to the stock `Dockerfile` from the `v0.0.71` checkout. +First, pin the base image to the same release. -COPY my-plugin/ /opt/my-plugin/ -WORKDIR /opt/my-plugin -RUN npm ci --no-audit --no-fund && npm run build +```diff +-ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest ++ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:v0.0.71 +``` -RUN mkdir -p /sandbox/.openclaw/extensions \ - && cp -a /opt/my-plugin /sandbox/.openclaw/extensions/my-plugin \ - && openclaw doctor --fix +Next, name the completed runtime stage so the plugin layer can extend it. -WORKDIR /opt/nemoclaw +```diff +-FROM ${BASE_IMAGE} ++FROM ${BASE_IMAGE} AS nemoclaw-runtime ``` -If the plugin needs configuration in `openclaw.json`, apply it after `openclaw doctor --fix` so the base config exists first. +Append the following stages to the end of the stock Dockerfile. +Replace `weather` with the plugin ID from `openclaw.plugin.json` in the stage name, staging directory, and OpenClaw plugin commands. -## Create the Sandbox +```dockerfile +# Build the plugin from its lockfile. +FROM builder AS weather-plugin-builder +WORKDIR /opt/my-plugin +COPY my-plugin/package.json my-plugin/package-lock.json my-plugin/tsconfig.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY my-plugin/openclaw.plugin.json ./ +COPY my-plugin/src/ ./src/ +RUN npm run build \ + && npm prune --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund \ + && test ! -e node_modules/openclaw + +# Extend the completed managed runtime. +FROM nemoclaw-runtime AS weather-runtime +ARG NEMOCLAW_TOOL_DISCLOSURE=progressive +ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} +COPY --from=weather-plugin-builder --chown=sandbox:sandbox \ + /opt/my-plugin/package.json \ + /opt/my-plugin/package-lock.json \ + /opt/my-plugin/openclaw.plugin.json \ + /opt/weather-plugin/ +COPY --from=weather-plugin-builder --chown=sandbox:sandbox \ + /opt/my-plugin/dist/ /opt/weather-plugin/dist/ +COPY --from=weather-plugin-builder --chown=sandbox:sandbox \ + /opt/my-plugin/node_modules/ /opt/weather-plugin/node_modules/ + +USER sandbox +RUN test ! -e /opt/weather-plugin/node_modules/openclaw \ + && HOME=/sandbox openclaw plugins install /opt/weather-plugin \ + && test -L /sandbox/.openclaw/extensions/weather/node_modules/openclaw \ + && test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = /usr/local/lib/node_modules/openclaw \ + && HOME=/sandbox openclaw plugins enable weather \ + && HOME=/sandbox openclaw plugins inspect weather --json > /dev/null + +# Enabling the plugin changes openclaw.json after the managed runtime hashes it. +# hadolint ignore=DL3002 +USER root +RUN chown sandbox:sandbox /sandbox/.openclaw/openclaw.json \ + && chmod 660 /sandbox/.openclaw/openclaw.json \ + && sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash \ + && chown sandbox:sandbox /sandbox/.openclaw/.config-hash \ + && chmod 660 /sandbox/.openclaw/.config-hash +``` + +The final stage inherits the stock runtime entrypoint, command, gateway health check, generated configuration, and file permissions. +It redeclares and promotes the tool-disclosure build argument because Docker build arguments are scoped to a stage and the appended plugin stage becomes the final image stage. +The local install copies the staged plugin into OpenClaw's extensions tree, records the install, links it to the image's OpenClaw runtime, and leaves existing managed plugin load paths intact before the explicit enable and inspect steps. +The pre-install `test` commands fail the image build if npm retained a private `node_modules/openclaw` directory that would prevent OpenClaw from creating its runtime link. +The post-install checks then prove that OpenClaw created the link and that it resolves to the image's global runtime. +The last `RUN` refreshes the managed config hash after `openclaw plugins enable` updates `openclaw.json`. + +## Create and Verify the Sandbox + +Pin NemoClaw's base-image resolver to the same release when you onboard. + +```bash +NEMOCLAW_SANDBOX_BASE_IMAGE_REF=ghcr.io/nvidia/nemoclaw/sandbox-base:v0.0.71 \ + nemoclaw onboard \ + --fresh \ + --no-gpu \ + --name weather-agent \ + --from "$PWD/Dockerfile" +``` -Point `nemoclaw onboard --from` at the Dockerfile in the build directory. +Verify the managed runtime and plugin after onboarding completes. ```bash -nemoclaw onboard --from ./my-plugin-sandbox/Dockerfile +nemoclaw weather-agent status +nemoclaw weather-agent exec -- test -s /tmp/gateway.log +nemoclaw weather-agent exec -- env HOME=/sandbox openclaw plugins inspect weather --runtime --json +nemoclaw weather-agent exec -- bash -lc \ + '. /tmp/nemoclaw-proxy-env.sh && printf "header = \"Authorization: Bearer %s\"\n" "$OPENCLAW_GATEWAY_TOKEN" | curl --noproxy "*" --max-time 30 --silent --show-error --fail-with-body --config - -H "Content-Type: application/json" --data "{\"agentId\":\"main\",\"tool\":\"get_weather\",\"args\":{\"location\":\"Santa Clara\"}}" "http://127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}/tools/invoke"' ``` -To run a second sandbox alongside an existing one, use a dedicated build directory and rerun onboarding with the sandbox name and ports you intend to use. +The plugin inspection must report the loaded `weather` plugin and list `get_weather` in `toolNames`. +The authenticated HTTP invocation must return the deterministic `Santa Clara` weather result. +The piped curl config keeps the managed gateway token out of process arguments. +NemoClaw's live regression also verifies that the running gateway's tool catalog contains `get_weather`. + +Keep the source checkout and Dockerfile at the recorded path if you plan to run `nemoclaw weather-agent rebuild --yes`. +This source-based workflow can reproduce the plugin during that rebuild, but it is not the durable managed-plugin lifecycle proposed in issue #5998. + + +The provenance safeguards below start in NemoClaw `v0.0.76`. +The `v0.0.71` worked example demonstrates custom-image construction but does not provide these lifecycle guards. +To rely on them, use NemoClaw `v0.0.76` or later and match its CLI, source checkout, base image, and pinned OpenClaw version. + + +Starting with `v0.0.76`, custom-image onboarding records validated plugin IDs and canonical install paths from OpenClaw's install index. +It derives direct extension-directory ownership from those paths and records exact configured load paths owned by linked `path` installs. +Use `openclaw plugins install` as shown above; manually copied, unregistered extension directories are outside this provenance contract. +During rebuild or warm recreation, the new image remains authoritative for those image-owned plugins: updated plugins keep the fresh image copy, removed plugins stay removed, and renamed plugins do not restore their old IDs. +NemoClaw continues to restore backup-only user plugins and their configuration. +Before rebuild or the default state-preserving warm recreation path deletes an existing custom-image sandbox, NemoClaw requires complete, validated provenance from the registry or selected backup. +If that previous provenance is missing or invalid, the operation stops with the existing sandbox and backup untouched. +Setting `NEMOCLAW_RECREATE_WITHOUT_BACKUP=1` deliberately bypasses the warm-recreation backup and provenance guard; use it only after making an independent backup and accepting loss of the current sandbox state. +After initial onboarding or replacement creation, NemoClaw validates the fresh image again before it restores state or publishes registry metadata. +If fresh discovery or restore-time reconciliation fails, NemoClaw does not publish the fresh replacement metadata. +Direct onboarding or recreation leaves the created sandbox unregistered; rebuild preserves the backup, restores retry metadata, and prints manual recovery commands. +Preserve any reported backup, then inspect and remove an unregistered sandbox with OpenShell before you retry the original onboarding command. +If the fresh image itself fails validation, correct its plugin install records or linked load paths before you retry onboarding. +Custom-image sandboxes created before `v0.0.76` cannot enter the state-preserving path automatically when they lack recorded provenance. +Keep the older sandbox intact, onboard the current release under a different name, and migrate its state manually. +Rerun the plugin inspection and HTTP invocation after a gateway restart or sandbox rebuild to verify that the plugin remains available. + +If the plugin imports runtime packages, keep those packages in `dependencies` rather than `devDependencies` so the prune step preserves them. +Keep `openclaw` in the release-matched peer and development dependency fields shown above; do not move it to `dependencies`. +If the plugin needs configuration in `openclaw.json`, apply it before refreshing `.config-hash`. + +Never bake plugin credentials into the Dockerfile or `openclaw.json`. +Use OpenShell credential providers for secrets. ## Build Performance @@ -77,26 +230,9 @@ Custom plugin images are normal Docker builds, so build time depends on the buil NemoClaw sends user-supplied `--from` contexts to the OpenShell gateway builder and reserves its host-side local BuildKit prebuild for contexts that NemoClaw generates itself. On a local Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and the custom image build continues through the gateway. -Keep the build context small and dedicated. -The Dockerfile's parent directory is staged as the build context before the Docker build starts, so a broad directory can make onboarding look stuck while Docker is only preparing context. -A small build directory stages quickly: - -```text -my-plugin-sandbox/ # fast: only what the image needs -├── Dockerfile -├── .dockerignore -└── my-plugin/ -``` - -A Dockerfile placed in a large tree stages slowly: - -```text -~/ # slow: stages the whole home directory -├── Dockerfile -├── Downloads/ -├── datasets/ -└── models/ -``` +Keep the Dockerfile at the release checkout root because the full managed image copies repository scripts, blueprint files, and the built-in NemoClaw plugin. +Use the checkout's `.dockerignore`, and do not place unrelated datasets, model files, caches, or credentials under that directory. +NemoClaw also applies secret-safety exclusions for credential-like paths such as `.env*`, `.ssh/`, `.aws/`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`. Distinguish cold builds from warm rebuilds. The first build on a fresh host is a cold build that downloads the base image and package indexes, so it is the slowest run. @@ -128,7 +264,10 @@ For custom preset workflows, refer to [Customize Network Policy](../network-poli The following mistakes commonly mix plugin installation with other NemoClaw extension paths. - Do not use `nemoclaw skill install` for OpenClaw plugins. That command only installs `SKILL.md` agent skills. -- Do not put a Dockerfile in a broad directory such as `/tmp` unless you intend to send that whole directory as the Docker build context. +- Do not use `sandbox-base` as the final custom image. It is an intermediate dependency image. +- Do not combine one NemoClaw release's Dockerfile with another release's base image. +- Do not copy a plugin into `/sandbox/.openclaw/extensions/` and then run `plugins install --link` on that same path. Stage it outside the managed extensions directory and use the local install shown above. +- Do not move or delete the recorded source checkout before rebuilding the sandbox. - Do not rely on `.dockerignore` to include credential-like paths; NemoClaw excludes those from staged custom build contexts for safety. - Keep plugin dependencies in the build stage or plugin directory, and avoid copying unrelated host files into the sandbox image. @@ -137,3 +276,4 @@ The following mistakes commonly mix plugin installation with other NemoClaw exte - Review [Sandbox Hardening](sandbox-hardening) before adding plugin code to a shared or long-lived sandbox. - Review [Network Policies](../reference/network-policies) to plan plugin egress rules. - Follow [Customize Network Policy](../network-policy/customize-network-policy) if the plugin needs a custom preset. +- Follow [issue #5998](https://github.com/NVIDIA/NemoClaw/issues/5998) for the no-source-checkout managed plugin lifecycle. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index abd5341fb6..f1e0fc2898 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -122,7 +122,8 @@ After the host upgrade, it runs `nemoclaw upgrade-sandboxes --auto` to rebuild s Successful recovery completes the existing-sandbox upgrade and skips generic onboarding, so the installer does not create an extra sandbox or ask for a new provider credential. For pre-fingerprint OpenClaw and Hermes registry entries, the installer asks you to confirm that every listed sandbox used a NemoClaw-managed image before it permits recovery onto the current managed image. In non-interactive runs, 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 you verify every named sandbox used a managed image. -Registry entries with recorded custom-image evidence remain blocked from automatic recreation. +Legacy managed-image confirmation never overrides recorded custom-image evidence. +A custom OpenClaw sandbox can be recovered only when the selected validated backup independently carries complete authoritative image-plugin provenance; otherwise recovery stops before deletion. If any backup is skipped or fails, the installer exits with a nonzero status before it changes the gateway. If an automatic rebuild fails or a non-Ready recovery is blocked or fails, the installer exits with a nonzero status and does not start generic onboarding. diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 3aa8ae793c..f777e31111 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -372,7 +372,8 @@ After the host CLI and OpenShell update, the installer runs `$$nemoclaw upgrade- If an existing sandbox is not Ready, the automatic path requires a validated latest backup whose sandbox and agent identity match the registry and positive evidence that NemoClaw managed the image. For a listed pre-fingerprint OpenClaw or Hermes registry entry, you can provide that evidence through the installer's explicit managed-image confirmation. In a non-interactive run, 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 you verify every named sandbox used a managed image. -Recorded custom-image evidence remains blocked from automatic recreation. +Legacy managed-image confirmation never overrides recorded custom-image evidence. +A custom OpenClaw sandbox can be recovered only when the selected validated backup independently carries complete authoritative image-plugin provenance; otherwise recovery stops before deletion. The installer attempts every eligible recovery, exits with a nonzero status if any recovery fails, and skips generic onboarding after successful recovery. For manual upgrade flows, create a snapshot first and then run the update or rebuild command you need: diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1aa0bcfb34..0d3817fb27 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -575,6 +575,7 @@ The poll count is clamped to a minimum of `1` so the probe always runs at least #### `--from ` 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. If that directory contains a `.dockerignore`, onboarding applies those rules 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. @@ -597,6 +598,14 @@ $$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. + + +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: @@ -2393,7 +2402,8 @@ Each rebuild reuses the same workspace backup-and-restore flow as `$$nemoclaw + + +Set `NEMOCLAW_SANDBOX_BASE_IMAGE_REF` to an OpenClaw sandbox-base tag or digest to override base-image resolution during onboarding. +Use a release-matched tag or immutable digest for a source-based custom image; NemoClaw resolves the reference and pins a repository digest into a stock-style `ARG BASE_IMAGE` declaration when possible. + + + ### Onboard Profiling Traces Set `NEMOCLAW_TRACE=1` before `$$nemoclaw onboard` to write an OpenTelemetry-style JSON trace for the run. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 9080cb3950..e7cb2cb97b 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -564,6 +564,22 @@ sudo ufw allow from "$SUBNET" to any port 8080 proto tcp $$nemoclaw onboard ``` + + +### 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. + +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. + + + ### `connect` exits because the gateway is down diff --git a/docs/security/e2e-weather-plugin-fixture-dependency-review.md b/docs/security/e2e-weather-plugin-fixture-dependency-review.md new file mode 100644 index 0000000000..e2c7ddd931 --- /dev/null +++ b/docs/security/e2e-weather-plugin-fixture-dependency-review.md @@ -0,0 +1,56 @@ +# E2E Weather Plugin Fixture Dependency Review + +Review date: 2026-07-08 + +Scope: `test/e2e/fixtures/plugins/weather/package-lock.json` and the secret-free OpenClaw custom-plugin lifecycle regression lane. + +## Checked-in Fixture Waiver + +The following fixture lockfile is intentionally committed and covered by this review: + +- `test/e2e/fixtures/plugins/weather/package-lock.json` + +The fixture reproduces a real, version-matched OpenClaw plugin build. +Its lockfile must remain committed so the release-matched peer and development dependency graph is deterministic; generating the lockfile during E2E would allow registry state to change the build without a repository diff. +Automated dependency updates are not enabled for this fixture because its OpenClaw version must move with NemoClaw's reviewed runtime pin rather than independently. + +This waiver is limited to test fixture code. +It does not waive review for production dependencies, and it must be revalidated whenever the fixture manifest or lockfile changes. + +## Accepted Residual Risk + +Registry packages can later be found vulnerable or compromised, and downloaded package code still participates in fixture compilation and the test plugin runtime despite integrity verification and lifecycle-script suppression. +The accepted residual risk is limited to this secret-free E2E lane with read-only contents permission and must be reconsidered on every fixture manifest or lockfile change. +The release-matched `openclaw@2026.5.27` development graph currently has known advisories, but upgrading it independently would stop this fixture from testing the documented NemoClaw `v0.0.71` runtime contract. + +## Compensating Controls + +- `typebox`, `openclaw`, and `typescript` use exact installed versions in `package.json`; the OpenClaw peer range expresses runtime compatibility but is not the lockfile's install selector. +- The committed npm lockfile records registry integrity for the resolved dependency graph. +- Every fixture install uses `npm ci --ignore-scripts`; the Docker build also uses `--no-audit --no-fund` and prunes development and peer dependencies before staging the plugin. +- The image build fails if a private `node_modules/openclaw` remains, then verifies that OpenClaw creates the expected link to the stock global runtime. +- The GitHub Actions job has read-only `contents` permission, uses full-SHA-pinned actions, and disables checkout credential persistence. Trusted runs use Docker Hub credentials only to pre-pull the digest-pinned builder image; the workflow then removes Docker auth, and the release-pinned fixture execution receives no repository secrets or Docker credential environment variables. +- The historical NemoClaw `v0.0.71` exercise uses that tagged CLI's onboarding preflight as its OpenShell compatibility boundary instead of applying current-release capability markers to the older stack. +- The lane is isolated to deterministic test data and uploads only its path-scoped E2E artifact directory. + +## Advisory Audit + +Run from `test/e2e/fixtures/plugins/weather`: + +```bash +npm audit --package-lock-only --ignore-scripts --json +``` + +Revalidated on 2026-07-08: npm audit exited `1` and reported 9 vulnerable packages (3 moderate and 6 high; 0 info, low, or critical) across 374 total dependencies. +The affected packages are `@earendil-works/pi-coding-agent`, `@openclaw/fs-safe`, `hono`, `linkify-it`, `markdown-it`, `openclaw`, `protobufjs`, `tar`, and `undici`. +The point-in-time advisory set is GHSA-22p9-wv53-3rq4, GHSA-2gcr-mfcq-wcc3, GHSA-35p6-xmwp-9g52, GHSA-38rv-x7px-6hhq, GHSA-3hrh-pfw6-9m5x, GHSA-6v5v-wf23-fmfq, GHSA-7v5m-pr3q-6453, GHSA-88fw-hqm2-52qc, GHSA-94rc-8x27-4472, GHSA-9c3v-684m-579c, GHSA-f38q-mgvj-vph7, GHSA-f577-qrjj-4474, GHSA-g8m3-5g58-fq7m, GHSA-j6c9-x7qj-28xf, GHSA-jfgx-wxx8-mp94, GHSA-mqxh-6gq7-558m, GHSA-p88m-4jfj-68fv, GHSA-pr7r-676h-xcf6, GHSA-r95r-rj6r-c39x, GHSA-rv63-4mwf-qqc2, GHSA-vmf3-w455-68vh, GHSA-vmh5-mc38-953g, GHSA-vxpw-j846-p89q, GHSA-wcpc-wj8m-hjx6, GHSA-wgpf-jwqj-8h8p, GHSA-wwfh-h76j-fc44, and GHSA-xrhx-7g5j-rcj5. +The advisories are in the release-pinned OpenClaw development graph; the Docker build suppresses lifecycle scripts and prunes development and peer dependencies before copying the plugin into the runtime image. +The reviewed lockfile has SHA-256 `1fa44d136d4bf5396f592dbf901da2c43740b38f1ebe52d23efc01ca0ba6f3da`, and every non-root package entry records both its resolved registry URL and integrity value. + +The audit is a point-in-time advisory check, not a substitute for the exact lockfile, lifecycle-script suppression, or secret-free workflow boundary. +Rerun it whenever `package.json` or `package-lock.json` changes and again before merge if npm advisory state changes. + +## Enforcement and Removal + +`test/e2e-fixture-dependency-review.test.ts` fails if any committed `test/e2e/fixtures/**/package-lock.json` is absent from this review and binds the weather fixture to the controls above. +Remove this waiver when the fixture is deleted or when repository-wide automated dependency review explicitly covers E2E fixture lockfiles while preserving the release-matched OpenClaw pin. diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.test.ts b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts index 68bc0b8be8..5f33475e81 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts @@ -8,6 +8,7 @@ import { normalizeRebuildObservabilityPolicyPresets, normalizeRebuildTargetPolicyPresets, normalizeRebuildWebSearchPolicyPresets, + type RebuildBackupPhaseInput, runRebuildBackupPhase, } from "./rebuild-backup-phase"; @@ -195,3 +196,103 @@ describe("rebuild web-search policy normalization", () => { ).toEqual(["npm", "future-agent-required"]); }); }); + +describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { + const completeMarkedManifest = { + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: "/tmp/custom-openclaw-backup", + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls: [], + } as never; + + function customOpenClawInput(overrides: Record = {}): RebuildBackupPhaseInput { + return { + sandboxName: "custom-openclaw", + sandboxEntry: { + name: "custom-openclaw", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + }, + staleRecovery: false, + preparedRecoveryManifest: null, + messagingPlan: null, + webSearchConfig: null, + log: vi.fn(), + bail: (message: string): never => { + throw new Error(message); + }, + relockShieldsIfNeeded: vi.fn(() => true), + ...overrides, + } as RebuildBackupPhaseInput; + } + + it("blocks a live custom image with missing registry provenance before backup", () => { + const backupStateForRebuild = vi.fn(); + const input = customOpenClawInput(); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(() => runRebuildBackupPhase(input, backupStateForRebuild)).toThrow( + "Custom-image OpenClaw plugin provenance is unavailable.", + ); + + expect(backupStateForRebuild).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); + + expect(result?.backupManifest).toBe(completeMarkedManifest); + expect(backupStateForRebuild).not.toHaveBeenCalled(); + }); + + it("blocks an unmarked legacy prepared manifest before deletion", () => { + const backupStateForRebuild = vi.fn(); + const input = customOpenClawInput({ + preparedRecoveryManifest: { + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: "/tmp/legacy-custom-openclaw-backup", + openclawImagePluginInstalls: [], + }, + }); + + expect(() => runRebuildBackupPhase(input, backupStateForRebuild)).toThrow( + "Custom-image OpenClaw plugin provenance is unavailable.", + ); + + expect(backupStateForRebuild).not.toHaveBeenCalled(); + }); + + it("revalidates a newly generated backup manifest before deletion", () => { + const backupStateForRebuild = vi.fn(() => ({ + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: "/tmp/incomplete-custom-openclaw-backup", + reconcileOpenClawImagePluginProvenance: true, + })); + const input = customOpenClawInput({ + sandboxEntry: { + name: "custom-openclaw", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + openclawImagePluginInstalls: [], + }, + }); + + expect(() => runRebuildBackupPhase(input, backupStateForRebuild as never)).toThrow( + "Custom-image OpenClaw plugin provenance is unavailable.", + ); + + expect(backupStateForRebuild).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index f448c09530..8bb2465587 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -14,6 +14,8 @@ import { resolveRecreatePolicyPresets } from "../../onboard/policy-preset-persis import { isStaleBuiltinWebSearchPolicyPreset } from "../../onboard/policy-selection"; import { filterSuppressedAgentRequiredPresets } from "../../onboard/policy-tier-suppression"; import { parsePresetPolicyKeys } from "../../policy"; +import { hasCompleteOpenClawImagePluginProvenance } from "../../state/openclaw-plugin-restore"; +import { hasAuthoritativeOpenClawImagePluginProvenance } from "../../state/sandbox"; import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight"; import { backupSandboxStateForRebuild, type RebuildSandboxEntry } from "./rebuild-flow-helpers"; @@ -42,6 +44,18 @@ export interface RebuildBackupPhaseResult { sessionPolicyPresets: string[] | null; } +function bailForUnsafeOpenClawPluginProvenance(input: RebuildBackupPhaseInput): never { + console.error( + " Custom-image OpenClaw plugin provenance is missing or invalid; rebuild cannot safely distinguish image-owned plugins from user state.", + ); + console.error(" The sandbox is untouched — no data was lost."); + console.error( + " To preserve state, onboard the custom image under a new sandbox name and manually migrate only user-owned state.", + ); + input.relockShieldsIfNeeded(!input.staleRecovery); + 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[], @@ -132,10 +146,35 @@ export function normalizeRebuildTargetPolicyPresets( export function runRebuildBackupPhase( input: RebuildBackupPhaseInput, + backupStateForRebuild: typeof backupSandboxStateForRebuild = backupSandboxStateForRebuild, ): RebuildBackupPhaseResult | null { + const customOpenClaw = + Boolean(input.sandboxEntry.fromDockerfile) && + (!input.sandboxEntry.agent || input.sandboxEntry.agent === "openclaw"); + const preparedRecoveryManifest = input.preparedRecoveryManifest; + const hasPreparedRecovery = preparedRecoveryManifest !== null; + const preparedRecoveryIsAuthoritative = + preparedRecoveryManifest !== null && + hasAuthoritativeOpenClawImagePluginProvenance(preparedRecoveryManifest); + const restoresCustomOpenClawState = + customOpenClaw && (!input.staleRecovery || hasPreparedRecovery); + if ( + (hasPreparedRecovery && + preparedRecoveryManifest?.reconcileOpenClawImagePluginProvenance === true && + !preparedRecoveryIsAuthoritative) || + (restoresCustomOpenClawState && + !preparedRecoveryIsAuthoritative && + (hasPreparedRecovery || + !hasCompleteOpenClawImagePluginProvenance( + input.sandboxEntry.openclawImagePluginInstalls, + "/sandbox/.openclaw", + ))) + ) { + return bailForUnsafeOpenClawPluginProvenance(input); + } const backupManifest = - input.preparedRecoveryManifest ?? - backupSandboxStateForRebuild( + preparedRecoveryManifest ?? + backupStateForRebuild( input.sandboxName, input.sandboxEntry, input.staleRecovery, @@ -145,6 +184,14 @@ export function runRebuildBackupPhase( { force: input.force }, ); if (backupManifest === undefined) return null; + if ( + backupManifest && + (backupManifest.reconcileOpenClawImagePluginProvenance === true || + restoresCustomOpenClawState) && + !hasAuthoritativeOpenClawImagePluginProvenance(backupManifest) + ) { + return bailForUnsafeOpenClawPluginProvenance(input); + } const backupWasForceSkipped = input.force === true && !input.staleRecovery && backupManifest === null; diff --git a/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts index e23cf73632..2e6bd34729 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts @@ -76,8 +76,9 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { sandboxEntry: originalEntry, sandboxEntryReads: [ originalEntry, // Initial rebuild target. - originalEntry, // Messaging-conflict gateway selection (#5954). - originalEntry, // Prepared DCode target capture. + originalEntry, // Exact post-confirmation lock guard. + originalEntry, // Messaging config hydration. + originalEntry, // Messaging-conflict gateway lookup (#5954). driftedEntry, // Final pre-backup target verification. ], dcodeRouteResults: [{ ok: true }, { ok: true }], @@ -125,10 +126,10 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { sandboxEntry: originalEntry, sandboxEntryReads: [ originalEntry, // Initial rebuild target. - originalEntry, // Messaging-conflict gateway selection (#5954). - originalEntry, // Prepared DCode target capture. + originalEntry, // Exact post-confirmation lock guard. + originalEntry, // Messaging config hydration. + originalEntry, // Messaging-conflict gateway lookup (#5954). originalEntry, // Final pre-backup target verification. - originalEntry, // Delete-edge target verification input. driftedEntry, // Registry reread at the destructive boundary. ], dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts index 36c72dedaa..76fd49f550 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts @@ -50,6 +50,7 @@ describe("rebuildSandbox DCode flow: recovery", () => { 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 () => { 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 5f1499c2ae..e7439fe3e3 100644 --- a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts +++ b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts @@ -214,6 +214,7 @@ describe("rebuild local-provider recreation", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", "/tmp/nemoclaw-rebuild-backup", + { targetAgentType: "openclaw" }, ); }); }); diff --git a/src/lib/actions/sandbox/rebuild-preflight-guards.ts b/src/lib/actions/sandbox/rebuild-preflight-guards.ts index 717469656d..02ee2e61a8 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-guards.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-guards.ts @@ -11,6 +11,7 @@ import * as onboardSession from "../../state/onboard-session"; import * as registry from "../../state/registry"; import type { RebuildBail } from "./rebuild-credential-preflight"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; +import type { RebuildVersionCheck } from "./rebuild-preflight-confirmation"; import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; export function checkRebuildGatewaySchemaPreflight( @@ -86,6 +87,25 @@ export function acquireRebuildOnboardLock(sandboxName: string, bail: RebuildBail return release; } +/** + * Derive the only registry transition rebuild itself can cause before locking. + * Keep the pre-confirmation entry intact except for a successful live probe's + * agent-version cache write, so every concurrent recreate/config change still + * fails the exact locked-entry comparison below. + */ +export function expectedRebuildEntryAfterVersionCheck( + confirmedEntry: RebuildSandboxEntry, + confirmedEntrySnapshot: string, + versionCheck: RebuildVersionCheck, +): RebuildSandboxEntry { + if (versionCheck.detectionMethod !== "ssh-exec" || versionCheck.sandboxVersion === null) { + return confirmedEntry; + } + const expectedEntry = JSON.parse(confirmedEntrySnapshot) as RebuildSandboxEntry; + expectedEntry.agentVersion = versionCheck.sandboxVersion; + return expectedEntry; +} + export function assertRebuildEntryUnchanged( sandboxName: string, confirmedEntrySnapshot: string, diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index d047f1edcd..58ccd1e337 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -36,6 +36,7 @@ import { acquireRebuildOnboardLock, assertRebuildEntryUnchanged, checkRebuildGatewaySchemaPreflight, + expectedRebuildEntryAfterVersionCheck, getRebuildSandboxEntryOrBail, isSingleAgentRebuildSupported, runRebuildGatewayIntentPreflight, @@ -119,15 +120,28 @@ export async function runRebuildPreflightPhase( return null; } const agentName = getRebuildAgentDisplayName(sandboxName); + const versionCheck = await runRebuildGatewayIntentPreflight({ + checkGatewaySchema: () => + isDcodeRebuildAgent(rebuildAgent) || + checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail), + confirmIntent: () => + confirmRebuildIntent(sandboxName, agentName, skipConfirm, activeSessionCount, bail), + }); + if (!versionCheck) return null; + const expectedSandboxEntry = expectedRebuildEntryAfterVersionCheck( + sandboxEntry, + confirmedEntrySnapshot, + versionCheck, + ); const dcodePreflight = createDcodeRebuildOrchestrator({ sandboxName, - entry: sandboxEntry, + entry: expectedSandboxEntry, rebuildAgent, log, bail, deps: { checkGatewaySchema: (name, scopedBail) => - checkRebuildGatewaySchemaPreflight(name, sandboxEntry, scopedBail), + checkRebuildGatewaySchemaPreflight(name, expectedSandboxEntry, scopedBail), preflightCredentials: (_name, entry, scopedLog, scopedBail) => preflightRebuildCredentials(entry, scopedLog, scopedBail), // Non-DCode rebuilds stay on the existing typed base-image preflight. @@ -139,22 +153,13 @@ export async function runRebuildPreflightPhase( let preparedImage: PreparedRebuildImage | null = null; let retainPreparedImage = false; try { - const versionCheck = await runRebuildGatewayIntentPreflight({ - checkGatewaySchema: () => - isDcodeRebuildAgent(rebuildAgent) || - checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail), - confirmIntent: () => - confirmRebuildIntent(sandboxName, agentName, skipConfirm, activeSessionCount, bail), - }); - if (!versionCheck) return null; - const releaseOnboardLock = acquireRebuildOnboardLock(sandboxName, bail); let retainOnboardLock = false; try { - assertRebuildEntryUnchanged(sandboxName, confirmedEntrySnapshot, bail); + assertRebuildEntryUnchanged(sandboxName, JSON.stringify(expectedSandboxEntry), bail); const preparedTarget = await prepareRebuildTargetPreflights({ sandboxName, - sandboxEntry, + sandboxEntry: expectedSandboxEntry, rebuildAgent, // Reaching this point means either --yes was supplied or confirmation // succeeded, matching the previous `skipConfirm || confirmed` contract. @@ -173,7 +178,7 @@ export async function runRebuildPreflightPhase( if (!preparedTarget) return null; preparedImage = preparedTarget.preparedImage; - const liveState = await resolveRebuildLiveState(sandboxName, sandboxEntry, log, bail); + const liveState = await resolveRebuildLiveState(sandboxName, expectedSandboxEntry, log, bail); if (!liveState) return null; if (isDcodeRebuildAgent(rebuildAgent)) { const recoveryRecreate = liveState.staleRecovery || recoveryManifest !== null; @@ -208,7 +213,7 @@ export async function runRebuildPreflightPhase( retainDcodePreflight = true; retainPreparedImage = true; return { - sandboxEntry, + sandboxEntry: expectedSandboxEntry, rebuildAgent, versionCheck, ...preparedTarget, diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index e17bc59bfa..7fdffebbd4 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -47,6 +47,7 @@ describe("prepared rebuild recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { targetAgentType: "openclaw" }, ); }); @@ -87,6 +88,7 @@ describe("prepared rebuild recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { targetAgentType: "openclaw" }, ); }); diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.ts index e105276aad..a2ae73b104 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.ts @@ -28,6 +28,24 @@ function failPreparedRecoveryPreDelete( return bail(errorMessage); } +function registryEntryWithoutOpenClawPluginProvenance( + entry: RebuildSandboxEntry, +): Omit { + const { openclawImagePluginInstalls: _provenance, ...rest } = entry; + return rest; +} + +function isPreparedRecoveryImageAllowed( + manifest: sandboxState.RebuildManifest, + entry: RebuildSandboxEntry, + allowLegacyManagedImageRecovery: boolean, +): boolean { + return ( + sandboxState.hasAuthoritativeOpenClawImagePluginProvenance(manifest) || + sandboxState.isManagedImageRecoveryAllowed(entry, allowLegacyManagedImageRecovery) + ); +} + export function validatePreparedRecoveryManifest( sandboxName: string, sandboxEntry: RebuildSandboxEntry, @@ -48,7 +66,13 @@ export function validatePreparedRecoveryManifest( bail(`Invalid recovery manifest: ${validation.reason}`); return null; } - if (!sandboxState.isManagedImageRecoveryAllowed(sandboxEntry, allowLegacyManagedImageRecovery)) { + if ( + !isPreparedRecoveryImageAllowed( + validation.manifest, + sandboxEntry, + allowLegacyManagedImageRecovery, + ) + ) { console.error(""); console.error( ` ${_RD}Recovery preflight failed:${R} registry has no NemoClaw-managed image fingerprint.`, @@ -83,7 +107,15 @@ export function revalidatePreparedRecoveryBeforeDelete( bail, ); } - if (!isDeepStrictEqual(currentEntry, initialEntry)) { + const authoritativePluginProvenance = + sandboxState.hasAuthoritativeOpenClawImagePluginProvenance(candidate); + const registryConfigurationMatches = authoritativePluginProvenance + ? isDeepStrictEqual( + registryEntryWithoutOpenClawPluginProvenance(currentEntry), + registryEntryWithoutOpenClawPluginProvenance(initialEntry), + ) + : isDeepStrictEqual(currentEntry, initialEntry); + if (!registryConfigurationMatches) { return failPreparedRecoveryPreDelete( "registered sandbox configuration changed during preflight", "Recovery registry configuration changed during preflight.", @@ -92,6 +124,10 @@ export function revalidatePreparedRecoveryBeforeDelete( } const latestManifest = sandboxState.getLatestBackup(sandboxName); + // candidate and latestManifest are two reads of the same prepared backup, + // enforced by the identity check below. Their plugin IDs are therefore one + // provenance domain; fresh-vs-previous ownership is validated later by the + // restore planner after the replacement image has been created. if ( !latestManifest || latestManifest.timestamp !== candidate.timestamp || @@ -116,7 +152,13 @@ export function revalidatePreparedRecoveryBeforeDelete( bail, ); } - if (!sandboxState.isManagedImageRecoveryAllowed(currentEntry, allowLegacyManagedImageRecovery)) { + if ( + !isPreparedRecoveryImageAllowed( + validation.manifest, + currentEntry, + allowLegacyManagedImageRecovery, + ) + ) { return failPreparedRecoveryPreDelete( "registry no longer has a NemoClaw-managed image fingerprint", "Recovery registry entry has no NemoClaw-managed image fingerprint.", diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index ae7418d74c..471051cb86 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -27,17 +27,50 @@ describe("rebuild policy restore fidelity", () => { vi.restoreAllMocks(); }); - it("replays custom web-policy names from exact content instead of same-name built-ins", () => { + it("surfaces a fresh OpenClaw plugin registry precondition failure", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "error").mockImplementation(() => undefined); - const parsePresetPolicyKeys = vi.spyOn(policies, "parsePresetPolicyKeys"); - vi.spyOn(sandboxState, "restoreSandboxState").mockReturnValue({ - success: true, + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const log = vi.fn(); + vi.spyOn(sandboxState, "restoreRecreatedSandboxState").mockReturnValue({ + success: false, restoredDirs: [], restoredFiles: [], - failedDirs: [], + failedDirs: ["extensions"], failedFiles: [], + error: "could not read fresh OpenClaw plugin install registry", + }); + + const result = runRebuildRestorePhase({ + sandboxName: "alpha", + backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never, + policyPresets: [], + customPolicies: [], + reconcileManagedDcodeObservability: false, + log, }); + + expect(result.restoreSucceeded).toBe(false); + expect(consoleError).toHaveBeenCalledWith( + " Restore blocked: could not read fresh OpenClaw plugin install registry", + ); + expect(log).toHaveBeenCalledWith( + 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(sandboxState, "restoreRecreatedSandboxState") + .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) => ({ @@ -48,6 +81,7 @@ describe("rebuild policy restore fidelity", () => { const result = runRebuildRestorePhase({ sandboxName: "alpha", backupManifest: { + agentType: "openclaw", backupPath: "/tmp/rebuild-backup", customPolicies, } as never, @@ -57,6 +91,9 @@ describe("rebuild policy restore fidelity", () => { log: vi.fn(), }); + expect(restoreRecreatedSandboxState).toHaveBeenCalledWith("alpha", "/tmp/rebuild-backup", { + targetAgentType: "openclaw", + }); expect(applyPreset).toHaveBeenCalledOnce(); expect(applyPreset).toHaveBeenCalledWith("alpha", "npm"); for (const entry of customPolicies) { diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index c658496843..1b80dbf720 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -187,12 +187,19 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild console.log(""); console.log(" Restoring workspace state..."); log(`Restoring from: ${backupManifest.backupPath} into sandbox: ${sandboxName}`); - const restore = sandboxState.restoreSandboxState(sandboxName, backupManifest.backupPath); + const restore = sandboxState.restoreRecreatedSandboxState( + sandboxName, + backupManifest.backupPath, + { targetAgentType: backupManifest.agentType }, + ); log( - `Restore result: success=${restore.success}, restored=${restore.restoredDirs.join(",")}; files=${restore.restoredFiles.join(",")}, failed=${restore.failedDirs.join(",")}; failedFiles=${restore.failedFiles.join(",")}`, + `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}` : ""}`, ); restoreSucceeded = restore.success; 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) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c6b210cbd8..736735a1ac 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -543,7 +543,7 @@ const agentOnboard = require("./agent/onboard"); const agentDefs = require("./agent/defs"); const gatewayState: typeof import("./state/gateway") = require("./state/gateway"); -const notReadyRecreate: typeof import("./onboard/not-ready-recreate") = require("./onboard/not-ready-recreate"); +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"); @@ -600,16 +600,14 @@ import { printMessagingProviderMissing, printSwapCreationFailed, } from "./onboard/preflight-messages"; -import { - backupSandboxBeforeRecreate, - shouldSkipPreRecreateBackup, -} from "./onboard/sandbox-backup-on-recreate"; +import { shouldSkipPreRecreateBackup } from "./onboard/sandbox-backup-on-recreate"; import { getResumeSandboxGpuOverrides, resolveSandboxGpuConfig, type SandboxGpuConfig, type SandboxGpuFlag, } from "./onboard/sandbox-gpu-mode"; +import { createSandboxRecreateProtection } from "./onboard/sandbox-recreate-protection"; import type { SelectionDrift } from "./onboard/selection-drift"; import { createSetupNimVllmHandler } from "./onboard/setup-nim-vllm"; import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary"; @@ -2394,15 +2392,16 @@ async function createSandboxWithBaseImageResolution( const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); let pendingStateRestore: BackupResult | null = null; - let pendingStateRestoreBackupPath: string | null = null; let notReadyRecreateInProgress = false; - - pendingStateRestoreBackupPath = notReadyRecreate.selectPreUpgradeBackupForCreate({ - liveExists, - hasExistingRegistryEntry: existingEntry !== null, + const customOpenClawImage = + Boolean(fromDockerfile) && getRequestedSandboxAgentName(agent) === "openclaw"; + const recreateProtection = createSandboxRecreateProtection({ sandboxName, + sandboxEntry: existingEntry, + customOpenClawImage, note, }); + let pendingStateRestoreBackupPath = recreateProtection.selectPreUpgradeBackup(liveExists); if (liveExists) { const existingSandboxState = getSandboxReuseState(sandboxName); @@ -2550,7 +2549,7 @@ async function createSandboxWithBaseImageResolution( } } else { notReadyRecreateInProgress = true; - const outcome = notReadyRecreate.resolveNotReadyOutcome(sandboxName, note); + const outcome = recreateProtection.resolveNotReadyOutcome(); if (outcome.kind === "blocked") { for (const hint of outcome.hints) console.error(hint); process.exit(1); @@ -2609,7 +2608,7 @@ async function createSandboxWithBaseImageResolution( console.log(` Messaging credential(s) rotated: ${rotatedNames}`); console.log(" Rebuilding sandbox to propagate new credentials to the L7 proxy..."); if (!shouldSkipPreRecreateBackup(process.env)) { - const result = backupSandboxBeforeRecreate({ sandboxName }); + const result = recreateProtection.backup(); if (!result.ok) { console.error( " Set NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 to recreate without preserving state.", @@ -2675,7 +2674,7 @@ async function createSandboxWithBaseImageResolution( !shouldSkipPreRecreateBackup(process.env) ) { note(" Backing up workspace state before recreating sandbox..."); - const result = backupSandboxBeforeRecreate({ sandboxName }); + const result = recreateProtection.backup(); if (!result.ok) { console.error( " Set NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 to recreate without preserving state.", @@ -2971,27 +2970,27 @@ async function createSandboxWithBaseImageResolution( hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true); } - // Resolve registry metadata now, but publish it only after restored state is - // reconciled and the live agent selection is verified. // openshell tags images with seconds; buildId is ms. Parse actual tag from output. Fixes #2672. const resolvedImageTag = prebuild.imageRef ?? resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); - const inferenceSelection = sandboxRegistration.selection; - finalizeCreatedSandbox( { sandboxName, restoreBackupPath, preUpgradeBackup: pendingStateRestoreBackupPath !== null, + targetAgentType: agent?.name ?? "openclaw", + discoverOpenClawImagePluginInstalls: customOpenClawImage, validateManagedDcode: isManagedDcodeAgent, provider, model, preferredInferenceApi, }, { - restoreSandboxState: sandboxState.restoreSandboxState, + discoverFreshOpenClawImagePluginInstalls: (name) => + openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls(name, sandboxState), + restoreRecreatedSandboxState: sandboxState.restoreRecreatedSandboxState, getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { runCaptureOpenshell, @@ -2999,10 +2998,10 @@ async function createSandboxWithBaseImageResolution( note, error: console.error, exitProcess: (code) => process.exit(code), - register: () => + register: (openclawImagePluginInstalls) => sandboxRegistration.registerCreatedSandbox({ sandboxName, - inferenceSelection: inferenceSelection( + inferenceSelection: sandboxRegistration.selection( sandboxName, provider, model, @@ -3012,6 +3011,7 @@ async function createSandboxWithBaseImageResolution( agent, agentVersionKnown: !fromDockerfile, imageTag: resolvedImageTag, + openclawImagePluginInstalls, appliedPolicies: initialSandboxPolicy.appliedPresets, toolDisclosure: effectiveToolDisclosure, observabilityEnabled: createIntent?.observabilityEnabled === true, @@ -4689,6 +4689,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { verifyDeployment: async (name, chain) => { const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment"); + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. return verifyDeploymentModule.verifyDeployment(name, chain, { executeSandboxCommand: (sandbox: string, script: string) => executeSandboxCommandForVerification(sandbox, script), @@ -4708,12 +4709,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { ); return parseInt(result.trim(), 10) || 0; }, - captureForwardList: () => - runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, + captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, getMessagingChannels: () => liveFinalFlowContext.selectedMessagingChannels || [], - providerExistsInGateway: (providerName: string) => - providerExistsInGateway(providerName), - }); + providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName), + }, { diagnoseCustomOpenClawRuntime: verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime(liveFinalFlowContext.fromDockerfile, agent?.name) }); }, formatVerificationDiagnostics: (result) => { const verifyDeploymentModule: typeof import("./verify-deployment") = diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 938bd00359..48d083c7a9 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -216,16 +216,22 @@ describe("created DCode sandbox finalization", () => { sandboxName: "dcode", restoreBackupPath: fixture.backupPath, preUpgradeBackup: false, + targetAgentType: "langchain-deepagents-code", validateManagedDcode: true, provider: "nvidia-prod", model: "new-model", preferredInferenceApi: null, }, { - restoreSandboxState: (name, backup, options) => { + discoverFreshOpenClawImagePluginInstalls: () => ({ + ok: true, + extensionDirs: [], + pluginInstalls: [], + }), + restoreRecreatedSandboxState: (name, backup, options) => { order.push("restore"); - expect(options?.stateFileRestorePolicy).toBe(managedDcodeConfigRestorePolicy); - return sandboxState.restoreSandboxState(name, backup, options); + expect(options.stateFileRestorePolicy).toBe(managedDcodeConfigRestorePolicy); + return sandboxState.restoreRecreatedSandboxState(name, backup, options); }, getDcodeSelectionDrift: (name, provider, model, api) => { order.push("validate"); @@ -266,13 +272,15 @@ describe("created DCode sandbox finalization", () => { sandboxName: "dcode", restoreBackupPath: null, preUpgradeBackup: false, + targetAgentType: "langchain-deepagents-code", validateManagedDcode: true, provider: "nvidia-prod", model: "new-model", preferredInferenceApi: null, }, { - restoreSandboxState: vi.fn(), + discoverFreshOpenClawImagePluginInstalls: vi.fn(), + restoreRecreatedSandboxState: vi.fn(), getDcodeSelectionDrift: () => ({ changed: true, providerChanged: false, @@ -307,14 +315,16 @@ describe("created DCode sandbox finalization", () => { sandboxName: "dcode", restoreBackupPath: fixture.backupPath, preUpgradeBackup: false, + targetAgentType: "langchain-deepagents-code", validateManagedDcode: true, provider: "nvidia-prod", model: "new-model", preferredInferenceApi: null, }, { - restoreSandboxState: (name, backup, options) => { - const restored = sandboxState.restoreSandboxState(name, backup, options); + discoverFreshOpenClawImagePluginInstalls: vi.fn(), + restoreRecreatedSandboxState: (name, backup, options) => { + const restored = sandboxState.restoreRecreatedSandboxState(name, backup, options); return { ...restored, success: false, failedDirs: ["skills"] }; }, getDcodeSelectionDrift: (name, provider, model, api) => @@ -347,7 +357,7 @@ describe("created DCode sandbox finalization", () => { }); it("keeps custom-image restores outside the managed config merge (#6311)", () => { - const restoreSandboxState = vi.fn(() => ({ + const restoreRecreatedSandboxState = vi.fn(() => ({ success: true, restoredDirs: [], failedDirs: [], @@ -360,13 +370,15 @@ describe("created DCode sandbox finalization", () => { sandboxName: "custom-dcode", restoreBackupPath: "/tmp/custom-backup", preUpgradeBackup: false, + targetAgentType: "langchain-deepagents-code", validateManagedDcode: false, provider: "custom-provider", model: "custom-model", preferredInferenceApi: null, }, { - restoreSandboxState, + discoverFreshOpenClawImagePluginInstalls: vi.fn(), + restoreRecreatedSandboxState, getDcodeSelectionDrift: vi.fn(), register: vi.fn(), note: vi.fn(), @@ -377,10 +389,242 @@ describe("created DCode sandbox finalization", () => { }, ); - expect(restoreSandboxState).toHaveBeenCalledWith( + expect(restoreRecreatedSandboxState).toHaveBeenCalledWith( "custom-dcode", "/tmp/custom-backup", - undefined, + { targetAgentType: "langchain-deepagents-code" }, + ); + }); +}); + +describe("created OpenClaw sandbox finalization", () => { + const pluginInstalls = [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }, + ]; + + it("skips image-plugin discovery for a managed OpenClaw image", () => { + const discoverFreshOpenClawImagePluginInstalls = vi.fn(); + const register = vi.fn(); + + finalizeCreatedSandbox( + { + sandboxName: "openclaw", + restoreBackupPath: null, + preUpgradeBackup: false, + targetAgentType: "openclaw", + validateManagedDcode: false, + provider: "compatible-endpoint", + model: "demo", + preferredInferenceApi: "openai-completions", + }, + { + discoverFreshOpenClawImagePluginInstalls, + restoreRecreatedSandboxState: vi.fn(), + getDcodeSelectionDrift: vi.fn(), + register, + note: vi.fn(), + error: vi.fn(), + exitProcess: (code) => { + throw new Error(`unexpected exit ${code}`); + }, + }, + ); + + expect(discoverFreshOpenClawImagePluginInstalls).not.toHaveBeenCalled(); + expect(register).toHaveBeenCalledWith(undefined); + }); + + it("captures and registers a fresh image plugin baseline without a restore", () => { + const order: string[] = []; + const restoreRecreatedSandboxState = vi.fn(); + const register = vi.fn(() => order.push("register")); + + finalizeCreatedSandbox( + { + sandboxName: "openclaw", + restoreBackupPath: null, + preUpgradeBackup: false, + targetAgentType: "openclaw", + discoverOpenClawImagePluginInstalls: true, + validateManagedDcode: false, + provider: "compatible-endpoint", + model: "demo", + preferredInferenceApi: "openai-completions", + }, + { + discoverFreshOpenClawImagePluginInstalls: () => { + order.push("discover"); + return { ok: true, extensionDirs: ["weather"], pluginInstalls }; + }, + restoreRecreatedSandboxState, + getDcodeSelectionDrift: vi.fn(), + register, + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); + + expect(order).toEqual(["discover", "register"]); + expect(restoreRecreatedSandboxState).not.toHaveBeenCalled(); + expect(register).toHaveBeenCalledWith(pluginInstalls); + }); + + it("preserves the fresh image plugin baseline across recreation before registration", () => { + const order: string[] = []; + const register = vi.fn(() => order.push("register")); + const restoreRecreatedSandboxState = vi.fn(() => { + order.push("restore"); + return { + success: true, + restoredDirs: ["extensions"], + failedDirs: [], + restoredFiles: ["openclaw.json"], + failedFiles: [], + }; + }); + + finalizeCreatedSandbox( + { + sandboxName: "openclaw", + restoreBackupPath: "/tmp/openclaw-backup", + preUpgradeBackup: false, + targetAgentType: "openclaw", + discoverOpenClawImagePluginInstalls: true, + validateManagedDcode: false, + provider: "compatible-endpoint", + model: "demo", + preferredInferenceApi: "openai-completions", + }, + { + discoverFreshOpenClawImagePluginInstalls: () => { + order.push("discover"); + return { ok: true, extensionDirs: ["weather"], pluginInstalls }; + }, + restoreRecreatedSandboxState, + getDcodeSelectionDrift: vi.fn(), + register, + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); + + expect(order).toEqual(["discover", "restore", "register"]); + expect(restoreRecreatedSandboxState).toHaveBeenCalledWith("openclaw", "/tmp/openclaw-backup", { + targetAgentType: "openclaw", + freshOpenClawImagePluginInstalls: pluginInstalls, + }); + expect(register).toHaveBeenCalledWith(pluginInstalls); + }); + + it("fails closed before restore and registration when provenance discovery fails", () => { + const restoreRecreatedSandboxState = vi.fn(); + const register = vi.fn(); + const error = vi.fn(); + + expect(() => + finalizeCreatedSandbox( + { + sandboxName: "openclaw", + restoreBackupPath: "/tmp/openclaw-backup", + preUpgradeBackup: false, + targetAgentType: "openclaw", + discoverOpenClawImagePluginInstalls: true, + validateManagedDcode: false, + provider: "compatible-endpoint", + model: "demo", + preferredInferenceApi: "openai-completions", + }, + { + discoverFreshOpenClawImagePluginInstalls: () => ({ + ok: false, + error: "registry unreadable", + }), + restoreRecreatedSandboxState, + getDcodeSelectionDrift: vi.fn(), + register, + note: vi.fn(), + error, + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ), + ).toThrow("exit 1"); + + expect(restoreRecreatedSandboxState).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("registry unreadable")); + expect(error).toHaveBeenCalledWith( + " State was not restored and registry metadata was not updated.", + ); + expect(error).toHaveBeenCalledWith(' openshell sandbox delete "openclaw"'); + expect(error).toHaveBeenCalledWith( + " Then rerun the original `nemoclaw onboard --from ` command.", + ); + expect(error).toHaveBeenCalledWith(" Manual recovery: /tmp/openclaw-backup"); + }); + + it("does not register after a marked backup provenance mismatch", () => { + const register = vi.fn(); + const error = vi.fn(); + + expect(() => + finalizeCreatedSandbox( + { + sandboxName: "openclaw", + restoreBackupPath: "/tmp/openclaw-backup", + preUpgradeBackup: false, + targetAgentType: "openclaw", + discoverOpenClawImagePluginInstalls: true, + validateManagedDcode: false, + provider: "compatible-endpoint", + model: "demo", + preferredInferenceApi: "openai-completions", + }, + { + discoverFreshOpenClawImagePluginInstalls: () => ({ + ok: true, + extensionDirs: ["weather"], + pluginInstalls, + }), + restoreRecreatedSandboxState: () => ({ + success: false, + restoredDirs: [], + failedDirs: ["manifest"], + restoredFiles: [], + failedFiles: [], + error: sandboxState.OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR, + }), + getDcodeSelectionDrift: vi.fn(), + register, + note: vi.fn(), + error, + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ), + ).toThrow("exit 1"); + + expect(register).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("future rebuild would be unsafe")); + expect(error).toHaveBeenCalledWith( + expect.stringContaining(sandboxState.OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR), + ); + expect(error).toHaveBeenCalledWith(' openshell sandbox delete "openclaw"'); + expect(error).toHaveBeenCalledWith( + " Then rerun the original `nemoclaw onboard --from ` command.", ); + expect(error).toHaveBeenCalledWith(" Manual recovery: /tmp/openclaw-backup"); }); }); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index fa38a311ba..1c72953ec4 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -2,13 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 import { managedDcodeConfigRestorePolicy } from "../state/dcode-config-restore-input"; -import type { RestoreOptions, RestoreResult } from "../state/sandbox"; +import type { + OpenClawImagePluginInstall, + OpenClawManagedExtensionDiscoveryResult, +} from "../state/openclaw-plugin-restore"; +import { + OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR, + type RecreatedSandboxRestoreOptions, + type RestoreResult, +} from "../state/sandbox"; import type { SelectionDrift } from "./selection-drift"; export type CreatedSandboxFinalizationOptions = { sandboxName: string; restoreBackupPath: string | null; preUpgradeBackup: boolean; + targetAgentType: string; + discoverOpenClawImagePluginInstalls?: boolean; validateManagedDcode: boolean; provider: string; model: string; @@ -16,10 +26,13 @@ export type CreatedSandboxFinalizationOptions = { }; export type CreatedSandboxFinalizationDeps = { - restoreSandboxState( + discoverFreshOpenClawImagePluginInstalls( + sandboxName: string, + ): OpenClawManagedExtensionDiscoveryResult; + restoreRecreatedSandboxState( sandboxName: string, backupPath: string, - options?: RestoreOptions, + options: RecreatedSandboxRestoreOptions, ): RestoreResult; getDcodeSelectionDrift( sandboxName: string, @@ -27,7 +40,7 @@ export type CreatedSandboxFinalizationDeps = { model: string, preferredInferenceApi: string | null, ): SelectionDrift; - register(): void; + register(openclawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]): void; note(message: string): void; error(message: string): void; exitProcess(code: number): never; @@ -38,24 +51,60 @@ export function finalizeCreatedSandbox( options: CreatedSandboxFinalizationOptions, deps: CreatedSandboxFinalizationDeps, ): void { + let freshOpenClawImagePluginInstalls: readonly OpenClawImagePluginInstall[] | undefined; + if (options.discoverOpenClawImagePluginInstalls === true) { + const discovery = deps.discoverFreshOpenClawImagePluginInstalls(options.sandboxName); + if (!discovery.ok) { + deps.error( + ` OpenClaw image plugin discovery failed for sandbox '${options.sandboxName}': ${discovery.error}`, + ); + deps.error(" State was not restored and registry metadata was not updated."); + deps.error(" Remove the unregistered sandbox before retrying:"); + deps.error(` openshell sandbox delete ${JSON.stringify(options.sandboxName)}`); + deps.error(" Then rerun the original `nemoclaw onboard --from ` command."); + if (options.restoreBackupPath) deps.error(` Manual recovery: ${options.restoreBackupPath}`); + return deps.exitProcess(1); + } + freshOpenClawImagePluginInstalls = discovery.pluginInstalls; + } + if (options.restoreBackupPath) { deps.note( options.preUpgradeBackup ? " Restoring workspace state from pre-upgrade backup..." : " Restoring workspace state from pre-recreate backup...", ); - const restore = deps.restoreSandboxState( + const restore = deps.restoreRecreatedSandboxState( options.sandboxName, options.restoreBackupPath, - options.validateManagedDcode - ? { stateFileRestorePolicy: managedDcodeConfigRestorePolicy } - : undefined, + { + targetAgentType: options.targetAgentType, + ...(freshOpenClawImagePluginInstalls !== undefined + ? { freshOpenClawImagePluginInstalls } + : {}), + ...(options.validateManagedDcode + ? { stateFileRestorePolicy: managedDcodeConfigRestorePolicy } + : {}), + }, ); if (restore.success) { deps.note( ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, ); } else { + if (restore.error === OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR) { + deps.error( + ` OpenClaw image plugin provenance validation failed for sandbox '${options.sandboxName}': ${restore.error}`, + ); + deps.error( + " The sandbox still exists, but registry metadata was not updated because a future rebuild would be unsafe.", + ); + deps.error(" Remove the unregistered sandbox before retrying:"); + deps.error(` openshell sandbox delete ${JSON.stringify(options.sandboxName)}`); + deps.error(" Then rerun the original `nemoclaw onboard --from ` command."); + deps.error(` Manual recovery: ${options.restoreBackupPath}`); + return deps.exitProcess(1); + } // Source-of-truth review: // - Invalid state: a fresh sandbox exists after an external workspace copy fails. // - Boundary: restore.success owns copy completeness; live validation owns route integrity. @@ -90,5 +139,5 @@ export function finalizeCreatedSandbox( } } - deps.register(); + deps.register(freshOpenClawImagePluginInstalls); } diff --git a/src/lib/onboard/custom-openclaw-runtime-diagnosis.test.ts b/src/lib/onboard/custom-openclaw-runtime-diagnosis.test.ts new file mode 100644 index 0000000000..6a56d3467a --- /dev/null +++ b/src/lib/onboard/custom-openclaw-runtime-diagnosis.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + buildCustomOpenClawRuntimeFailureHints, + classifyOpenClawRuntimeFailure, + shouldDiagnoseCustomOpenClawRuntime, +} from "./custom-openclaw-runtime-diagnosis.js"; + +function probeResult(stdout: string) { + return { status: 0, stdout, stderr: "" }; +} + +describe("classifyOpenClawRuntimeFailure", () => { + it("recognizes a normal managed runtime when startup and config artifacts exist", () => { + const result = classifyOpenClawRuntimeFailure("my-sandbox", () => + probeResult("nemoclaw-runtime-probe-v1 log=0 start=1 config=1"), + ); + expect(result).toEqual({ + kind: "normal_runtime", + gatewayLogPresent: false, + startupScriptPresent: true, + configPresent: true, + }); + }); + + it("recognizes the base-only image when log, startup, and config artifacts are absent (#6108)", () => { + const result = classifyOpenClawRuntimeFailure("my-sandbox", () => + probeResult("nemoclaw-runtime-probe-v1 log=0 start=0 config=0"), + ); + expect(result.kind).toBe("base_only_image"); + expect(buildCustomOpenClawRuntimeFailureHints(result)).toMatchObject({ + gateway: expect.stringContaining("sandbox-base"), + dashboard: expect.stringContaining("cannot start"), + }); + }); + + it.each([ + ["gateway log only", "nemoclaw-runtime-probe-v1 log=1 start=0 config=0"], + ["startup script only", "nemoclaw-runtime-probe-v1 log=0 start=1 config=0"], + ["generated config only", "nemoclaw-runtime-probe-v1 log=0 start=0 config=1"], + ])("keeps a partial managed runtime inconclusive when it has %s", (_name, stdout) => { + const result = classifyOpenClawRuntimeFailure("my-sandbox", () => probeResult(stdout)); + expect(result.kind).toBe("inconclusive"); + expect(buildCustomOpenClawRuntimeFailureHints(result)).toBeNull(); + }); + + it("preserves an unreachable classification when sandbox exec fails", () => { + const result = classifyOpenClawRuntimeFailure("my-sandbox", () => null); + expect(result.kind).toBe("sandbox_unreachable"); + }); + + it("recognizes OpenShell-framed probe output", () => { + const result = classifyOpenClawRuntimeFailure("my-sandbox", () => + probeResult( + "OpenShell sandbox exec output:\r\nstdout: nemoclaw-runtime-probe-v1 log=0 start=0 config=0\r\n", + ), + ); + expect(result.kind).toBe("base_only_image"); + }); + + it.each([ + [ + "nonzero probe", + { status: 1, stdout: "nemoclaw-runtime-probe-v1 log=0 start=0 config=0", stderr: "" }, + ], + ["malformed output", probeResult("not a runtime probe frame")], + [ + "ANSI-prefixed output", + probeResult("\u001b[31mnemoclaw-runtime-probe-v1 log=0 start=0 config=0"), + ], + ["tab-prefixed output", probeResult("\tnemoclaw-runtime-probe-v1 log=0 start=0 config=0")], + [ + "form-feed-prefixed output", + probeResult("\fnemoclaw-runtime-probe-v1 log=0 start=0 config=0"), + ], + ["case-altered marker", probeResult("NEMOCLAW-runtime-probe-v1 log=0 start=0 config=0")], + ])("keeps a %s inconclusive", (_name, probe) => { + const result = classifyOpenClawRuntimeFailure("my-sandbox", () => probe); + expect(result).toEqual({ + kind: "inconclusive", + gatewayLogPresent: null, + startupScriptPresent: null, + configPresent: null, + }); + }); +}); + +describe("shouldDiagnoseCustomOpenClawRuntime", () => { + it.each([ + ["custom OpenClaw Dockerfile", "/tmp/Dockerfile", "openclaw", true], + ["stock OpenClaw image", null, "openclaw", false], + ["custom Hermes Dockerfile", "/tmp/Dockerfile", "hermes", false], + ])("returns the expected gate for a %s", (_name, dockerfile, agent, expected) => { + expect(shouldDiagnoseCustomOpenClawRuntime(dockerfile, agent)).toBe(expected); + }); +}); diff --git a/src/lib/onboard/custom-openclaw-runtime-diagnosis.ts b/src/lib/onboard/custom-openclaw-runtime-diagnosis.ts new file mode 100644 index 0000000000..b4089f6d76 --- /dev/null +++ b/src/lib/onboard/custom-openclaw-runtime-diagnosis.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type SandboxCommandExecutor = ( + name: string, + script: string, +) => { status: number; stdout: string; stderr: string } | null; + +export type OpenClawRuntimeFailureKind = + | "normal_runtime" + | "base_only_image" + | "sandbox_unreachable" + | "inconclusive"; + +export interface OpenClawRuntimeFailureDiagnosis { + kind: OpenClawRuntimeFailureKind; + gatewayLogPresent: boolean | null; + startupScriptPresent: boolean | null; + configPresent: boolean | null; +} + +export interface CustomOpenClawRuntimeFailureHints { + gateway: string; + dashboard: string; +} + +const OPENCLAW_RUNTIME_PROBE = + "printf 'nemoclaw-runtime-probe-v1 '; " + + "if [ -e /tmp/gateway.log ]; then printf 'log=1 '; else printf 'log=0 '; fi; " + + "if [ -x /usr/local/bin/nemoclaw-start ]; then printf 'start=1 '; else printf 'start=0 '; fi; " + + "if [ -e /sandbox/.openclaw/openclaw.json ]; then printf 'config=1\\n'; else printf 'config=0\\n'; fi"; + +/** + * This diagnostic handles an invalid state authored at the custom-image source + * boundary: a user Dockerfile passed to `onboard --from` selects the + * intermediate `sandbox-base` image as its final runtime. Preventing that state + * earlier would require reliable static analysis of arbitrary multi-stage + * Dockerfiles or a new build-time image contract. The focused classifier tests + * prevent false positives, and the live custom-plugin E2E proves the supported + * full-runtime path. + * REMOVE-WHEN: #5998 supplies the managed plugin lifecycle, or a build-time + * image contract validator replaces this runtime fallback. + */ + +export function shouldDiagnoseCustomOpenClawRuntime( + fromDockerfile: string | null | undefined, + selectedAgentName: string | null | undefined, +): boolean { + return Boolean(fromDockerfile && selectedAgentName === "openclaw"); +} + +/** + * Distinguish the documented sandbox-base-only failure from an ordinary + * gateway startup failure without reading config or log contents. + */ +export function classifyOpenClawRuntimeFailure( + sandboxName: string, + executeSandboxCommand: SandboxCommandExecutor, +): OpenClawRuntimeFailureDiagnosis { + const result = executeSandboxCommand(sandboxName, OPENCLAW_RUNTIME_PROBE); + if (!result) { + return { + kind: "sandbox_unreachable", + gatewayLogPresent: null, + startupScriptPresent: null, + configPresent: null, + }; + } + + const match = result.stdout.match( + /(?:^|\n)[ ]*(?:(?:\[stdout\]|stdout:)[ ]*)?nemoclaw-runtime-probe-v1 log=([01]) start=([01]) config=([01])(?:\r?\n|$)/, + ); + if (result.status !== 0 || !match) { + return { + kind: "inconclusive", + gatewayLogPresent: null, + startupScriptPresent: null, + configPresent: null, + }; + } + + const gatewayLogPresent = match[1] === "1"; + const startupScriptPresent = match[2] === "1"; + const configPresent = match[3] === "1"; + let kind: OpenClawRuntimeFailureKind = "inconclusive"; + if (!gatewayLogPresent && !startupScriptPresent && !configPresent) { + kind = "base_only_image"; + } else if (startupScriptPresent && configPresent) { + kind = "normal_runtime"; + } + return { kind, gatewayLogPresent, startupScriptPresent, configPresent }; +} + +export function buildCustomOpenClawRuntimeFailureHints( + diagnosis: OpenClawRuntimeFailureDiagnosis, +): CustomOpenClawRuntimeFailureHints | null { + if (diagnosis.kind !== "base_only_image") return null; + return { + gateway: + "This custom image does not contain the NemoClaw-managed OpenClaw runtime: " + + "`/usr/local/bin/nemoclaw-start` and `/sandbox/.openclaw/openclaw.json` are missing, " + + "and `/tmp/gateway.log` was never created. `nemoclaw onboard --from` uses the supplied " + + "Dockerfile as the complete sandbox image; it does not layer it over the managed runtime. " + + "Build from the full NemoClaw Dockerfile and context for the same release instead of using " + + "`ghcr.io/nvidia/nemoclaw/sandbox-base` directly.", + dashboard: + "The dashboard cannot start until the custom image includes the NemoClaw-managed OpenClaw runtime.", + }; +} diff --git a/src/lib/onboard/not-ready-recreate.test.ts b/src/lib/onboard/not-ready-recreate.test.ts index 311bfc495b..5180c52671 100644 --- a/src/lib/onboard/not-ready-recreate.test.ts +++ b/src/lib/onboard/not-ready-recreate.test.ts @@ -11,6 +11,7 @@ import { NotReadySandboxError, resolveNotReadyOutcome, selectPreUpgradeBackupForCreate, + UnsafeCustomImagePluginBackupError, } from "./not-ready-recreate"; const BACKUP_PATH = "/home/user/.nemoclaw/rebuild-backups/my-assistant/2026-07-01T06-50-40-925Z"; @@ -124,7 +125,7 @@ describe("selectPreUpgradeBackupForCreate", () => { process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; getLatestBackupSpy.mockReturnValue({ backupPath: BACKUP_PATH, - } as ReturnType); + } as unknown as ReturnType); expect( selectPreUpgradeBackupForCreate({ liveExists: false, @@ -138,6 +139,78 @@ describe("selectPreUpgradeBackupForCreate", () => { ); }); + it("blocks a legacy custom OpenClaw backup before installer recreation (#6108)", () => { + process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; + getLatestBackupSpy.mockReturnValue({ + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: BACKUP_PATH, + openclawImagePluginInstalls: [], + } as unknown as ReturnType); + + expect(() => + selectPreUpgradeBackupForCreate({ + liveExists: false, + hasExistingRegistryEntry: true, + existingSandboxEntry: { + name: "my-assistant", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + }, + sandboxName: "my-assistant", + note, + }), + ).toThrow(UnsafeCustomImagePluginBackupError); + + expect(note).not.toHaveBeenCalled(); + }); + + it("accepts an authoritative custom OpenClaw backup for installer recreation (#6108)", () => { + process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; + getLatestBackupSpy.mockReturnValue({ + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: BACKUP_PATH, + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls: [], + } as unknown as ReturnType); + + expect( + selectPreUpgradeBackupForCreate({ + liveExists: false, + hasExistingRegistryEntry: true, + existingSandboxEntry: { + name: "my-assistant", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + }, + sandboxName: "my-assistant", + note, + }), + ).toBe(BACKUP_PATH); + }); + + it("blocks custom OpenClaw installer recreation when no valid backup is readable (#6108)", () => { + process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; + getLatestBackupSpy.mockReturnValue(null); + + expect(() => + selectPreUpgradeBackupForCreate({ + liveExists: false, + hasExistingRegistryEntry: true, + existingSandboxEntry: { + name: "my-assistant", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + }, + sandboxName: "my-assistant", + note, + }), + ).toThrow(UnsafeCustomImagePluginBackupError); + + expect(note).not.toHaveBeenCalled(); + }); + it("returns null and notes fresh-state recreate when installer restore intent finds no backup", () => { process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; getLatestBackupSpy.mockReturnValue(null); @@ -202,7 +275,7 @@ describe("applyNonInteractiveNotReadyDecision", () => { process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; getLatestBackupSpy.mockReturnValue({ backupPath: BACKUP_PATH, - } as ReturnType); + } as unknown as ReturnType); expect(applyNonInteractiveNotReadyDecision("my-assistant", note)).toBe(BACKUP_PATH); expect(note).toHaveBeenCalledWith( expect.stringMatching(/recreating and restoring pre-upgrade backup/), @@ -249,12 +322,55 @@ describe("resolveNotReadyOutcome", () => { process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; getLatestBackupSpy.mockReturnValue({ backupPath: BACKUP_PATH, - } as ReturnType); + } as unknown as ReturnType); expect(resolveNotReadyOutcome("my-assistant", note)).toEqual({ kind: "proceed", restoreBackupPath: BACKUP_PATH, }); }); + + it("blocks live not-ready custom OpenClaw recreation with a legacy backup (#6108)", () => { + process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; + getLatestBackupSpy.mockReturnValue({ + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: BACKUP_PATH, + openclawImagePluginInstalls: [], + } as unknown as ReturnType); + + const outcome = resolveNotReadyOutcome("my-assistant", note, { + name: "my-assistant", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + }); + + expect(outcome.kind).toBe("blocked"); + expect(outcome).toMatchObject({ + hints: expect.arrayContaining([expect.stringContaining("lacks verified plugin provenance")]), + }); + expect(note).not.toHaveBeenCalled(); + }); + + it("blocks an orphan sandbox when the requested target is custom OpenClaw (#6108)", () => { + process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE = "1"; + getLatestBackupSpy.mockReturnValue({ + agentType: "openclaw", + dir: "/sandbox/.openclaw", + backupPath: BACKUP_PATH, + openclawImagePluginInstalls: [], + } as unknown as ReturnType); + + const outcome = resolveNotReadyOutcome("orphan", note, null, true); + + expect(outcome.kind).toBe("blocked"); + expect(outcome).toMatchObject({ + hints: expect.arrayContaining([ + expect.stringContaining("new sandbox name"), + expect.stringContaining("NEMOCLAW_RECREATE_WITHOUT_BACKUP=1"), + ]), + }); + expect(note).not.toHaveBeenCalled(); + }); }); describe("installerRestoreOnRecreateFromEnv", () => { diff --git a/src/lib/onboard/not-ready-recreate.ts b/src/lib/onboard/not-ready-recreate.ts index e45e5c6f90..580c946498 100644 --- a/src/lib/onboard/not-ready-recreate.ts +++ b/src/lib/onboard/not-ready-recreate.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SandboxEntry } from "../state/registry"; import * as sandboxState from "../state/sandbox"; export interface NotReadyRecreateInput { @@ -52,6 +53,47 @@ export class NotReadySandboxError extends Error { } } +export class UnsafeCustomImagePluginBackupError extends Error { + readonly hints: readonly string[]; + + constructor(sandboxName: string, backupPath: string | null) { + super(`Custom-image backup for '${sandboxName}' lacks verified OpenClaw plugin provenance.`); + this.name = "UnsafeCustomImagePluginBackupError"; + this.hints = [ + ` The pre-upgrade backup for custom OpenClaw sandbox '${sandboxName}' lacks verified plugin provenance.`, + " Automatic recreation is blocked before delete so image-owned plugins cannot be restored as user state.", + " The sandbox and backup are untouched — no data was lost.", + backupPath + ? ` Recover manually from: ${backupPath}` + : " No valid pre-upgrade backup was found; inspect the existing sandbox before manual recovery.", + " To preserve state, onboard the custom image under a new sandbox name and manually migrate only user-owned state.", + " Or, after taking an independent manual backup, explicitly accept destructive same-name recreation with NEMOCLAW_RECREATE_WITHOUT_BACKUP=1.", + ]; + } +} + +function assertNotReadyBackupPluginProvenance( + sandboxName: string, + backup: sandboxState.SnapshotEntry | null, + entry: SandboxEntry | null, + requireOpenClawImagePluginProvenance: boolean, +): void { + const customOpenClaw = + requireOpenClawImagePluginProvenance || + (Boolean(entry?.fromDockerfile) && (!entry?.agent || entry.agent === "openclaw")); + if (!backup) { + if (customOpenClaw) throw new UnsafeCustomImagePluginBackupError(sandboxName, null); + return; + } + const markedCustomImageBackup = backup.reconcileOpenClawImagePluginProvenance === true; + if ( + (customOpenClaw || markedCustomImageBackup) && + !sandboxState.hasAuthoritativeOpenClawImagePluginProvenance(backup) + ) { + throw new UnsafeCustomImagePluginBackupError(sandboxName, backup.backupPath); + } +} + export function installerRestoreOnRecreateFromEnv(env: NodeJS.ProcessEnv): boolean { return env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1"; } @@ -59,6 +101,8 @@ export function installerRestoreOnRecreateFromEnv(env: NodeJS.ProcessEnv): boole export interface PreUpgradeBackupSelectInput { liveExists: boolean; hasExistingRegistryEntry: boolean; + existingSandboxEntry?: SandboxEntry | null; + requireOpenClawImagePluginProvenance?: boolean; sandboxName: string; note: (message: string) => void; } @@ -102,6 +146,12 @@ export function selectPreUpgradeBackupForCreate(input: PreUpgradeBackupSelectInp return null; } const latest = sandboxState.getLatestBackup(input.sandboxName); + assertNotReadyBackupPluginProvenance( + input.sandboxName, + latest, + input.existingSandboxEntry ?? null, + input.requireOpenClawImagePluginProvenance === true, + ); if (latest?.backupPath) { input.note( ` Found pre-upgrade backup for '${input.sandboxName}'; it will be restored after recreation.`, @@ -123,9 +173,19 @@ export function selectPreUpgradeBackupForCreate(input: PreUpgradeBackupSelectInp export function applyNonInteractiveNotReadyDecision( sandboxName: string, note: (message: string) => void, + existingSandboxEntry: SandboxEntry | null = null, + requireOpenClawImagePluginProvenance = false, ): string | null { const installerRestoreOnRecreate = installerRestoreOnRecreateFromEnv(process.env); const latest = installerRestoreOnRecreate ? sandboxState.getLatestBackup(sandboxName) : null; + if (installerRestoreOnRecreate) { + assertNotReadyBackupPluginProvenance( + sandboxName, + latest, + existingSandboxEntry, + requireOpenClawImagePluginProvenance, + ); + } const decision = decideNonInteractiveNotReadyAction({ sandboxName, installerRestoreOnRecreate, @@ -156,14 +216,25 @@ export type NonInteractiveNotReadyOutcome = export function resolveNotReadyOutcome( sandboxName: string, note: (message: string) => void, + existingSandboxEntry: SandboxEntry | null = null, + requireOpenClawImagePluginProvenance = false, ): NonInteractiveNotReadyOutcome { try { return { kind: "proceed", - restoreBackupPath: applyNonInteractiveNotReadyDecision(sandboxName, note), + restoreBackupPath: applyNonInteractiveNotReadyDecision( + sandboxName, + note, + existingSandboxEntry, + requireOpenClawImagePluginProvenance, + ), }; } catch (error) { - if (!(error instanceof NotReadySandboxError)) throw error; + if ( + !(error instanceof NotReadySandboxError) && + !(error instanceof UnsafeCustomImagePluginBackupError) + ) + throw error; return { kind: "blocked", hints: error.hints }; } } diff --git a/src/lib/onboard/sandbox-backup-on-recreate.test.ts b/src/lib/onboard/sandbox-backup-on-recreate.test.ts index 6442d16985..060e944383 100644 --- a/src/lib/onboard/sandbox-backup-on-recreate.test.ts +++ b/src/lib/onboard/sandbox-backup-on-recreate.test.ts @@ -41,6 +41,46 @@ describe("backupSandboxBeforeRecreate", () => { expect(log).toHaveBeenCalledWith(expect.stringContaining("State backed up")); }); + it("rejects an unmarked custom OpenClaw backup before recreate deletion (#6108)", () => { + const errorLog = vi.fn(); + const result = backupSandboxBeforeRecreate({ + sandboxName: "my-assistant", + sandboxEntry: { + name: "my-assistant", + agent: "openclaw", + fromDockerfile: "/tmp/Dockerfile.custom", + }, + backupImpl: () => makeBackup(), + log: vi.fn(), + errorLog, + }); + + expect(result.ok).toBe(false); + expect(result.failureKind).toBe("plugin-provenance"); + expect(errorLog).toHaveBeenCalledWith( + expect.stringContaining("aborting recreate before delete"), + ); + }); + + it("rejects an unmarked backup for an orphan custom OpenClaw target (#6108)", () => { + const errorLog = vi.fn(); + const result = backupSandboxBeforeRecreate({ + sandboxName: "orphan", + sandboxEntry: null, + requireOpenClawImagePluginProvenance: true, + backupImpl: () => makeBackup(), + log: vi.fn(), + errorLog, + }); + + expect(result.ok).toBe(false); + expect(result.failureKind).toBe("plugin-provenance"); + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("new name")); + expect(errorLog).toHaveBeenCalledWith( + expect.stringContaining("NEMOCLAW_RECREATE_WITHOUT_BACKUP=1"), + ); + }); + it("returns ok:false with failureKind=partial when some entries failed", () => { const backup = makeBackup({ success: false, diff --git a/src/lib/onboard/sandbox-backup-on-recreate.ts b/src/lib/onboard/sandbox-backup-on-recreate.ts index d475f4a3bc..bbbfda4dd9 100644 --- a/src/lib/onboard/sandbox-backup-on-recreate.ts +++ b/src/lib/onboard/sandbox-backup-on-recreate.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SandboxEntry } from "../state/registry"; import type { BackupResult } from "../state/sandbox"; import * as sandboxState from "../state/sandbox"; @@ -8,12 +9,19 @@ export type SandboxBackupImpl = (sandboxName: string) => BackupResult; export interface PreRecreateBackupOptions { sandboxName: string; + sandboxEntry?: SandboxEntry | null; + requireOpenClawImagePluginProvenance?: boolean; backupImpl?: SandboxBackupImpl; log?: (msg: string) => void; errorLog?: (msg: string) => void; } -export type PreRecreateBackupFailureKind = "none" | "partial" | "empty" | "threw"; +export type PreRecreateBackupFailureKind = + | "none" + | "partial" + | "empty" + | "threw" + | "plugin-provenance"; export interface PreRecreateBackupResult { ok: boolean; @@ -28,9 +36,29 @@ export function backupSandboxBeforeRecreate( const log = opts.log ?? ((m: string) => console.log(m)); const errorLog = opts.errorLog ?? ((m: string) => console.error(m)); const backupImpl = opts.backupImpl ?? sandboxState.backupSandboxState; + const sandboxEntry = opts.sandboxEntry ?? null; + const customOpenClaw = + opts.requireOpenClawImagePluginProvenance === true || + (Boolean(sandboxEntry?.fromDockerfile) && + (!sandboxEntry?.agent || sandboxEntry.agent === "openclaw")); try { const backup = backupImpl(opts.sandboxName); if (backup.success && backup.manifest?.backupPath) { + if ( + (customOpenClaw || backup.manifest.reconcileOpenClawImagePluginProvenance === true) && + !sandboxState.hasAuthoritativeOpenClawImagePluginProvenance(backup.manifest) + ) { + errorLog( + " Custom-image OpenClaw plugin provenance is missing; aborting recreate before delete.", + ); + errorLog( + " Keep the sandbox and backup untouched; onboard under a new name and manually migrate user-owned state.", + ); + errorLog( + " Or take an independent manual backup, then explicitly accept destructive recreation with NEMOCLAW_RECREATE_WITHOUT_BACKUP=1.", + ); + return { ok: false, backup, failureKind: "plugin-provenance" }; + } log( ` ✓ State backed up (${backup.backedUpDirs.length} directories, ${backup.backedUpFiles.length} files)`, ); diff --git a/src/lib/onboard/sandbox-recreate-protection.test.ts b/src/lib/onboard/sandbox-recreate-protection.test.ts new file mode 100644 index 0000000000..6da8b2b806 --- /dev/null +++ b/src/lib/onboard/sandbox-recreate-protection.test.ts @@ -0,0 +1,63 @@ +// 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 { createSandboxRecreateProtection } from "./sandbox-recreate-protection"; + +describe("createSandboxRecreateProtection", () => { + it("forwards one custom-image protection context to every recreation path (#6108)", () => { + const note = vi.fn(); + const sandboxEntry = { + name: "my-assistant", + agent: "openclaw" as const, + fromDockerfile: "/tmp/Dockerfile.custom", + }; + const selectPreUpgradeBackupForCreate = vi.fn(() => "/tmp/backup"); + const resolveNotReadyOutcome = vi.fn(() => ({ + kind: "proceed" as const, + restoreBackupPath: "/tmp/backup", + })); + const backupResult = { + ok: true, + backup: null, + failureKind: "none" as const, + }; + const backupSandboxBeforeRecreate = vi.fn(() => backupResult); + const protection = createSandboxRecreateProtection( + { + sandboxName: "my-assistant", + sandboxEntry, + customOpenClawImage: true, + note, + }, + { + selectPreUpgradeBackupForCreate, + resolveNotReadyOutcome, + backupSandboxBeforeRecreate, + }, + ); + + expect(protection.selectPreUpgradeBackup(true)).toBe("/tmp/backup"); + expect(selectPreUpgradeBackupForCreate).toHaveBeenCalledWith({ + liveExists: true, + hasExistingRegistryEntry: true, + existingSandboxEntry: sandboxEntry, + requireOpenClawImagePluginProvenance: true, + sandboxName: "my-assistant", + note, + }); + + expect(protection.resolveNotReadyOutcome()).toEqual({ + kind: "proceed", + restoreBackupPath: "/tmp/backup", + }); + expect(resolveNotReadyOutcome).toHaveBeenCalledWith("my-assistant", note, sandboxEntry, true); + + expect(protection.backup()).toBe(backupResult); + expect(backupSandboxBeforeRecreate).toHaveBeenCalledWith({ + sandboxName: "my-assistant", + sandboxEntry, + requireOpenClawImagePluginProvenance: true, + }); + }); +}); diff --git a/src/lib/onboard/sandbox-recreate-protection.ts b/src/lib/onboard/sandbox-recreate-protection.ts new file mode 100644 index 0000000000..94df005ab2 --- /dev/null +++ b/src/lib/onboard/sandbox-recreate-protection.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxEntry } from "../state/registry"; +import * as notReadyRecreate from "./not-ready-recreate"; +import { + backupSandboxBeforeRecreate, + type PreRecreateBackupResult, +} from "./sandbox-backup-on-recreate"; + +export interface SandboxRecreateProtectionOptions { + sandboxName: string; + sandboxEntry: SandboxEntry | null; + customOpenClawImage: boolean; + note(message: string): void; +} + +interface SandboxRecreateProtectionDeps { + selectPreUpgradeBackupForCreate: typeof notReadyRecreate.selectPreUpgradeBackupForCreate; + resolveNotReadyOutcome: typeof notReadyRecreate.resolveNotReadyOutcome; + backupSandboxBeforeRecreate: typeof backupSandboxBeforeRecreate; +} + +const defaultDeps: SandboxRecreateProtectionDeps = { + selectPreUpgradeBackupForCreate: notReadyRecreate.selectPreUpgradeBackupForCreate, + resolveNotReadyOutcome: notReadyRecreate.resolveNotReadyOutcome, + backupSandboxBeforeRecreate, +}; + +/** Bind the shared state-preservation checks used by every onboard recreation path. */ +export function createSandboxRecreateProtection( + options: SandboxRecreateProtectionOptions, + deps: SandboxRecreateProtectionDeps = defaultDeps, +) { + const { sandboxName, sandboxEntry, customOpenClawImage, note } = options; + + return { + selectPreUpgradeBackup(liveExists: boolean): string | null { + return deps.selectPreUpgradeBackupForCreate({ + liveExists, + hasExistingRegistryEntry: sandboxEntry !== null, + existingSandboxEntry: sandboxEntry, + requireOpenClawImagePluginProvenance: customOpenClawImage, + sandboxName, + note, + }); + }, + resolveNotReadyOutcome(): notReadyRecreate.NonInteractiveNotReadyOutcome { + return deps.resolveNotReadyOutcome(sandboxName, note, sandboxEntry, customOpenClawImage); + }, + backup(): PreRecreateBackupResult { + return deps.backupSandboxBeforeRecreate({ + sandboxName, + sandboxEntry, + requireOpenClawImagePluginProvenance: customOpenClawImage, + }); + }, + }; +} diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index 579aff39af..58aa8353bc 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -26,6 +26,13 @@ describe("buildCreatedSandboxRegistryEntry", () => { schemaVersion: 1 as const, plan: { sandboxName: "demo" }, }; + const openclawImagePluginInstalls = [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: ["/opt/weather-plugin"], + }, + ]; const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", @@ -42,6 +49,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { agent: null, agentVersionKnown: true, imageTag: "nemoclaw-demo:123", + openclawImagePluginInstalls, appliedPolicies: ["discord", "slack"], observabilityEnabled: true, policyTier: "restricted", @@ -67,6 +75,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", imageTag: "nemoclaw-demo:123", + openclawImagePluginInstalls, policies: ["discord", "slack"], toolDisclosure: "progressive", observabilityEnabled: true, @@ -89,6 +98,11 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.agent).toBeNull(); expect(entry.agentVersion).toBeTruthy(); expect(entry.nemoclawVersion).toBeTruthy(); + expect(entry.openclawImagePluginInstalls).not.toBe(openclawImagePluginInstalls); + expect(entry.openclawImagePluginInstalls?.[0]).not.toBe(openclawImagePluginInstalls[0]); + expect(entry.openclawImagePluginInstalls?.[0]?.loadPaths).not.toBe( + openclawImagePluginInstalls[0]?.loadPaths, + ); expect(entry.messaging).toBe(plannedMessagingState); const rawEntry = entry as unknown as Record; expect(rawEntry.messagingChannels).toBeUndefined(); @@ -323,6 +337,7 @@ describe("registerCreatedSandbox", () => { agent: null, agentVersionKnown: true, imageTag: null, + openclawImagePluginInstalls: [], appliedPolicies: [], plannedMessagingState: undefined, hermesToolGateways: [], @@ -335,5 +350,6 @@ describe("registerCreatedSandbox", () => { expect(registerSandbox).toHaveBeenCalledWith(entry); expect(entry.name).toBe("demo"); + expect(entry.openclawImagePluginInstalls).toEqual([]); }); }); diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 52cb7f48e9..612345506e 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -6,6 +6,7 @@ import type { InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields } from "../inference/selection"; 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 { SandboxEntry, SandboxMcpState, SandboxMessagingState } from "../state/registry"; import * as registry from "../state/registry"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; @@ -34,6 +35,7 @@ export interface CreatedSandboxRegistryEntryInput { agent: AgentDefinition | null | undefined; agentVersionKnown: boolean; imageTag: string | null; + openclawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; appliedPolicies: string[]; toolDisclosure?: ToolDisclosure; observabilityEnabled?: boolean; @@ -113,6 +115,14 @@ export function buildCreatedSandboxRegistryEntry( ...input.runtimeFields, ...getSandboxAgentRegistryFields(input.agent, input.agentVersionKnown), imageTag: input.imageTag, + ...(input.openclawImagePluginInstalls !== undefined + ? { + openclawImagePluginInstalls: input.openclawImagePluginInstalls.map((install) => ({ + ...install, + ...(install.loadPaths !== undefined ? { loadPaths: [...install.loadPaths] } : {}), + })), + } + : {}), policies: input.appliedPolicies, toolDisclosure: input.toolDisclosure ?? DEFAULT_TOOL_DISCLOSURE, observabilityEnabled: input.observabilityEnabled === true, diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index 2586d172a3..e6710ec4e2 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -5,6 +5,22 @@ import { describe, expect, it } from "vitest"; import { mergeOpenClawRestoredConfig } from "./openclaw-config-merge"; +const WEATHER_V1_PATH = "/sandbox/.openclaw/extensions/weather"; +const WEATHER_V2_PATH = "/sandbox/.openclaw/extensions/weather-v2"; +const USER_PLUGIN_PATH = "/sandbox/.openclaw/extensions/user-plugin"; + +function pluginConfig( + entries: Record, + installs: Record, + paths: string[], +) { + return { plugins: { entries, installs, load: { paths } } }; +} + +function imageInstall(id: string, installPath: string, loadPaths: string[] = []) { + return { id, installPath, loadPaths }; +} + describe("mergeOpenClawRestoredConfig", () => { it("keeps rebuilt runtime-owned config while restoring durable backup-only settings", () => { const merged = mergeOpenClawRestoredConfig( @@ -385,4 +401,250 @@ describe("mergeOpenClawRestoredConfig", () => { expect(merged.plugins.entries.tavily).toBeUndefined(); expect(merged.plugins.entries.customPlugin).toEqual({ enabled: true }); }); + + it("removes all prior image-owned config while preserving user-owned plugin state", () => { + const merged = mergeOpenClawRestoredConfig( + { + channels: { + weather: { token: "stale-image-channel" }, + "user-channel": { room: "keep" }, + }, + plugins: { + allow: ["weather", "user-plugin"], + deny: ["weather", "user-denied"], + entries: { + weather: { enabled: true, config: { revision: "v1" } }, + "user-plugin": { enabled: true }, + }, + installs: { + weather: { installPath: WEATHER_V1_PATH }, + "user-plugin": { installPath: USER_PLUGIN_PATH }, + }, + load: { paths: [WEATHER_V1_PATH, USER_PLUGIN_PATH] }, + slots: { memory: "weather", contextEngine: "user-plugin" }, + }, + }, + { channels: {}, plugins: { allow: [], deny: [], entries: {}, load: { paths: [] } } }, + { + freshImagePluginInstalls: [], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH, [WEATHER_V1_PATH])], + }, + ); + + expect(merged).toMatchObject({ + channels: { "user-channel": { room: "keep" } }, + plugins: { + allow: ["user-plugin"], + deny: ["user-denied"], + entries: { "user-plugin": { enabled: true } }, + load: { paths: [USER_PLUGIN_PATH] }, + slots: { contextEngine: "user-plugin" }, + }, + }); + expect((merged as { channels: Record }).channels.weather).toBeUndefined(); + expect((merged as { plugins: Record }).plugins.installs).toBeUndefined(); + }); + + it("keeps only the fresh plugin when an image plugin is renamed", () => { + const merged = mergeOpenClawRestoredConfig( + pluginConfig( + { weather: { enabled: true, config: { revision: "v1" } } }, + { weather: { installPath: WEATHER_V1_PATH } }, + [WEATHER_V1_PATH], + ), + pluginConfig( + { "weather-v2": { enabled: true, config: { revision: "v2" } } }, + { "weather-v2": { installPath: WEATHER_V2_PATH } }, + [WEATHER_V2_PATH], + ), + { + freshImagePluginInstalls: [imageInstall("weather-v2", WEATHER_V2_PATH, [WEATHER_V2_PATH])], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH, [WEATHER_V1_PATH])], + }, + ) as { plugins: Record }; + + expect(merged.plugins).toEqual({ + entries: { "weather-v2": { enabled: true, config: { revision: "v2" } } }, + load: { paths: [WEATHER_V2_PATH] }, + }); + }); + + it("preserves backup-only user plugins while removing a retired image plugin", () => { + const merged = mergeOpenClawRestoredConfig( + pluginConfig( + { + weather: { enabled: true }, + "user-plugin": { enabled: true, config: { owner: "user" } }, + }, + { + weather: { installPath: WEATHER_V1_PATH }, + "user-plugin": { installPath: USER_PLUGIN_PATH }, + }, + [WEATHER_V1_PATH, USER_PLUGIN_PATH], + ), + pluginConfig({}, {}, []), + { + freshImagePluginInstalls: [], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH, [WEATHER_V1_PATH])], + }, + ) as { plugins: Record }; + + expect(merged.plugins).toEqual({ + entries: { "user-plugin": { enabled: true, config: { owner: "user" } } }, + load: { paths: [USER_PLUGIN_PATH] }, + }); + }); + + it("does not treat a non-linked install path as an owned load path", () => { + const merged = mergeOpenClawRestoredConfig( + pluginConfig({ weather: { enabled: true } }, { weather: { installPath: WEATHER_V1_PATH } }, [ + WEATHER_V1_PATH, + ]), + pluginConfig({}, {}, []), + { + freshImagePluginInstalls: [], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { plugins: Record }; + + expect(merged.plugins).toEqual({ entries: {}, load: { paths: [WEATHER_V1_PATH] } }); + }); + + it("preserves retained allow and deny state when the fresh image omits those lists", () => { + const merged = mergeOpenClawRestoredConfig( + { + channels: { weather: { token: "stale" } }, + plugins: { + allow: ["weather", "user-plugin"], + deny: ["weather", "user-denied"], + entries: { weather: { enabled: false }, "user-plugin": { enabled: true } }, + slots: { memory: "weather" }, + }, + }, + { channels: {}, plugins: { entries: { weather: { enabled: true } } } }, + { + freshImagePluginInstalls: [imageInstall("weather", WEATHER_V2_PATH)], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { channels: Record; plugins: Record }; + + expect(merged.channels.weather).toBeUndefined(); + expect(merged.plugins).toMatchObject({ + allow: ["weather", "user-plugin"], + deny: ["weather", "user-denied"], + entries: { weather: { enabled: true }, "user-plugin": { enabled: true } }, + }); + expect(merged.plugins.slots).toBeUndefined(); + }); + + it("lets an explicit fresh allowlist own prior image IDs while retaining user IDs", () => { + const merged = mergeOpenClawRestoredConfig( + { plugins: { allow: ["weather", "user-plugin"], entries: {} } }, + { plugins: { allow: ["weather"], entries: { weather: { enabled: true } } } }, + { + freshImagePluginInstalls: [imageInstall("weather", WEATHER_V2_PATH)], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { plugins: Record }; + + expect(merged.plugins.allow).toEqual(["weather", "user-plugin"]); + }); + + it("lets a fresh allowlist override stale backup deny entries", () => { + const merged = mergeOpenClawRestoredConfig( + { plugins: { deny: ["weather", "user-denied"], entries: {} } }, + { plugins: { allow: ["weather"], entries: { weather: { enabled: true } } } }, + { + freshImagePluginInstalls: [imageInstall("weather", WEATHER_V2_PATH)], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { plugins: Record }; + + expect(merged.plugins.allow).toEqual(["weather"]); + expect(merged.plugins.deny).toEqual(["user-denied"]); + }); + + it("lets a fresh denylist override stale backup allow entries", () => { + const merged = mergeOpenClawRestoredConfig( + { plugins: { allow: ["weather", "user-plugin"], entries: {} } }, + { plugins: { deny: ["weather"], entries: { weather: { enabled: true } } } }, + { + freshImagePluginInstalls: [imageInstall("weather", WEATHER_V2_PATH)], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { plugins: Record }; + + expect(merged.plugins.allow).toEqual(["user-plugin"]); + expect(merged.plugins.deny).toEqual(["weather"]); + }); + + it("lets an explicit fresh denylist own prior image IDs while retaining user IDs", () => { + const merged = mergeOpenClawRestoredConfig( + { plugins: { deny: ["weather", "user-denied"], entries: {} } }, + { plugins: { deny: [], entries: { weather: { enabled: true } } } }, + { + freshImagePluginInstalls: [imageInstall("weather", WEATHER_V2_PATH)], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { plugins: Record }; + + expect(merged.plugins.deny).toEqual(["user-denied"]); + }); + + it("does not merge stale same-ID channel fallback into the fresh image channel", () => { + const merged = mergeOpenClawRestoredConfig( + { + channels: { weather: { enabled: false, token: "stale" } }, + plugins: { entries: { weather: { enabled: false } } }, + }, + { + channels: { weather: { enabled: true } }, + plugins: { entries: { weather: { enabled: true } } }, + }, + { + freshImagePluginInstalls: [imageInstall("weather", WEATHER_V2_PATH)], + previousImagePluginInstalls: [imageInstall("weather", WEATHER_V1_PATH)], + }, + ) as { channels: Record }; + + expect(merged.channels.weather).toEqual({ enabled: true }); + }); + + it("keeps legacy backup-only plugins but drops transient install records", () => { + const backup = pluginConfig( + { weather: { enabled: true, config: { revision: "v1" } } }, + { weather: { installPath: WEATHER_V1_PATH } }, + [WEATHER_V1_PATH], + ); + + expect(mergeOpenClawRestoredConfig(backup, pluginConfig({}, {}, []))).toEqual({ + plugins: { + entries: { weather: { enabled: true, config: { revision: "v1" } } }, + load: { paths: [WEATHER_V1_PATH] }, + }, + }); + }); + + it("fails closed when reconciliation receives incomplete or one-sided provenance", () => { + expect(() => + mergeOpenClawRestoredConfig(pluginConfig({}, {}, []), pluginConfig({}, {}, []), { + previousImagePluginInstalls: [ + { id: "weather", installPath: WEATHER_V1_PATH, loadPaths: undefined }, + ], + }), + ).toThrow("Complete previous and fresh OpenClaw image plugin provenance is required"); + expect(() => + mergeOpenClawRestoredConfig(pluginConfig({}, {}, []), pluginConfig({}, {}, []), { + freshImagePluginInstalls: [], + }), + ).toThrow("Complete previous and fresh OpenClaw image plugin provenance is required"); + expect(() => + mergeOpenClawRestoredConfig(pluginConfig({}, {}, []), pluginConfig({}, {}, []), { + freshImagePluginInstalls: [], + previousImagePluginInstalls: [ + { id: "weather", installPath: WEATHER_V1_PATH, loadPaths: undefined }, + ], + }), + ).toThrow("missing explicit load paths"); + }); }); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index b3bd0cad36..4155ddca4d 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -3,6 +3,7 @@ import { isRecord } from "../core/json-types.js"; import { listOpenClawManagedChannelNames } from "../messaging/channels/index.js"; +import type { OpenClawImagePluginInstall } from "./openclaw-plugin-restore.js"; const MANAGED_OPENCLAW_CHANNEL_NAMES = listOpenClawManagedChannelNames(); @@ -73,7 +74,11 @@ function mergeJsonObjects( return merged; } -function mergeOpenClawChannels(backupChannels: unknown, currentChannels: unknown): unknown { +function mergeOpenClawChannels( + backupChannels: unknown, + currentChannels: unknown, + previousImagePluginIds?: ReadonlySet, +): unknown { if (!isPlainJsonObject(backupChannels)) return cloneJson(currentChannels); const merged: Record = isPlainJsonObject(currentChannels) @@ -89,7 +94,7 @@ function mergeOpenClawChannels(backupChannels: unknown, currentChannels: unknown continue; } - if (MANAGED_OPENCLAW_CHANNELS.has(key)) { + if (MANAGED_OPENCLAW_CHANNELS.has(key) || previousImagePluginIds?.has(key)) { // Freshly generated channel blocks carry current OpenShell placeholder // revisions and current start/stop/add/remove state. Never resurrect a // managed channel that the fresh config omitted, and never overwrite a @@ -109,11 +114,13 @@ function mergeOpenClawChannels(backupChannels: unknown, currentChannels: unknown function mergeOpenClawEntryMap( backupEntries: unknown, currentEntries: unknown, + previousImagePluginIds?: ReadonlySet, ): Record | undefined { if (!isPlainJsonObject(backupEntries) && !isPlainJsonObject(currentEntries)) return undefined; const merged: Record = {}; if (isPlainJsonObject(backupEntries)) { for (const [key, value] of Object.entries(backupEntries)) { + if (previousImagePluginIds?.has(key)) continue; // Search-provider plugins are selected by the fresh rebuild. Omitting // one is meaningful: provider switches and disablement must not restore // a stale Brave/Tavily entry from the durable snapshot. @@ -129,6 +136,100 @@ function mergeOpenClawEntryMap( return merged; } +function mergeOpenClawPluginLoad( + backupLoad: unknown, + currentLoad: unknown, + previousImagePluginLoadPaths?: ReadonlySet, +): Record | undefined { + const backup = isPlainJsonObject(backupLoad) ? backupLoad : {}; + const current = isPlainJsonObject(currentLoad) ? currentLoad : {}; + const merged = mergeJsonObjects(current, backup); + const currentPaths = Array.isArray(current.paths) + ? current.paths.filter((entry): entry is string => typeof entry === "string") + : []; + const backupPaths = Array.isArray(backup.paths) + ? backup.paths.filter( + (entry): entry is string => + typeof entry === "string" && !previousImagePluginLoadPaths?.has(entry), + ) + : []; + const paths = [...new Set([...currentPaths, ...backupPaths])]; + if (Array.isArray(current.paths) || Array.isArray(backup.paths)) merged.paths = paths; + else delete merged.paths; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : undefined; +} + +function mergeOpenClawPluginIdList( + backupValue: unknown, + currentValue: unknown, + previousImagePluginIds?: ReadonlySet, + currentOppositeIds: ReadonlySet = new Set(), +): string[] | undefined { + const backup = stringArray(backupValue); + const current = stringArray(currentValue); + if (!backup && !current) return undefined; + return [ + ...new Set([ + ...(current ?? []), + ...(backup ?? []).filter( + (id) => !previousImagePluginIds?.has(id) && !currentOppositeIds.has(id), + ), + ]), + ]; +} + +function mergeOpenClawPluginSlots( + backupSlots: unknown, + currentSlots: unknown, + previousImagePluginIds?: ReadonlySet, +): Record | undefined { + if (!isPlainJsonObject(backupSlots) && !isPlainJsonObject(currentSlots)) return undefined; + const merged: Record = {}; + if (isPlainJsonObject(backupSlots)) { + for (const [key, value] of Object.entries(backupSlots)) { + if (typeof value === "string" && previousImagePluginIds?.has(value)) continue; + merged[key] = cloneJson(value); + } + } + if (isPlainJsonObject(currentSlots)) Object.assign(merged, cloneJson(currentSlots)); + return Object.keys(merged).length > 0 ? merged : undefined; +} + +type OpenClawImagePluginOwnership = { + ids?: ReadonlySet; + loadPaths?: ReadonlySet; +}; + +function imagePluginOwnership( + installs?: readonly OpenClawImagePluginInstall[], +): OpenClawImagePluginOwnership { + if (installs === undefined) return {}; + const ids = new Set(); + const loadPaths = new Set(); + for (const install of installs) { + if (!Array.isArray(install.loadPaths)) { + throw new Error("OpenClaw image plugin provenance is missing explicit load paths"); + } + ids.add(install.id); + for (const loadPath of install.loadPaths) loadPaths.add(loadPath); + } + return { ids, loadPaths }; +} + +function removedImagePluginIds( + previous: OpenClawImagePluginOwnership, + fresh: OpenClawImagePluginOwnership, +): ReadonlySet | undefined { + if (!previous.ids) return undefined; + return new Set([...previous.ids].filter((id) => !fresh.ids?.has(id))); +} + function mergeOpenClawTools(backupTools: unknown, currentTools: unknown): unknown { if (!isPlainJsonObject(backupTools)) return cloneJson(currentTools); if (!isPlainJsonObject(currentTools) && currentTools !== undefined && currentTools !== null) { @@ -271,23 +372,77 @@ function mergeOpenClawModels(backupModels: unknown, currentModels: unknown): unk return merged; } -function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown): unknown { - if (!isPlainJsonObject(backupPlugins)) return cloneJson(currentPlugins); +function mergeOpenClawPlugins( + backupPlugins: unknown, + currentPlugins: unknown, + previousOwnership: OpenClawImagePluginOwnership, + freshOwnership: OpenClawImagePluginOwnership, +): Record | undefined { + if (!isPlainJsonObject(backupPlugins) && !isPlainJsonObject(currentPlugins)) return undefined; + const backup = isPlainJsonObject(backupPlugins) ? backupPlugins : {}; const current = isPlainJsonObject(currentPlugins) ? currentPlugins : {}; - - const merged = mergeJsonObjects(current, backupPlugins); - const entries = mergeOpenClawEntryMap(backupPlugins.entries, current.entries); + const merged = mergeJsonObjects(current, backup); + // OpenClaw injects install records only into transient command snapshots. Its + // durable ledger is separate, so never write this synthetic map to openclaw.json. + delete merged.installs; + const entries = mergeOpenClawEntryMap(backup.entries, current.entries, previousOwnership.ids); if (entries) merged.entries = entries; - return merged; + else delete merged.entries; + + const currentAllow = stringArray(current.allow); + const currentDeny = stringArray(current.deny); + const removedIds = removedImagePluginIds(previousOwnership, freshOwnership); + const backupAllowOwnedIds = currentAllow ? previousOwnership.ids : removedIds; + const backupDenyOwnedIds = currentDeny ? previousOwnership.ids : removedIds; + const allow = mergeOpenClawPluginIdList( + backup.allow, + current.allow, + backupAllowOwnedIds, + new Set(currentDeny ?? []), + ); + if (allow && allow.length > 0) merged.allow = allow; + else delete merged.allow; + const deny = mergeOpenClawPluginIdList( + backup.deny, + current.deny, + backupDenyOwnedIds, + new Set(currentAllow ?? []), + ); + if (deny && deny.length > 0) merged.deny = deny; + else delete merged.deny; + + const slots = mergeOpenClawPluginSlots(backup.slots, current.slots, previousOwnership.ids); + if (slots) merged.slots = slots; + else delete merged.slots; + + const load = mergeOpenClawPluginLoad(backup.load, current.load, previousOwnership.loadPaths); + if (load) merged.load = load; + else delete merged.load; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export interface OpenClawConfigMergeOptions { + freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; + previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; } export function mergeOpenClawRestoredConfig( backedUpConfig: unknown, currentConfig: unknown, + options: OpenClawConfigMergeOptions = {}, ): unknown { - if (!isPlainJsonObject(backedUpConfig)) return cloneJson(currentConfig ?? backedUpConfig); - if (!isPlainJsonObject(currentConfig)) return cloneJson(backedUpConfig); + if (!isPlainJsonObject(backedUpConfig) || !isPlainJsonObject(currentConfig)) { + throw new Error("OpenClaw selective config merge requires JSON objects"); + } + if ( + (options.previousImagePluginInstalls === undefined) !== + (options.freshImagePluginInstalls === undefined) + ) { + throw new Error("Complete previous and fresh OpenClaw image plugin provenance is required"); + } + const previousOwnership = imagePluginOwnership(options.previousImagePluginInstalls); + const freshOwnership = imagePluginOwnership(options.freshImagePluginInstalls); const merged = mergeJsonObjects(currentConfig, backedUpConfig); for (const key of OPENCLAW_CONFIG_RESTORE_OWNERSHIP.runtimeSections) { @@ -295,9 +450,18 @@ export function mergeOpenClawRestoredConfig( else delete merged[key]; } - merged.channels = mergeOpenClawChannels(backedUpConfig.channels, currentConfig.channels); + merged.channels = mergeOpenClawChannels( + backedUpConfig.channels, + currentConfig.channels, + previousOwnership.ids, + ); merged.models = mergeOpenClawModels(backedUpConfig.models, currentConfig.models); - merged.plugins = mergeOpenClawPlugins(backedUpConfig.plugins, currentConfig.plugins); + merged.plugins = mergeOpenClawPlugins( + backedUpConfig.plugins, + currentConfig.plugins, + previousOwnership, + freshOwnership, + ); merged.tools = mergeOpenClawTools(backedUpConfig.tools, currentConfig.tools); return merged; diff --git a/src/lib/state/openclaw-config-restore-input.test.ts b/src/lib/state/openclaw-config-restore-input.test.ts index dbe6493f34..910fa16b72 100644 --- a/src/lib/state/openclaw-config-restore-input.test.ts +++ b/src/lib/state/openclaw-config-restore-input.test.ts @@ -74,4 +74,52 @@ describe("buildOpenClawConfigRestoreInput", () => { expect(result.error).toContain("refusing unsafe wholesale backup restore"); } }); + + it("reconciles complete plugin provenance without persisting transient installs", () => { + const result = buildOpenClawConfigRestoreInput( + bufferJson({ + plugins: { + entries: { weather: { enabled: true } }, + installs: { weather: { installPath: "/sandbox/.openclaw/extensions/weather" } }, + }, + }), + bufferJson({ plugins: { entries: {} } }), + { + freshImagePluginInstalls: [], + previousImagePluginInstalls: [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }, + ], + }, + ); + + expect(result.ok).toBe(true); + expect(result.ok ? JSON.parse(result.input.toString("utf8")) : null).toEqual({ + plugins: { entries: {} }, + }); + }); + + it("fails closed on incomplete plugin provenance", () => { + const result = buildOpenClawConfigRestoreInput( + bufferJson({ plugins: { entries: {} } }), + bufferJson({ plugins: { entries: {} } }), + { + freshImagePluginInstalls: [], + previousImagePluginInstalls: [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + }, + ], + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("missing explicit load paths"), + }); + }); }); diff --git a/src/lib/state/openclaw-config-restore-input.ts b/src/lib/state/openclaw-config-restore-input.ts index e270af10fd..00b8439176 100644 --- a/src/lib/state/openclaw-config-restore-input.ts +++ b/src/lib/state/openclaw-config-restore-input.ts @@ -4,7 +4,14 @@ import { spawnSync } from "child_process"; import { shellQuote } from "../runner.js"; -import { mergeOpenClawRestoredConfig } from "./openclaw-config-merge.js"; +import { + mergeOpenClawRestoredConfig, + type OpenClawConfigMergeOptions, +} from "./openclaw-config-merge.js"; +import { + hasCompleteOpenClawImagePluginProvenance, + type OpenClawImagePluginInstall, +} from "./openclaw-plugin-restore.js"; export interface OpenClawConfigStateFileSpec { path: string; @@ -46,7 +53,9 @@ export type OpenClawConfigRestoreInputResult = export interface OpenClawConfigRestoreFromSandboxOptions { backupContents: Buffer; dir: string; + freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; log?: (message: string) => void; + previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; specPath: string; sshArgs: readonly string[]; } @@ -92,6 +101,7 @@ function readCurrentOpenClawConfig( export function buildOpenClawConfigRestoreInput( backupContents: Buffer, currentContents: Buffer | null, + options: OpenClawConfigMergeOptions = {}, ): OpenClawConfigRestoreInputResult { if (!currentContents) { return { ok: false, error: "openclaw.json selective merge requires current rebuilt config" }; @@ -100,7 +110,7 @@ export function buildOpenClawConfigRestoreInput( try { const backedUpConfig = JSON.parse(backupContents.toString("utf-8")) as unknown; const currentConfig = JSON.parse(currentContents.toString("utf-8")) as unknown; - const merged = mergeOpenClawRestoredConfig(backedUpConfig, currentConfig); + const merged = mergeOpenClawRestoredConfig(backedUpConfig, currentConfig, options); return { ok: true, input: Buffer.from(`${JSON.stringify(merged, null, 2)}\n`) }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); @@ -114,12 +124,33 @@ export function buildOpenClawConfigRestoreInput( export function buildOpenClawConfigRestoreInputFromSandbox({ backupContents, dir, + freshImagePluginInstalls, log = () => {}, + previousImagePluginInstalls, specPath, sshArgs, }: OpenClawConfigRestoreFromSandboxOptions): OpenClawConfigRestoreInputResult { + if ((previousImagePluginInstalls === undefined) !== (freshImagePluginInstalls === undefined)) { + return { + ok: false, + error: "Complete previous and fresh OpenClaw image plugin provenance is required", + }; + } + if ( + freshImagePluginInstalls !== undefined && + !hasCompleteOpenClawImagePluginProvenance(freshImagePluginInstalls, dir) + ) { + return { ok: false, error: "Fresh OpenClaw image plugin provenance is incomplete" }; + } + if ( + previousImagePluginInstalls !== undefined && + !hasCompleteOpenClawImagePluginProvenance(previousImagePluginInstalls, dir) + ) { + return { ok: false, error: "OpenClaw image plugin provenance is incomplete" }; + } return buildOpenClawConfigRestoreInput( backupContents, readCurrentOpenClawConfig(sshArgs, dir, specPath, log), + { freshImagePluginInstalls, previousImagePluginInstalls }, ); } diff --git a/src/lib/state/openclaw-managed-extensions.test.ts b/src/lib/state/openclaw-managed-extensions.test.ts index 629fbd354d..c6ce0a7a5f 100644 --- a/src/lib/state/openclaw-managed-extensions.test.ts +++ b/src/lib/state/openclaw-managed-extensions.test.ts @@ -63,7 +63,11 @@ describe("OpenClaw managed extension policy", () => { }); it("excludes only image-managed extensions from the restore archive", () => { - const args = buildRestoreTarArgs("/tmp/rebuild backup", ["workspace", "extensions"], true); + const args = buildRestoreTarArgs( + "/tmp/rebuild backup", + ["workspace", "extensions"], + OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, + ); expect(args.slice(0, 4)).toEqual(["-cf", "-", "-C", "/tmp/rebuild backup"]); expect(args.flatMap((arg, index) => (arg === "--exclude" ? [args[index + 1]] : []))).toEqual( @@ -73,8 +77,18 @@ describe("OpenClaw managed extension policy", () => { expect(args).not.toContain("extensions/telegram"); }); + it("also excludes dynamically discovered fresh plugin directories", () => { + const args = buildRestoreTarArgs( + "/tmp/backup", + ["extensions"], + [...OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, "weather"], + ); + + expect(args).toContain("extensions/weather"); + }); + it("leaves ordinary restore archives unfiltered", () => { - expect(buildRestoreTarArgs("/tmp/backup", ["workspace", "extensions"], false)).toEqual([ + expect(buildRestoreTarArgs("/tmp/backup", ["workspace", "extensions"], [])).toEqual([ "-cf", "-", "-C", @@ -125,6 +139,9 @@ describe("OpenClaw managed extension symlink policy", () => { "../../../../openclaw.json", ), ).toBe(false); + expect(isAllowedStateSymlink("extensions/nemoclaw/node_modules/.bin/leak", "../..")).toBe( + false, + ); expect( isAllowedStateSymlink("extensions/nemoclaw/node_modules/.bin/loop", "../.bin/other"), ).toBe(false); @@ -158,7 +175,8 @@ describe("OpenClaw managed extension cleanup", () => { const command = buildRestoreCleanupCommand( "/sandbox/.openclaw", ["workspace", "extensions"], - true, + OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, + new Set(), ); expect(command).toContain("rm -rf -- '/sandbox/.openclaw/workspace'"); @@ -182,7 +200,12 @@ describe("OpenClaw managed extension cleanup", () => { fs.mkdirSync(managed, { recursive: true }); fs.mkdirSync(userExtension); fs.symlinkSync(path.join(root, "missing-target"), dangling); - const command = buildRestoreCleanupCommand(root, ["extensions"], true); + const command = buildRestoreCleanupCommand( + root, + ["extensions"], + OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, + new Set(), + ); expect(() => execFileSync("bash", ["-c", command], { stdio: "pipe" })).toThrow(); expect(fs.lstatSync(dangling).isSymbolicLink()).toBe(true); @@ -194,13 +217,44 @@ describe("OpenClaw managed extension cleanup", () => { fs.rmSync(root, { recursive: true, force: true }); }); + it("requires dynamically discovered fresh plugin directories to remain real directories", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-required-extension-")); + try { + const extensions = path.join(root, "extensions"); + const weather = path.join(extensions, "weather"); + const userExtension = path.join(extensions, "user-extension"); + fs.mkdirSync(weather, { recursive: true }); + fs.mkdirSync(userExtension); + const command = buildRestoreCleanupCommand( + root, + ["extensions"], + [...OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, "weather"], + new Set(["weather"]), + ); + + execFileSync("bash", ["-c", command], { stdio: "pipe" }); + expect(fs.statSync(weather).isDirectory()).toBe(true); + expect(fs.existsSync(userExtension)).toBe(false); + + fs.rmSync(weather, { recursive: true, force: true }); + expect(() => execFileSync("bash", ["-c", command], { stdio: "pipe" })).toThrow(); + + const target = path.join(root, "weather-target"); + fs.mkdirSync(target); + fs.symlinkSync(target, weather); + expect(() => execFileSync("bash", ["-c", command], { stdio: "pipe" })).toThrow(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("removes complete state directories when managed preservation is disabled", () => { expect( - buildRestoreCleanupCommand("/sandbox/.openclaw", ["workspace", "extensions"], false), + buildRestoreCleanupCommand("/sandbox/.openclaw", ["workspace", "extensions"], [], new Set()), ).toBe("rm -rf -- '/sandbox/.openclaw/workspace' && rm -rf -- '/sandbox/.openclaw/extensions'"); }); it("returns a no-op when no restore directories require cleanup", () => { - expect(buildRestoreCleanupCommand("/sandbox/.openclaw", [], false)).toBe(":"); + expect(buildRestoreCleanupCommand("/sandbox/.openclaw", [], [], new Set())).toBe(":"); }); }); diff --git a/src/lib/state/openclaw-managed-extensions.ts b/src/lib/state/openclaw-managed-extensions.ts index c3a12eee53..2a07e2c75d 100644 --- a/src/lib/state/openclaw-managed-extensions.ts +++ b/src/lib/state/openclaw-managed-extensions.ts @@ -52,6 +52,7 @@ function isAllowedExtensionNpmBinSymlink(relPath: string, linkTarget: string): b return ( targetWithinNodeModules.length > 0 && + targetWithinNodeModules !== ".." && !targetWithinNodeModules.startsWith("../") && !path.posix.isAbsolute(targetWithinNodeModules) && !targetWithinNodeModules.startsWith(".bin/") @@ -89,33 +90,44 @@ export function shouldPreserveOpenClawManagedExtensions( export function buildRestoreTarArgs( backupPath: string, localDirs: readonly string[], - preserveManagedExtensions: boolean, + managedExtensionDirs: readonly string[], ): string[] { const args = ["-cf", "-", "-C", backupPath]; - if (preserveManagedExtensions) { - for (const extensionName of OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS) { - args.push("--exclude", `extensions/${extensionName}`); - } + for (const extensionName of managedExtensionDirs) { + args.push("--exclude", `extensions/${extensionName}`); } args.push("--", ...localDirs); return args; } -function buildOpenClawExtensionsCleanupCommand(dir: string): string { +function buildOpenClawExtensionsCleanupCommand( + dir: string, + managedExtensionDirs: readonly string[], + requiredExtensionDirs: ReadonlySet, +): string { const extensionsDir = `${dir}/extensions`; const quotedExtensionsDir = shellQuote(extensionsDir); - const validationCommands = OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS.map((extensionName) => { - const managedPath = `${extensionsDir}/${extensionName}`; - return ( - `p=${shellQuote(managedPath)}; ` + - 'if { [ -e "$p" ] || [ -L "$p" ]; } && { [ ! -d "$p" ] || [ -L "$p" ]; }; then ' + - 'echo "refusing to preserve unsafe managed extension: $p" >&2; exit 20; fi' - ); - }).join("; "); + const validationCommands = managedExtensionDirs + .map((extensionName) => { + const managedPath = `${extensionsDir}/${extensionName}`; + if (requiredExtensionDirs.has(extensionName)) { + return ( + `p=${shellQuote(managedPath)}; ` + + 'if [ ! -d "$p" ] || [ -L "$p" ]; then ' + + 'echo "refusing missing or unsafe image-managed extension: $p" >&2; exit 20; fi' + ); + } + return ( + `p=${shellQuote(managedPath)}; ` + + 'if { [ -e "$p" ] || [ -L "$p" ]; } && { [ ! -d "$p" ] || [ -L "$p" ]; }; then ' + + 'echo "refusing to preserve unsafe managed extension: $p" >&2; exit 20; fi' + ); + }) + .join("; "); const validateManagedPaths = `{ ${validationCommands}; }`; - const preservedNames = OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS.map( - (extensionName) => `! -name ${shellQuote(extensionName)}`, - ).join(" "); + const preservedNames = managedExtensionDirs + .map((extensionName) => `! -name ${shellQuote(extensionName)}`) + .join(" "); return [ `mkdir -p -- ${quotedExtensionsDir}`, @@ -127,15 +139,19 @@ function buildOpenClawExtensionsCleanupCommand(dir: string): string { export function buildRestoreCleanupCommand( dir: string, localDirs: readonly string[], - preserveManagedExtensions: boolean, + managedExtensionDirs: readonly string[], + requiredExtensionDirs: ReadonlySet, ): string { + const preserveManagedExtensions = managedExtensionDirs.length > 0; const commands: string[] = []; for (const dirName of localDirs) { if (preserveManagedExtensions && dirName === "extensions") continue; commands.push(`rm -rf -- ${shellQuote(`${dir}/${dirName}`)}`); } if (preserveManagedExtensions) { - commands.push(buildOpenClawExtensionsCleanupCommand(dir)); + commands.push( + buildOpenClawExtensionsCleanupCommand(dir, managedExtensionDirs, requiredExtensionDirs), + ); } return commands.length > 0 ? commands.join(" && ") : ":"; } diff --git a/src/lib/state/openclaw-plugin-restore.test.ts b/src/lib/state/openclaw-plugin-restore.test.ts new file mode 100644 index 0000000000..6f0c6ef458 --- /dev/null +++ b/src/lib/state/openclaw-plugin-restore.test.ts @@ -0,0 +1,487 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, 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 { OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS } from "./openclaw-managed-extensions"; +import { + buildFreshOpenClawPluginIndexSqliteReadCommand, + hasCompleteOpenClawImagePluginProvenance, + parseFreshOpenClawPluginExtensionDirs, + parseOpenClawImagePluginInstalls, + planOpenClawPluginRestore, +} from "./openclaw-plugin-restore"; + +const OPENCLAW_DIR = "/sandbox/.openclaw"; + +function install(installPath: string): Record { + return { source: "npm", installPath }; +} + +function pathInstall(installPath: string, sourcePath: string): Record { + return { source: "path", sourcePath, installPath }; +} + +function writeOpenClawConfig(root: string, loadPaths: string[] = []): void { + fs.writeFileSync( + path.join(root, "openclaw.json"), + JSON.stringify({ plugins: { load: { paths: loadPaths } } }), + ); +} + +const CREATE_PLUGIN_INDEX_SQLITE_PY = [ + "import json, sqlite3, sys", + "conn = sqlite3.connect(sys.argv[1])", + "conn.execute('CREATE TABLE installed_plugin_index (index_key TEXT PRIMARY KEY, install_records_json TEXT)')", + "records = json.loads(sys.argv[2])", + "if records is not None: conn.execute('INSERT INTO installed_plugin_index VALUES (?, ?)', ('installed-plugin-index', json.dumps(records)))", + "conn.commit()", + "conn.close()", +].join("\n"); + +function createPluginIndexDatabase(dbPath: string, records: unknown): void { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + execFileSync("python3", ["-c", CREATE_PLUGIN_INDEX_SQLITE_PY, dbPath, JSON.stringify(records)]); +} + +describe("buildFreshOpenClawPluginIndexSqliteReadCommand", () => { + it("reads canonical install records from the OpenClaw SQLite index", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-index-")); + try { + const dbPath = path.join(root, "state", "openclaw.sqlite"); + const records = { weather: install(`${OPENCLAW_DIR}/extensions/weather`) }; + createPluginIndexDatabase(dbPath, records); + writeOpenClawConfig(root); + + const stdout = execFileSync( + "bash", + ["-c", buildFreshOpenClawPluginIndexSqliteReadCommand(root)], + { encoding: "utf8" }, + ); + expect(JSON.parse(stdout)).toEqual({ version: 1, installRecords: records, loadPaths: [] }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails closed when the SQLite database has no installed-plugin-index row", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-index-")); + try { + createPluginIndexDatabase(path.join(root, "state", "openclaw.sqlite"), null); + writeOpenClawConfig(root); + expect(() => + execFileSync("bash", ["-c", buildFreshOpenClawPluginIndexSqliteReadCommand(root)]), + ).toThrow(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("uses exit status 2 only when the canonical SQLite database is absent", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-index-")); + try { + const result = spawnSync("bash", [ + "-c", + buildFreshOpenClawPluginIndexSqliteReadCommand(root), + ]); + expect(result.status).toBe(2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a broken SQLite database symlink instead of using legacy fallback", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-index-")); + try { + const dbPath = path.join(root, "state", "openclaw.sqlite"); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.symlinkSync(path.join(root, "missing.sqlite"), dbPath); + const result = spawnSync("bash", [ + "-c", + buildFreshOpenClawPluginIndexSqliteReadCommand(root), + ]); + expect(result.status).toBe(10); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a symlinked OpenClaw config used for load-path ownership", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-index-")); + try { + createPluginIndexDatabase(path.join(root, "state", "openclaw.sqlite"), {}); + fs.symlinkSync(path.join(root, "missing-openclaw.json"), path.join(root, "openclaw.json")); + const result = spawnSync("bash", [ + "-c", + buildFreshOpenClawPluginIndexSqliteReadCommand(root), + ]); + expect(result.status).toBe(10); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("parseFreshOpenClawPluginExtensionDirs", () => { + it("returns sorted validated directories for direct extension installs", () => { + expect( + parseFreshOpenClawPluginExtensionDirs( + { + version: 1, + installRecords: { + weather: install(`${OPENCLAW_DIR}/extensions/weather`), + nemoclaw: install(`${OPENCLAW_DIR}/extensions/nemoclaw`), + }, + loadPaths: [], + }, + OPENCLAW_DIR, + ), + ).toEqual({ + ok: true, + extensionDirs: ["nemoclaw", "weather"], + pluginInstalls: [ + { id: "nemoclaw", installPath: `${OPENCLAW_DIR}/extensions/nemoclaw`, loadPaths: [] }, + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + ], + }); + }); + + it("ignores npm installs outside extensions and accepts scoped IDs with encoded directories", () => { + expect( + parseFreshOpenClawPluginExtensionDirs( + { + version: 1, + installRecords: { + "@scope/weather": install(`${OPENCLAW_DIR}/extensions/scope__weather`), + diagnostics: install(`${OPENCLAW_DIR}/npm/node_modules/diagnostics`), + "foo+bar": install(`${OPENCLAW_DIR}/extensions/foo+bar`), + }, + loadPaths: [], + }, + OPENCLAW_DIR, + ), + ).toEqual({ + ok: true, + extensionDirs: ["foo+bar", "scope__weather"], + pluginInstalls: expect.arrayContaining([ + { + id: "@scope/weather", + installPath: `${OPENCLAW_DIR}/extensions/scope__weather`, + loadPaths: [], + }, + { + id: "diagnostics", + installPath: `${OPENCLAW_DIR}/npm/node_modules/diagnostics`, + loadPaths: [], + }, + { id: "foo+bar", installPath: `${OPENCLAW_DIR}/extensions/foo+bar`, loadPaths: [] }, + ]), + }); + }); + + it.each([ + ["a traversal ID", { "../weather": install(`${OPENCLAW_DIR}/extensions/../weather`) }], + [ + "an ID containing whitespace", + { "my plugin": install(`${OPENCLAW_DIR}/extensions/my-plugin`) }, + ], + ["a nested install path", { weather: install(`${OPENCLAW_DIR}/extensions/nested/weather`) }], + ["a noncanonical install path", { weather: install(`${OPENCLAW_DIR}/extensions/../weather`) }], + ["an oversized install path", { weather: install(`/${"a".repeat(4096)}`) }], + [ + "an excessively deep install path", + { weather: install(`/${Array.from({ length: 65 }, () => "a").join("/")}`) }, + ], + ["a glob extension directory", { weather: install(`${OPENCLAW_DIR}/extensions/*`) }], + ["non-object metadata", { weather: "invalid" }], + ])("rejects %s", (_label, installs) => { + const result = parseFreshOpenClawPluginExtensionDirs( + { version: 1, installRecords: installs, loadPaths: [] }, + OPENCLAW_DIR, + ); + expect(result).toEqual( + expect.objectContaining({ + ok: false, + error: expect.stringMatching(/unsafe|invalid/), + }), + ); + }); + + it("rejects an unbounded install set before constructing restore commands", () => { + const installs = Object.fromEntries( + Array.from({ length: 129 }, (_, index) => { + const id = `plugin-${index}`; + return [id, install(`${OPENCLAW_DIR}/extensions/${id}`)]; + }), + ); + expect( + parseFreshOpenClawPluginExtensionDirs( + { version: 1, installRecords: installs, loadPaths: [] }, + OPENCLAW_DIR, + ), + ).toEqual({ + ok: false, + error: "fresh OpenClaw registry has too many plugin installs (129)", + }); + }); + + it("accepts the 64-component install-path boundary", () => { + const installPath = `/${Array.from({ length: 64 }, () => "a").join("/")}`; + expect( + parseFreshOpenClawPluginExtensionDirs( + { version: 1, installRecords: { weather: install(installPath) }, loadPaths: [] }, + OPENCLAW_DIR, + ), + ).toEqual({ + ok: true, + extensionDirs: [], + pluginInstalls: [{ id: "weather", installPath, loadPaths: [] }], + }); + }); + + it("captures only configured load paths owned by linked path installs", () => { + const linkedPath = "/opt/weather-linked"; + expect( + parseFreshOpenClawPluginExtensionDirs( + { + version: 1, + installRecords: { + linked: pathInstall(linkedPath, linkedPath), + copied: pathInstall(`${OPENCLAW_DIR}/extensions/copied`, "/opt/copied"), + }, + loadPaths: [linkedPath, linkedPath, "./user-owned", "~/user-owned"], + }, + OPENCLAW_DIR, + ), + ).toEqual({ + ok: true, + extensionDirs: ["copied"], + pluginInstalls: [ + { id: "copied", installPath: `${OPENCLAW_DIR}/extensions/copied`, loadPaths: [] }, + { id: "linked", installPath: linkedPath, loadPaths: [linkedPath] }, + ], + }); + }); + + it.each([ + ["missing load paths", { version: 1, installRecords: {} }], + [ + "control character in configured load path", + { version: 1, installRecords: {}, loadPaths: ["~/plug\u0000in"] }, + ], + [ + "unsupported install source", + { + version: 1, + installRecords: { + weather: { source: "unknown", installPath: `${OPENCLAW_DIR}/extensions/weather` }, + }, + loadPaths: [], + }, + ], + [ + "relative path-install source", + { + version: 1, + installRecords: { + weather: pathInstall(`${OPENCLAW_DIR}/extensions/weather`, "relative/weather"), + }, + loadPaths: [], + }, + ], + ])("rejects %s", (_label, index) => { + expect(parseFreshOpenClawPluginExtensionDirs(index, OPENCLAW_DIR)).toEqual( + expect.objectContaining({ ok: false }), + ); + }); +}); + +describe("parseOpenClawImagePluginInstalls", () => { + it("preserves known-empty provenance and validates populated records", () => { + expect(parseOpenClawImagePluginInstalls([], OPENCLAW_DIR)).toEqual({ + ok: true, + extensionDirs: [], + pluginInstalls: [], + }); + expect( + parseOpenClawImagePluginInstalls( + [ + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + { + id: "npm-tool", + installPath: `${OPENCLAW_DIR}/npm/node_modules/npm-tool`, + loadPaths: [], + }, + ], + OPENCLAW_DIR, + ), + ).toEqual({ + ok: true, + extensionDirs: ["weather"], + pluginInstalls: [ + { + id: "npm-tool", + installPath: `${OPENCLAW_DIR}/npm/node_modules/npm-tool`, + loadPaths: [], + }, + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + ], + }); + }); + + it.each([ + [ + "unsafe ID", + [{ id: "../weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }], + ], + [ + "prototype-pollution ID", + [{ id: "__proto__", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }], + ], + [ + "constructor prototype-pollution ID", + [{ id: "constructor", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }], + ], + [ + "prototype key ID", + [{ id: "prototype", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }], + ], + [ + "control character in path", + [{ id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weath\u0000er`, loadPaths: [] }], + ], + [ + "missing explicit load paths", + [{ id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather` }], + ], + [ + "duplicate ID", + [ + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/forecast`, loadPaths: [] }, + ], + ], + [ + "duplicate install path", + [ + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + { id: "forecast", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + ], + ], + ["relative path", [{ id: "weather", installPath: "extensions/weather", loadPaths: [] }]], + ])("rejects %s", (_label, provenance) => { + expect(parseOpenClawImagePluginInstalls(provenance, OPENCLAW_DIR)).toEqual( + expect.objectContaining({ ok: false }), + ); + }); + + it("distinguishes complete empty provenance from missing legacy provenance", () => { + expect(hasCompleteOpenClawImagePluginProvenance([], OPENCLAW_DIR)).toBe(true); + expect(hasCompleteOpenClawImagePluginProvenance(undefined, OPENCLAW_DIR)).toBe(false); + expect( + hasCompleteOpenClawImagePluginProvenance( + [{ id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather` }], + OPENCLAW_DIR, + ), + ).toBe(false); + }); +}); + +describe("planOpenClawPluginRestore", () => { + it("preserves fresh plugins while excluding removed previous plugins from the archive", () => { + const result = planOpenClawPluginRestore({ + agentType: "openclaw", + dir: OPENCLAW_DIR, + localDirs: ["extensions"], + freshImagePluginInstalls: [ + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + ], + previousImagePluginInstalls: [ + { id: "forecast", installPath: `${OPENCLAW_DIR}/extensions/forecast`, loadPaths: [] }, + ], + }); + + const preservedExtensionDirs = [ + ...new Set([...OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, "weather"]), + ].sort(); + expect(result).toEqual({ + ok: true, + freshExtensionDirs: ["weather"], + previousExtensionDirs: ["forecast"], + preservedExtensionDirs, + archiveExcludedExtensionDirs: [...preservedExtensionDirs, "forecast"].sort(), + requiredFreshExtensionDirs: ["weather"], + }); + }); + + it("treats a same-ID fresh plugin as an upgrade and excludes the previous image directory", () => { + const result = planOpenClawPluginRestore({ + agentType: "openclaw", + dir: OPENCLAW_DIR, + localDirs: ["extensions"], + freshImagePluginInstalls: [ + { + id: "weather", + installPath: `${OPENCLAW_DIR}/extensions/weather-v2`, + loadPaths: [], + }, + ], + previousImagePluginInstalls: [ + { + id: "weather", + installPath: `${OPENCLAW_DIR}/extensions/weather-v1`, + loadPaths: [], + }, + ], + }); + + expect(result).toEqual( + expect.objectContaining({ + ok: true, + freshExtensionDirs: ["weather-v2"], + previousExtensionDirs: ["weather-v1"], + requiredFreshExtensionDirs: ["weather-v2"], + archiveExcludedExtensionDirs: expect.arrayContaining(["weather-v1", "weather-v2"]), + }), + ); + }); + + it("returns an empty extension plan when the backup does not contain extensions", () => { + expect( + planOpenClawPluginRestore({ + agentType: "openclaw", + dir: OPENCLAW_DIR, + localDirs: ["workspace"], + freshImagePluginInstalls: [ + { id: "weather", installPath: `${OPENCLAW_DIR}/extensions/weather`, loadPaths: [] }, + ], + }), + ).toEqual({ + ok: true, + freshExtensionDirs: [], + previousExtensionDirs: [], + preservedExtensionDirs: [], + archiveExcludedExtensionDirs: [], + requiredFreshExtensionDirs: [], + }); + }); + + it("fails closed on invalid previous plugin provenance", () => { + expect( + planOpenClawPluginRestore({ + agentType: "openclaw", + dir: OPENCLAW_DIR, + localDirs: ["extensions"], + freshImagePluginInstalls: [], + previousImagePluginInstalls: [ + { id: "weather", installPath: "extensions/weather", loadPaths: [] }, + ], + }), + ).toEqual(expect.objectContaining({ ok: false })); + }); +}); diff --git a/src/lib/state/openclaw-plugin-restore.ts b/src/lib/state/openclaw-plugin-restore.ts new file mode 100644 index 0000000000..a7212c3328 --- /dev/null +++ b/src/lib/state/openclaw-plugin-restore.ts @@ -0,0 +1,476 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; +import { spawnSync } from "child_process"; + +import { isRecord } from "../core/json-types.js"; +import { shellQuote } from "../core/shell-quote.js"; +import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; +import { + OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, + shouldPreserveOpenClawManagedExtensions, +} from "./openclaw-managed-extensions.js"; + +const MAX_OPENCLAW_IMAGE_MANAGED_PLUGIN_INSTALLS = 128; +const MAX_OPENCLAW_CONFIGURED_PLUGIN_LOAD_PATHS = 512; +// The parser accepts at most 128 records with 4 KiB install paths, leaving +// ample room for IDs and metadata while bounding sandbox-controlled output. +const OPENCLAW_PLUGIN_INSTALL_REGISTRY_MAX_BYTES = 1024 * 1024; +// Bound sandbox-controlled registry strings before path normalization. +const MAX_OPENCLAW_PLUGIN_INSTALL_PATH_LENGTH = 4096; +const MAX_OPENCLAW_PLUGIN_INSTALL_PATH_SEGMENTS = 64; +const OPENCLAW_EXTENSION_GLOB_CHARS = ["/", "\\", "*", "?", "[", "]"] as const; +const OPENCLAW_PLUGIN_INSTALL_SOURCES = new Set([ + "archive", + "clawhub", + "git", + "marketplace", + "npm", + "path", +]); +const OPENCLAW_PLUGIN_ID_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/; +const FORBIDDEN_OPENCLAW_PLUGIN_IDS = new Set(["__proto__", "constructor", "prototype"]); +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + +export type OpenClawManagedExtensionDiscoveryResult = + | { ok: true; extensionDirs: string[]; pluginInstalls: OpenClawImagePluginInstall[] } + | { ok: false; error: string }; + +export interface OpenClawImagePluginInstall { + readonly id: string; + readonly installPath: string; + /** Exact configured load paths owned by this image install; durable validation requires it. */ + readonly loadPaths?: readonly string[]; +} + +export type CompleteOpenClawImagePluginInstall = Omit & { + readonly loadPaths: readonly string[]; +}; + +export interface OpenClawPluginDiscoveryDeps { + getSshConfig(sandboxName: string): string | null; + sshArgs(configFile: string, sandboxName: string): string[]; +} + +export type OpenClawPluginRestorePlanResult = + | { + ok: true; + freshExtensionDirs: string[]; + previousExtensionDirs: string[]; + preservedExtensionDirs: string[]; + archiveExcludedExtensionDirs: string[]; + requiredFreshExtensionDirs: string[]; + } + | { ok: false; error: string }; + +const OPENCLAW_PLUGIN_INDEX_SQLITE_PY = [ + "import json, sqlite3, sys, urllib.parse", + 'uri = "file:" + urllib.parse.quote(sys.argv[1], safe="/") + "?mode=ro"', + "conn = sqlite3.connect(uri, uri=True, timeout=30)", + "try:", + ' conn.execute("PRAGMA query_only=ON")', + ' conn.execute("PRAGMA busy_timeout=30000")', + " row = conn.execute(\"SELECT install_records_json FROM installed_plugin_index WHERE index_key = 'installed-plugin-index'\").fetchone()", + " if not row or not row[0]: raise SystemExit(12)", + " records = json.loads(row[0])", + " with open(sys.argv[2], 'r', encoding='utf-8') as config_file: config = json.load(config_file)", + " plugins = config.get('plugins') if isinstance(config, dict) else None", + " load = plugins.get('load') if isinstance(plugins, dict) else None", + " load_paths = load.get('paths', []) if isinstance(load, dict) else []", + " print(json.dumps({'version': 1, 'installRecords': records, 'loadPaths': load_paths}, separators=(',', ':')))", + "finally:", + " conn.close()", +].join("\n"); + +const OPENCLAW_PLUGIN_INDEX_LEGACY_PY = [ + "import json, sys", + "with open(sys.argv[1], 'r', encoding='utf-8') as index_file: index = json.load(index_file)", + "with open(sys.argv[2], 'r', encoding='utf-8') as config_file: config = json.load(config_file)", + "plugins = config.get('plugins') if isinstance(config, dict) else None", + "load = plugins.get('load') if isinstance(plugins, dict) else None", + "load_paths = load.get('paths', []) if isinstance(load, dict) else []", + "if not isinstance(index, dict): raise SystemExit(12)", + "index['loadPaths'] = load_paths", + "print(json.dumps(index, separators=(',', ':')))", +].join("\n"); + +function buildSafeRegularFileReadGuard(variable: string, missingStatus: number): string[] { + return [ + `[ -e "$${variable}" ] || [ -L "$${variable}" ] || exit ${missingStatus}`, + `[ -f "$${variable}" ] && [ ! -L "$${variable}" ] || { echo "unsafe state file: $${variable}" >&2; exit 10; }`, + `${variable}_hardlinks="$(find "$${variable}" -maxdepth 0 -type f -links +1 -print 2>/dev/null | wc -l | tr -d " ")"`, + `[ "\${${variable}_hardlinks:-0}" = "0" ] || { echo "hard-linked state file rejected: $${variable}" >&2; exit 11; }`, + ]; +} + +export function buildFreshOpenClawPluginIndexSqliteReadCommand(dir: string): string { + const sqlitePath = `${dir.replace(/\/+$/, "")}/state/openclaw.sqlite`; + const configPath = `${dir.replace(/\/+$/, "")}/openclaw.json`; + const quotedSqlitePath = shellQuote(sqlitePath); + const quotedConfigPath = shellQuote(configPath); + return [ + `db=${quotedSqlitePath}`, + `cfg=${quotedConfigPath}`, + ...buildSafeRegularFileReadGuard("db", 2), + ...buildSafeRegularFileReadGuard("cfg", 12), + `python3 -c ${shellQuote(OPENCLAW_PLUGIN_INDEX_SQLITE_PY)} "$db" "$cfg"`, + ].join("; "); +} + +function buildLegacyOpenClawPluginIndexReadCommand(dir: string): string { + const installIndexPath = `${dir.replace(/\/+$/, "")}/plugins/installs.json`; + const configPath = `${dir.replace(/\/+$/, "")}/openclaw.json`; + const quotedInstallIndexPath = shellQuote(installIndexPath); + const quotedConfigPath = shellQuote(configPath); + return [ + `src=${quotedInstallIndexPath}`, + `cfg=${quotedConfigPath}`, + ...buildSafeRegularFileReadGuard("src", 2), + ...buildSafeRegularFileReadGuard("cfg", 12), + `python3 -c ${shellQuote(OPENCLAW_PLUGIN_INDEX_LEGACY_PY)} "$src" "$cfg"`, + ].join("; "); +} + +function readFreshOpenClawPluginInstallIndex( + deps: OpenClawPluginDiscoveryDeps, + configFile: string, + sandboxName: string, + dir: string, +): ReturnType { + // OpenClaw 2026.6.10 moved install records into its shared SQLite state. + // Fall back only when that database is absent so a corrupt/incomplete + // canonical index cannot be masked by stale legacy JSON. + const sqliteResult = spawnSync( + "ssh", + [...deps.sshArgs(configFile, sandboxName), buildFreshOpenClawPluginIndexSqliteReadCommand(dir)], + { + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + maxBuffer: OPENCLAW_PLUGIN_INSTALL_REGISTRY_MAX_BYTES, + }, + ); + if (sqliteResult.status !== 2 || sqliteResult.error || sqliteResult.signal) return sqliteResult; + + return spawnSync( + "ssh", + [...deps.sshArgs(configFile, sandboxName), buildLegacyOpenClawPluginIndexReadCommand(dir)], + { + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + maxBuffer: OPENCLAW_PLUGIN_INSTALL_REGISTRY_MAX_BYTES, + }, + ); +} + +export function discoverFreshOpenClawPluginExtensionDirs( + deps: OpenClawPluginDiscoveryDeps, + configFile: string, + sandboxName: string, + dir: string, +): OpenClawManagedExtensionDiscoveryResult { + const result = readFreshOpenClawPluginInstallIndex(deps, configFile, sandboxName, dir); + if ( + result.stdout && + Buffer.byteLength(result.stdout) > OPENCLAW_PLUGIN_INSTALL_REGISTRY_MAX_BYTES + ) { + return { ok: false, error: "fresh OpenClaw plugin install registry response too large" }; + } + if (result.status !== 0 || result.error || result.signal || !result.stdout) { + return { ok: false, error: "could not read fresh OpenClaw plugin install registry" }; + } + + let config: unknown; + try { + config = JSON.parse(result.stdout.toString("utf-8")) as unknown; + } catch { + return { + ok: false, + error: "fresh OpenClaw plugin install registry is not valid JSON", + }; + } + const parsed = parseFreshOpenClawPluginExtensionDirs(config, dir); + return parsed.ok + ? parsed + : { ok: false, error: "fresh OpenClaw plugin install registry failed validation" }; +} + +export function discoverFreshOpenClawImagePluginInstalls( + sandboxName: string, + deps: OpenClawPluginDiscoveryDeps, + dir = "/sandbox/.openclaw", +): OpenClawManagedExtensionDiscoveryResult { + const sshConfig = deps.getSshConfig(sandboxName); + if (!sshConfig) { + return { ok: false, error: "could not get SSH config for OpenClaw plugin discovery" }; + } + const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-plugin-discovery-"); + try { + return discoverFreshOpenClawPluginExtensionDirs(deps, tempSshConfig.file, sandboxName, dir); + } finally { + tempSshConfig.cleanup(); + } +} + +function isSafeOpenClawPluginInstallId(id: string): boolean { + if (id.length === 0 || id.length > 256 || FORBIDDEN_OPENCLAW_PLUGIN_IDS.has(id)) return false; + const slash = id.indexOf("/"); + if (slash === -1) return OPENCLAW_PLUGIN_ID_SEGMENT.test(id); + return ( + id.startsWith("@") && + slash > 1 && + slash === id.lastIndexOf("/") && + slash < id.length - 1 && + !FORBIDDEN_OPENCLAW_PLUGIN_IDS.has(id.slice(slash + 1)) && + OPENCLAW_PLUGIN_ID_SEGMENT.test(id.slice(1, slash)) && + OPENCLAW_PLUGIN_ID_SEGMENT.test(id.slice(slash + 1)) + ); +} + +export function isSafeOpenClawExtensionDirName(name: string): boolean { + return ( + name.length > 0 && + name.length <= 128 && + name !== "." && + name !== ".." && + !/[\u0000-\u001f\u007f]/.test(name) && + !OPENCLAW_EXTENSION_GLOB_CHARS.some((char) => name.includes(char)) + ); +} + +function validateCanonicalAbsolutePath(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_OPENCLAW_PLUGIN_INSTALL_PATH_LENGTH && + !CONTROL_CHARACTERS.test(value) && + value.split("/").filter(Boolean).length <= MAX_OPENCLAW_PLUGIN_INSTALL_PATH_SEGMENTS && + path.posix.isAbsolute(value) && + path.posix.normalize(value) === value + ); +} + +function validateConfiguredLoadPath(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_OPENCLAW_PLUGIN_INSTALL_PATH_LENGTH && + !CONTROL_CHARACTERS.test(value) + ); +} + +function validateDurableOpenClawImagePluginInstall( + id: unknown, + installPath: unknown, + loadPaths: unknown, +): CompleteOpenClawImagePluginInstall | null { + if (typeof id !== "string" || !isSafeOpenClawPluginInstallId(id)) return null; + if (!validateCanonicalAbsolutePath(installPath)) return null; + if ( + !Array.isArray(loadPaths) || + loadPaths.length > 1 || + !loadPaths.every(validateCanonicalAbsolutePath) || + new Set(loadPaths).size !== loadPaths.length + ) + return null; + return { id, installPath, loadPaths: [...loadPaths] }; +} + +function extensionDirForInstall( + install: OpenClawImagePluginInstall, + dir: string, +): { ok: true; extensionDir: string | null } | { ok: false; error: string } { + const extensionsDir = `${dir.replace(/\/+$/, "")}/extensions`; + const relativeInstallPath = path.posix.relative(extensionsDir, install.installPath); + if ( + relativeInstallPath.startsWith("../") || + relativeInstallPath === ".." || + path.posix.isAbsolute(relativeInstallPath) + ) { + return { ok: true, extensionDir: null }; + } + if ( + relativeInstallPath.length === 0 || + relativeInstallPath.includes("/") || + !isSafeOpenClawExtensionDirName(relativeInstallPath) + ) { + return { + ok: false, + error: `fresh OpenClaw plugin install path is invalid for ${install.id}`, + }; + } + return { ok: true, extensionDir: relativeInstallPath }; +} + +function validateOpenClawImagePluginInstalls( + entries: readonly (readonly [string, unknown, unknown])[], + dir: string, +): OpenClawManagedExtensionDiscoveryResult { + if (entries.length > MAX_OPENCLAW_IMAGE_MANAGED_PLUGIN_INSTALLS) { + return { + ok: false, + error: `fresh OpenClaw registry has too many plugin installs (${entries.length})`, + }; + } + + const ids = new Set(); + const installPaths = new Set(); + const loadPaths = new Set(); + const extensionDirs = new Set(); + const pluginInstalls: OpenClawImagePluginInstall[] = []; + for (const [id, installPath, durableLoadPaths] of entries) { + const install = validateDurableOpenClawImagePluginInstall(id, installPath, durableLoadPaths); + if (!install) { + return { ok: false, error: `fresh OpenClaw plugin install metadata is invalid: ${id}` }; + } + if ( + ids.has(install.id) || + installPaths.has(install.installPath) || + install.loadPaths?.some((loadPath) => loadPaths.has(loadPath)) + ) { + return { ok: false, error: `fresh OpenClaw plugin install provenance is duplicated: ${id}` }; + } + const projected = extensionDirForInstall(install, dir); + if (!projected.ok) return projected; + if (projected.extensionDir && extensionDirs.has(projected.extensionDir)) { + return { ok: false, error: `fresh OpenClaw extension directory is duplicated: ${id}` }; + } + ids.add(install.id); + installPaths.add(install.installPath); + for (const loadPath of install.loadPaths ?? []) loadPaths.add(loadPath); + pluginInstalls.push(install); + if (projected.extensionDir) extensionDirs.add(projected.extensionDir); + } + return { + ok: true, + extensionDirs: [...extensionDirs].sort(), + pluginInstalls: pluginInstalls.sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +export function parseOpenClawImagePluginInstalls( + value: unknown, + dir: string, +): OpenClawManagedExtensionDiscoveryResult { + if (!Array.isArray(value)) { + return { ok: false, error: "OpenClaw image plugin provenance is invalid" }; + } + return validateOpenClawImagePluginInstalls( + value.map((entry) => [ + isRecord(entry) && typeof entry.id === "string" ? entry.id : "", + isRecord(entry) ? entry.installPath : undefined, + isRecord(entry) ? entry.loadPaths : undefined, + ]), + dir, + ); +} + +/** True only for explicit, fully validated provenance; an explicit empty array is complete. */ +export function hasCompleteOpenClawImagePluginProvenance( + value: unknown, + dir: string, +): value is readonly CompleteOpenClawImagePluginInstall[] { + return Array.isArray(value) && parseOpenClawImagePluginInstalls(value, dir).ok; +} + +export function parseFreshOpenClawPluginExtensionDirs( + registryIndex: unknown, + dir: string, +): OpenClawManagedExtensionDiscoveryResult { + if (!isRecord(registryIndex) || registryIndex.version !== 1) { + return { ok: false, error: "fresh OpenClaw plugin install registry is invalid" }; + } + const installs = registryIndex.installRecords; + if (!isRecord(installs)) { + return { ok: false, error: "fresh OpenClaw plugin install records are invalid" }; + } + + const configuredLoadPaths = registryIndex.loadPaths; + if ( + !Array.isArray(configuredLoadPaths) || + configuredLoadPaths.length > MAX_OPENCLAW_CONFIGURED_PLUGIN_LOAD_PATHS || + !configuredLoadPaths.every(validateConfiguredLoadPath) + ) { + return { ok: false, error: "fresh OpenClaw configured plugin load paths are invalid" }; + } + const configuredLoadPathSet = new Set(configuredLoadPaths); + + const entries = Object.keys(installs) + .sort() + .map((id) => { + const metadata = installs[id]; + if (!isRecord(metadata) || !OPENCLAW_PLUGIN_INSTALL_SOURCES.has(String(metadata.source))) { + return [id, undefined, undefined] as const; + } + if ( + metadata.sourcePath !== undefined && + !validateCanonicalAbsolutePath(metadata.sourcePath) + ) { + return [id, undefined, undefined] as const; + } + if (metadata.source === "path" && !validateCanonicalAbsolutePath(metadata.sourcePath)) { + return [id, undefined, undefined] as const; + } + const loadPaths = + metadata.source === "path" && configuredLoadPathSet.has(String(metadata.sourcePath)) + ? [String(metadata.sourcePath)] + : []; + return [id, metadata.installPath, loadPaths] as const; + }); + return validateOpenClawImagePluginInstalls(entries, dir); +} + +export function planOpenClawPluginRestore(options: { + agentType: string; + dir: string; + localDirs: readonly string[]; + freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; + previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; +}): OpenClawPluginRestorePlanResult { + const freshProjection = parseOpenClawImagePluginInstalls( + options.freshImagePluginInstalls ?? [], + options.dir, + ); + if (!freshProjection.ok) return freshProjection; + + const previousImagePluginInstalls = + options.freshImagePluginInstalls !== undefined + ? options.previousImagePluginInstalls + : undefined; + const previousProjection = parseOpenClawImagePluginInstalls( + previousImagePluginInstalls ?? [], + options.dir, + ); + if (!previousProjection.ok) return previousProjection; + + // The same ID in both projections is an image-owned plugin upgrade, not a + // provenance collision. Fresh ownership wins during config merge; retaining + // the previous projection here only excludes its old directory from the + // user backup so stale image code cannot be restored over the fresh install. + + const preserveManagedExtensions = shouldPreserveOpenClawManagedExtensions( + { agentType: options.agentType }, + options.dir, + options.localDirs, + ); + const freshExtensionDirs = preserveManagedExtensions ? freshProjection.extensionDirs : []; + const previousExtensionDirs = + preserveManagedExtensions && previousImagePluginInstalls !== undefined + ? previousProjection.extensionDirs + : []; + const preservedExtensionDirs = preserveManagedExtensions + ? [...new Set([...OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS, ...freshExtensionDirs])].sort() + : []; + const archiveExcludedExtensionDirs = preserveManagedExtensions + ? [...new Set([...preservedExtensionDirs, ...previousExtensionDirs])].sort() + : []; + + return { + ok: true, + freshExtensionDirs, + previousExtensionDirs, + preservedExtensionDirs, + archiveExcludedExtensionDirs, + requiredFreshExtensionDirs: freshExtensionDirs, + }; +} diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index b7a1cf0405..6a7b33694c 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -18,6 +18,7 @@ import { normalizeExtraProviders, readExtraProviders, } from "./extra-providers"; +import type { OpenClawImagePluginInstall } from "./openclaw-plugin-restore"; import { normalizeSandboxMcpState, type SandboxMcpState, @@ -111,6 +112,8 @@ export interface SandboxEntry extends Partial { webSearchProvider?: WebSearchProvider | null; agent?: string | null; agentVersion?: string | null; + /** Plugin install baseline captured before state is restored into a fresh OpenClaw image. */ + openclawImagePluginInstalls?: OpenClawImagePluginInstall[]; // NemoClaw build fingerprint (the NemoClaw CLI/build version) stamped only on // NemoClaw-managed images at create/rebuild time. `upgrade-sandboxes` compares // it against the running NemoClaw build so an image/build change with an @@ -505,6 +508,12 @@ export function registerSandbox(entry: SandboxEntry): void { // cannot inherit a stale finalized marker. See #4621. agent: entry.agent || null, agentVersion: entry.agentVersion || null, + openclawImagePluginInstalls: Array.isArray(entry.openclawImagePluginInstalls) + ? entry.openclawImagePluginInstalls.map((install) => ({ + ...install, + ...(install.loadPaths !== undefined ? { loadPaths: [...install.loadPaths] } : {}), + })) + : undefined, nemoclawVersion: entry.nemoclawVersion || null, fromDockerfile: entry.fromDockerfile || null, hermesAuthMethod: diff --git a/src/lib/state/sandbox-openclaw-plugin-restore.test.ts b/src/lib/state/sandbox-openclaw-plugin-restore.test.ts new file mode 100644 index 0000000000..e5f8147937 --- /dev/null +++ b/src/lib/state/sandbox-openclaw-plugin-restore.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "child_process"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: vi.fn() }; +}); + +import { discoverFreshOpenClawPluginExtensionDirs } from "./openclaw-plugin-restore"; + +const OPENCLAW_DIR = "/sandbox/.openclaw"; +const MAX_PLUGIN_REGISTRY_BYTES = 1024 * 1024; + +function spawnResult( + status: number | null, + stdout: Buffer, + options: { error?: Error; signal?: NodeJS.Signals | null } = {}, +): ReturnType { + return { + error: options.error, + status, + signal: options.signal ?? null, + output: [null, stdout, Buffer.alloc(0)], + pid: 1234, + stdout, + stderr: Buffer.alloc(0), + } as ReturnType; +} + +function discover() { + return discoverFreshOpenClawPluginExtensionDirs( + { + getSshConfig: () => "unused", + sshArgs: (configFile, sandboxName) => ["-F", configFile, `openshell-${sandboxName}`], + }, + "/tmp/ssh-config", + "sandbox-one", + OPENCLAW_DIR, + ); +} + +describe("fresh OpenClaw plugin registry reads", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects an oversized SQLite registry response before JSON.parse", () => { + const parseSpy = vi.spyOn(JSON, "parse"); + vi.mocked(spawnSync).mockReturnValue(spawnResult(0, Buffer.alloc(5 * 1024 * 1024, " "))); + + expect(discover()).toEqual({ + ok: false, + error: "fresh OpenClaw plugin install registry response too large", + }); + expect(parseSpy).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledOnce(); + expect(vi.mocked(spawnSync).mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ maxBuffer: MAX_PLUGIN_REGISTRY_BYTES, timeout: 30000 }), + ); + }); + + it("classifies a real maxBuffer ENOBUFS result before parsing truncated output", () => { + const parseSpy = vi.spyOn(JSON, "parse"); + const error = Object.assign(new Error("spawnSync ssh ENOBUFS"), { code: "ENOBUFS" }); + vi.mocked(spawnSync).mockReturnValue( + spawnResult(null, Buffer.alloc(MAX_PLUGIN_REGISTRY_BYTES + 8192, " "), { + error, + signal: "SIGTERM", + }), + ); + + expect(discover()).toEqual({ + ok: false, + error: "fresh OpenClaw plugin install registry response too large", + }); + expect(parseSpy).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledOnce(); + }); + + it("rejects an oversized legacy registry response after the status-2 fallback", () => { + const parseSpy = vi.spyOn(JSON, "parse"); + vi.mocked(spawnSync) + .mockReturnValueOnce(spawnResult(2, Buffer.alloc(0))) + .mockReturnValueOnce(spawnResult(0, Buffer.alloc(5 * 1024 * 1024, " "))); + + expect(discover()).toEqual({ + ok: false, + error: "fresh OpenClaw plugin install registry response too large", + }); + expect(parseSpy).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledTimes(2); + for (const call of vi.mocked(spawnSync).mock.calls) { + expect(call[2]).toEqual( + expect.objectContaining({ maxBuffer: MAX_PLUGIN_REGISTRY_BYTES, timeout: 30000 }), + ); + } + }); + + it.each([10, 11])("does not use the legacy fallback for SQLite status %i", (status) => { + vi.mocked(spawnSync).mockReturnValue(spawnResult(status, Buffer.alloc(0))); + + expect(discover()).toEqual({ + ok: false, + error: "could not read fresh OpenClaw plugin install registry", + }); + expect(spawnSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 792d6f7e16..df52650c55 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -42,8 +42,14 @@ import { buildRestoreCleanupCommand, buildRestoreTarArgs, isAllowedStateSymlink, - shouldPreserveOpenClawManagedExtensions, } from "./openclaw-managed-extensions.js"; +import { + discoverFreshOpenClawImagePluginInstalls, + hasCompleteOpenClawImagePluginProvenance, + type OpenClawImagePluginInstall, + parseOpenClawImagePluginInstalls, + planOpenClawPluginRestore, +} from "./openclaw-plugin-restore.js"; import type { CustomPolicyEntry } from "./registry.js"; import * as registry from "./registry.js"; import { isSshTransportFailure } from "./ssh-transport.js"; @@ -54,6 +60,8 @@ const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); const REBUILD_BACKUPS_DIR = path.join(HOME_DIR, ".nemoclaw", "rebuild-backups"); const MANIFEST_VERSION = 1; +export const OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR = + "custom-image OpenClaw plugin provenance is missing or invalid"; function parseJson(text: string): T { return JSON.parse(text); @@ -68,6 +76,10 @@ export interface RebuildManifest { agentType: string; agentVersion: string | null; expectedVersion: string | null; + /** Fresh-image plugin baseline captured before user state was restored. */ + openclawImagePluginInstalls?: OpenClawImagePluginInstall[]; + /** The plugin baseline is authoritative and must be reconciled during recreation. */ + reconcileOpenClawImagePluginProvenance?: boolean; stateDirs: string[]; /** Directories verified as safe to restore. Absent on older manifests. */ backedUpDirs?: string[]; @@ -141,6 +153,8 @@ export interface RestoreResult { failedDirs: string[]; restoredFiles: string[]; failedFiles: string[]; + /** A safe, user-actionable explanation for a restore precondition failure. */ + error?: string; } export interface RestoreOptions { @@ -148,6 +162,17 @@ export interface RestoreOptions { stateFileRestorePolicy?: StateFileRestorePolicy; } +export interface RecreatedSandboxRestoreOptions extends RestoreOptions { + /** Agent in the newly created target image, not the backup manifest agent. */ + targetAgentType: string; + /** Pre-captured baseline avoids a second remote read during onboarding finalization. */ + freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; +} + +interface InternalRestoreOptions extends RestoreOptions { + freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; +} + export interface TarValidationResult { safe: boolean; entries: string[]; @@ -197,8 +222,34 @@ function isCustomPolicyEntryArray(value: unknown): value is CustomPolicyEntry[] ); } +function cloneOpenClawImagePluginInstalls( + installs: readonly OpenClawImagePluginInstall[], +): OpenClawImagePluginInstall[] { + return installs.map((install) => ({ + ...install, + ...(install.loadPaths !== undefined ? { loadPaths: [...install.loadPaths] } : {}), + })); +} + +export function hasAuthoritativeOpenClawImagePluginProvenance(value: { + agentType?: unknown; + dir?: unknown; + writableDir?: unknown; + openclawImagePluginInstalls?: unknown; + reconcileOpenClawImagePluginProvenance?: unknown; +}): boolean { + const dir = typeof value.dir === "string" ? value.dir : value.writableDir; + return ( + value.agentType === "openclaw" && + typeof dir === "string" && + value.reconcileOpenClawImagePluginProvenance === true && + hasCompleteOpenClawImagePluginProvenance(value.openclawImagePluginInstalls, dir) + ); +} + function isRebuildManifest(value: unknown): value is RebuildManifest { if (!isRecord(value) || !isStateDirArray(value.stateDirs)) return false; + const dir = typeof value.dir === "string" ? value.dir : value.writableDir; return ( typeof value.version === "number" && typeof value.sandboxName === "string" && @@ -207,7 +258,13 @@ function isRebuildManifest(value: unknown): value is RebuildManifest { (value.agentVersion === null || typeof value.agentVersion === "string") && (value.expectedVersion === null || typeof value.expectedVersion === "string") && (value.backedUpDirs === undefined || isBackedUpDirArray(value.backedUpDirs, value.stateDirs)) && - (typeof value.dir === "string" || typeof value.writableDir === "string") && + typeof dir === "string" && + (value.openclawImagePluginInstalls === undefined || + parseOpenClawImagePluginInstalls(value.openclawImagePluginInstalls, dir).ok) && + (value.reconcileOpenClawImagePluginProvenance === undefined || + typeof value.reconcileOpenClawImagePluginProvenance === "boolean") && + (value.reconcileOpenClawImagePluginProvenance !== true || + hasAuthoritativeOpenClawImagePluginProvenance(value)) && typeof value.backupPath === "string" && (value.stateFiles === undefined || (Array.isArray(value.stateFiles) && value.stateFiles.every(isStateFileSpec))) && @@ -345,13 +402,11 @@ function auditExtractedSymlinks(dirPath: string, allowedRoots: string[]): string if (stat.isSymbolicLink()) { const linkTarget = readlinkSync(fullPath); - // Whitelisted npm symlinks baked into the base image at build time - // (see AUDIT_SYMLINK_WHITELIST). Accepting them here matches the - // pre-backup audit so legitimate plugin installs in extensions/ - // can survive a rebuild without tripping the post-extraction check. - // Match both the source path AND the link target — a whitelisted - // path with a tampered target falls through to the normal - // containment check. + // Allowed npm symlinks baked into managed or custom images. The + // shared matcher checks both source shape and exact target so the + // pre-backup and post-extraction audits enforce the same contract. + // A recognized path with a tampered target falls through to the + // normal containment check. const relFromDir = path.relative(dirPath, fullPath).split(path.sep).join("/"); if (isAllowedStateSymlink(relFromDir, linkTarget)) { continue; @@ -881,13 +936,17 @@ function buildStateFileRestoreInput( spec: StateFileSpec, backupContents: Buffer, mergeOpenClawConfig: boolean, + freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], + previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], ): Buffer | null { if (!mergeOpenClawConfig) return backupContents; const result = buildOpenClawConfigRestoreInputFromSandbox({ backupContents, dir, + freshImagePluginInstalls, log: _log, + previousImagePluginInstalls, specPath: spec.path, sshArgs: sshArgs(configFile, sandboxName), }); @@ -905,6 +964,8 @@ function restoreStateFile( backupPath: string, mergeOpenClawConfig = false, stateFileRestorePolicy?: StateFileRestorePolicy, + freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], + previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], ): boolean { const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; @@ -922,6 +983,8 @@ function restoreStateFile( spec, backupContents, mergeOpenClawConfig, + freshImagePluginInstalls, + previousImagePluginInstalls, ); if (input === null) return false; @@ -964,6 +1027,27 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = `backupSandboxState: agent=${agentName}, dir=${dir}, stateDirs=[${stateDirs.join(",")}], stateFiles=[${stateFiles.map((f) => f.path).join(",")}]`, ); + const reconcileOpenClawImagePluginProvenance = + agentName === "openclaw" && Boolean(sb?.fromDockerfile); + let openclawImagePluginInstalls: OpenClawImagePluginInstall[] | undefined; + if ( + agentName === "openclaw" && + (reconcileOpenClawImagePluginProvenance || sb?.openclawImagePluginInstalls !== undefined) + ) { + const provenance = parseOpenClawImagePluginInstalls(sb?.openclawImagePluginInstalls, dir); + if (!provenance.ok) { + return { + success: false, + backedUpDirs: [], + failedDirs: [], + backedUpFiles: [], + failedFiles: [], + error: "registered OpenClaw image plugin provenance is missing or invalid", + }; + } + openclawImagePluginInstalls = cloneOpenClawImagePluginInstalls(provenance.pluginInstalls); + } + // Validate user-supplied name and check for conflicts BEFORE creating any // files on disk. const existingBackups = listBackups(sandboxName); @@ -1028,6 +1112,10 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = agentType: agentName, agentVersion: sb?.agentVersion || null, expectedVersion: agent.expectedVersion, + ...(openclawImagePluginInstalls !== undefined ? { openclawImagePluginInstalls } : {}), + ...(reconcileOpenClawImagePluginProvenance + ? { reconcileOpenClawImagePluginProvenance: true } + : {}), stateDirs, stateFiles, dir, @@ -1189,7 +1277,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = } if (whitelisted.length > 0) { _log( - `Pre-backup audit whitelisted ${whitelisted.length} entries (base-image npm symlinks): ${whitelisted.slice(0, 5).join("; ")}`, + `Pre-backup audit whitelisted ${whitelisted.length} entries (image npm symlinks): ${whitelisted.slice(0, 5).join("; ")}`, ); } if (violations.length > 0) { @@ -1351,17 +1439,60 @@ export function restoreSandboxState( sandboxName: string, backupPath: string, options: RestoreOptions = {}, +): RestoreResult { + return restoreSandboxStateInternal(sandboxName, backupPath, options); +} + +export function restoreRecreatedSandboxState( + sandboxName: string, + backupPath: string, + options: RecreatedSandboxRestoreOptions, +): RestoreResult { + let freshOpenClawImagePluginInstalls: readonly OpenClawImagePluginInstall[] | undefined; + if (options.targetAgentType === "openclaw") { + const discovery = options.freshOpenClawImagePluginInstalls + ? parseOpenClawImagePluginInstalls( + options.freshOpenClawImagePluginInstalls, + "/sandbox/.openclaw", + ) + : discoverFreshOpenClawImagePluginInstalls(sandboxName, { getSshConfig, sshArgs }); + if (!discovery.ok) { + return { + success: false, + restoredDirs: [], + failedDirs: ["extensions"], + restoredFiles: [], + failedFiles: [], + error: discovery.error, + }; + } + freshOpenClawImagePluginInstalls = discovery.pluginInstalls; + } + return restoreSandboxStateInternal(sandboxName, backupPath, { + freshOpenClawImagePluginInstalls, + stateFileRestorePolicy: options.stateFileRestorePolicy, + }); +} + +function restoreSandboxStateInternal( + sandboxName: string, + backupPath: string, + options: InternalRestoreOptions, ): RestoreResult { _log(`restoreSandboxState: sandbox=${sandboxName}, backupPath=${backupPath}`); const manifest = readManifest(backupPath); if (!manifest) { _log("FAILED: Could not read rebuild-manifest.json"); + const provenanceError = hasInvalidMarkedOpenClawPluginProvenance(backupPath) + ? OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR + : undefined; return { success: false, restoredDirs: [], failedDirs: ["manifest"], restoredFiles: [], failedFiles: [], + ...(provenanceError ? { error: provenanceError } : {}), }; } @@ -1415,18 +1546,50 @@ export function restoreSandboxState( const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); const configFile = tempSshConfig.file; + const freshOpenClawImagePluginInstalls = options.freshOpenClawImagePluginInstalls; + const previousOpenClawImagePluginInstalls = + freshOpenClawImagePluginInstalls !== undefined + ? manifest.openclawImagePluginInstalls + : undefined; try { + const pluginRestorePlan = planOpenClawPluginRestore({ + agentType: manifest.agentType, + dir, + localDirs, + freshImagePluginInstalls: freshOpenClawImagePluginInstalls, + previousImagePluginInstalls: previousOpenClawImagePluginInstalls, + }); + if (!pluginRestorePlan.ok) { + return { + success: false, + restoredDirs, + failedDirs: [...localDirs], + restoredFiles, + failedFiles: localFiles.map((f) => f.path), + error: + manifest.reconcileOpenClawImagePluginProvenance === true + ? OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR + : pluginRestorePlan.error, + }; + } + if ( + freshOpenClawImagePluginInstalls !== undefined && + pluginRestorePlan.preservedExtensionDirs.length > 0 + ) { + _log( + `Fresh image-managed OpenClaw extensions: [${pluginRestorePlan.freshExtensionDirs.join(",")}]`, + ); + _log( + `Previous image-managed OpenClaw extensions: [${pluginRestorePlan.previousExtensionDirs.join(",")}]`, + ); + } + if (localDirs.length > 0) { // Upload via tar pipe // NC-2227-04: Removed -h flag from restore as well — no symlink following. - const preserveManagedExtensions = shouldPreserveOpenClawManagedExtensions( - manifest, - dir, - localDirs, - ); const tarResult = spawnSync( "tar", - buildRestoreTarArgs(backupPath, localDirs, preserveManagedExtensions), + buildRestoreTarArgs(backupPath, localDirs, pluginRestorePlan.archiveExcludedExtensionDirs), { stdio: ["ignore", "pipe", "pipe"], timeout: 60000, @@ -1449,7 +1612,12 @@ export function restoreSandboxState( // image-managed extensions are preserved from the freshly built image and // excluded from the restore tar; only user/non-managed extension entries // are cleared and restored from the backup. - const rmCmd = buildRestoreCleanupCommand(dir, localDirs, preserveManagedExtensions); + const rmCmd = buildRestoreCleanupCommand( + dir, + localDirs, + pluginRestorePlan.preservedExtensionDirs, + new Set(pluginRestorePlan.requiredFreshExtensionDirs), + ); _log(`Cleaning target dirs before restore: ${rmCmd}`); const rmResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), rmCmd], { stdio: ["ignore", "pipe", "pipe"], @@ -1544,6 +1712,8 @@ export function restoreSandboxState( backupPath, shouldMergeOpenClawConfigStateFile(manifest.agentType, dir, spec), options.stateFileRestorePolicy, + freshOpenClawImagePluginInstalls, + previousOpenClawImagePluginInstalls, ) ) { restoredFiles.push(spec.path); @@ -1576,11 +1746,28 @@ function writeManifest(backupPath: string, manifest: RebuildManifest): void { chmodSync(manifestPath, 0o600); } -function readManifest(backupPath: string): RebuildManifest | null { +function readManifestPayload(backupPath: string): unknown | null { const manifestPath = path.join(backupPath, "rebuild-manifest.json"); if (!existsSync(manifestPath)) return null; try { - const parsed = parseJson(readFileSync(manifestPath, "utf-8")); + return parseJson(readFileSync(manifestPath, "utf-8")); + } catch { + return null; + } +} + +function hasInvalidMarkedOpenClawPluginProvenance(backupPath: string): boolean { + const parsed = readManifestPayload(backupPath); + return ( + isRecord(parsed) && + parsed.reconcileOpenClawImagePluginProvenance === true && + !hasAuthoritativeOpenClawImagePluginProvenance(parsed) + ); +} + +function readManifest(backupPath: string): RebuildManifest | null { + try { + const parsed = readManifestPayload(backupPath); if (!isRebuildManifest(parsed)) return null; const manifest = parsed as RebuildManifest & { dir?: string; writableDir?: string }; const dir = manifest.dir ?? manifest.writableDir; diff --git a/src/lib/verify-deployment.test.ts b/src/lib/verify-deployment.test.ts index 32a5894cbd..77540fb265 100644 --- a/src/lib/verify-deployment.test.ts +++ b/src/lib/verify-deployment.test.ts @@ -11,6 +11,11 @@ const chain = buildChain(); // Production callers use the default DEFAULT_RETRY_DELAYS_MS. const NO_RETRY = { retryDelaysMs: [], sleep: async (_ms: number) => {} }; +const CUSTOM_OPENCLAW_NO_RETRY = { + ...NO_RETRY, + diagnoseCustomOpenClawRuntime: true, +}; + function makeDeps(overrides: Record = {}) { return { executeSandboxCommand: (_name: string, _script: string) => ({ @@ -26,6 +31,16 @@ function makeDeps(overrides: Record = {}) { }; } +function makeFailedCustomOpenClawDeps(runtimeProbeStdout: string) { + return makeDeps({ + executeSandboxCommand: (_name: string, script: string) => + script.includes("nemoclaw-runtime-probe-v1") + ? { status: 0, stdout: runtimeProbeStdout, stderr: "" } + : { status: 0, stdout: "000", stderr: "" }, + probeHostPort: () => 0, + }); +} + describe("verifyDeployment", () => { it("reports healthy when gateway and dashboard reachable", async () => { const result = await verifyDeployment("my-sandbox", chain, makeDeps(), NO_RETRY); @@ -57,6 +72,76 @@ describe("verifyDeployment", () => { expect(gwDiag?.hint).toContain("openshell-gateway.log"); }); + it("diagnoses a base-only custom OpenClaw image without suggesting another port-forward retry (#6108)", async () => { + const sleepCalls: number[] = []; + const deps = makeFailedCustomOpenClawDeps("nemoclaw-runtime-probe-v1 log=0 start=0 config=0"); + const result = await verifyDeployment("my-sandbox", chain, deps, { + retryDelaysMs: [10, 20], + sleep: async (delayMs) => { + sleepCalls.push(delayMs); + }, + diagnoseCustomOpenClawRuntime: true, + }); + const gateway = result.diagnostics.find((diagnostic) => diagnostic.link === "gateway"); + const dashboard = result.diagnostics.find((diagnostic) => diagnostic.link === "dashboard"); + expect(gateway?.hint).toContain("does not contain the NemoClaw-managed OpenClaw runtime"); + expect(gateway?.hint).toContain("onboard --from"); + expect(gateway?.hint).toContain("sandbox-base"); + expect(dashboard?.hint).toContain("cannot start until the custom image includes"); + expect(dashboard?.hint).not.toContain("openshell forward start"); + expect(sleepCalls).toEqual([10, 20]); + }); + + it("keeps generic guidance when a custom image has the normal runtime contract", async () => { + const deps = makeFailedCustomOpenClawDeps("nemoclaw-runtime-probe-v1 log=0 start=1 config=1"); + const result = await verifyDeployment("my-sandbox", chain, deps, CUSTOM_OPENCLAW_NO_RETRY); + const gateway = result.diagnostics.find((diagnostic) => diagnostic.link === "gateway"); + const dashboard = result.diagnostics.find((diagnostic) => diagnostic.link === "dashboard"); + expect(gateway?.hint).toContain("nemoclaw my-sandbox logs"); + expect(dashboard?.hint).toContain("openshell forward start"); + }); + + it.each([ + ["gateway log only", "nemoclaw-runtime-probe-v1 log=1 start=0 config=0"], + ["startup script only", "nemoclaw-runtime-probe-v1 log=0 start=1 config=0"], + ])("keeps generic guidance for a partial custom runtime with %s", async (_name, stdout) => { + const result = await verifyDeployment( + "my-sandbox", + chain, + makeFailedCustomOpenClawDeps(stdout), + CUSTOM_OPENCLAW_NO_RETRY, + ); + const gateway = result.diagnostics.find((diagnostic) => diagnostic.link === "gateway"); + const dashboard = result.diagnostics.find((diagnostic) => diagnostic.link === "dashboard"); + expect(gateway?.hint).toContain("nemoclaw my-sandbox logs"); + expect(dashboard?.hint).toContain("openshell forward start"); + }); + + it("keeps generic guidance when the custom sandbox is unreachable", async () => { + const deps = makeDeps({ + executeSandboxCommand: () => null, + probeHostPort: () => 0, + }); + const result = await verifyDeployment("my-sandbox", chain, deps, CUSTOM_OPENCLAW_NO_RETRY); + const gateway = result.diagnostics.find((diagnostic) => diagnostic.link === "gateway"); + const dashboard = result.diagnostics.find((diagnostic) => diagnostic.link === "dashboard"); + expect(gateway?.hint).toContain("openshell-gateway.log"); + expect(dashboard?.hint).toContain("openshell forward start"); + }); + + it("does not probe the custom runtime contract when diagnosis is disabled", async () => { + const scripts: string[] = []; + const deps = makeDeps({ + executeSandboxCommand: (_name: string, script: string) => { + scripts.push(script); + return { status: 0, stdout: "000", stderr: "" }; + }, + probeHostPort: () => 0, + }); + await verifyDeployment("my-sandbox", chain, deps, NO_RETRY); + expect(scripts.join("\n")).not.toContain("nemoclaw-runtime-probe-v1"); + }); + it("hint surfaces both the in-sandbox gateway log (via nemoclaw logs) and the host OpenShell log (#3563)", async () => { const deps = makeDeps({ executeSandboxCommand: () => ({ status: 0, stdout: "000", stderr: "" }), diff --git a/src/lib/verify-deployment.ts b/src/lib/verify-deployment.ts index 8bab1f59ee..c8f359bac6 100644 --- a/src/lib/verify-deployment.ts +++ b/src/lib/verify-deployment.ts @@ -17,11 +17,18 @@ * "Health Offline" in the dashboard. */ -import type { DashboardDeliveryChain } from "./dashboard/contract"; import { compareChannelSets, type RuntimeChannelStatus } from "./channel-runtime-status"; +import type { DashboardDeliveryChain } from "./dashboard/contract"; import { listMessagingChannelsWithoutCredentials } from "./messaging/channels"; +import { + buildCustomOpenClawRuntimeFailureHints, + classifyOpenClawRuntimeFailure, + type SandboxCommandExecutor, +} from "./onboard/custom-openclaw-runtime-diagnosis"; import { getMessagingProviderNamesForChannel } from "./onboard/messaging-reuse"; +export { shouldDiagnoseCustomOpenClawRuntime } from "./onboard/custom-openclaw-runtime-diagnosis"; + // ── Types ──────────────────────────────────────────────────────────── export type AccessMethod = "localhost" | "proxy" | "ssh-tunnel"; @@ -72,10 +79,7 @@ export interface VerifyDeploymentResult { export interface VerifyDeploymentDeps { /** Execute a command inside the sandbox via SSH. Returns null if sandbox unreachable. */ - executeSandboxCommand: ( - name: string, - script: string, - ) => { status: number; stdout: string; stderr: string } | null; + executeSandboxCommand: SandboxCommandExecutor; /** Probe an HTTP endpoint on the host. Returns the HTTP status code or 0 on failure. */ probeHostPort: (port: number, path: string) => number; @@ -118,6 +122,12 @@ export interface VerifyDeploymentOptions { retryDelaysMs?: number[]; /** Sleep helper, injectable for tests. */ sleep?: (ms: number) => Promise; + /** + * Inspect a failed custom OpenClaw image for the managed runtime contract. + * Keep this disabled for stock images and other agents, whose config and + * startup paths intentionally differ. + */ + diagnoseCustomOpenClawRuntime?: boolean; } const DEFAULT_RETRY_DELAYS_MS: readonly number[] = [1000, 2000, 5000, 7000, 10000]; @@ -137,7 +147,8 @@ const CREDENTIALLESS_MESSAGING_CHANNELS = new Set(listMessagingChannelsWithoutCr // sandbox log is the first thing to check. If the sandbox itself never // came up, the host-side OpenShell gateway log is the right place to // look — see gatewayLogCandidates() in onboard/sandbox-create-failure.ts. -function buildGatewayLogHint(sandboxName: string): string { +function buildGatewayLogHint(sandboxName: string, customRuntimeHint: string | null): string { + if (customRuntimeHint) return customRuntimeHint; return ( `The gateway probe failed after retrying. Inspect the in-sandbox gateway log with ` + `\`nemoclaw ${sandboxName} logs\` (the gateway writes to /tmp/gateway.log inside the sandbox when it starts). ` + @@ -466,25 +477,41 @@ export async function verifyDeployment( // 1. Gateway reachable inside sandbox const gateway = await verifyGatewayInSandbox(sandboxName, chain, deps, retryDelaysMs, sleep); + // Diagnose only after the normal startup budget. A slow custom runtime should + // get the same recovery window as the stock image, and an early unreachable + // exec cannot safely prove that image artifacts are absent. + const runtimeDiagnosis = + !gateway.reachable && options.diagnoseCustomOpenClawRuntime + ? classifyOpenClawRuntimeFailure(sandboxName, deps.executeSandboxCommand) + : null; + const customRuntimeHints = runtimeDiagnosis + ? buildCustomOpenClawRuntimeFailureHints(runtimeDiagnosis) + : null; diagnostics.push({ link: "gateway", status: gateway.reachable ? "ok" : "fail", detail: gateway.detail, - hint: gateway.reachable ? "" : buildGatewayLogHint(sandboxName), + hint: gateway.reachable + ? "" + : buildGatewayLogHint(sandboxName, customRuntimeHints?.gateway ?? null), }); // 2. Gateway version (cosmetic — not a health signal) const gatewayVersion = gateway.reachable ? fetchGatewayVersion(sandboxName, deps) : null; // 3. Dashboard reachable from host (port forward) - const dashboard = await verifyDashboardFromHost(chain, deps, retryDelaysMs, sleep); + // A port forward cannot repair an image that has no managed gateway runtime, + // so avoid spending a second retry budget on the dependent dashboard probe. + const dashboardRetryDelays = customRuntimeHints ? [] : retryDelaysMs; + const dashboard = await verifyDashboardFromHost(chain, deps, dashboardRetryDelays, sleep); diagnostics.push({ link: "dashboard", status: dashboard.reachable ? "ok" : "fail", detail: dashboard.detail, hint: dashboard.reachable ? "" - : `Port forward on ${chain.port} is not working. Run: openshell forward start ${chain.forwardTarget} ${sandboxName}`, + : (customRuntimeHints?.dashboard ?? + `Port forward on ${chain.port} is not working. Run: openshell forward start ${chain.forwardTarget} ${sandboxName}`), }); // 4. Inference route diff --git a/test/e2e-fixture-dependency-review.test.ts b/test/e2e-fixture-dependency-review.test.ts new file mode 100644 index 0000000000..4a203520b9 --- /dev/null +++ b/test/e2e-fixture-dependency-review.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(__dirname, ".."); +const FIXTURES_ROOT = path.join(REPO_ROOT, "test", "e2e", "fixtures"); +const REVIEW_PATH = path.join( + REPO_ROOT, + "docs", + "security", + "e2e-weather-plugin-fixture-dependency-review.md", +); + +describe("E2E fixture dependency review", () => { + const review = fs.readFileSync(REVIEW_PATH, "utf8"); + + it("records every committed fixture lockfile in the checked-in review", () => { + const lockfiles = execFileSync( + "git", + ["ls-files", "--", "test/e2e/fixtures/**/package-lock.json"], + { cwd: REPO_ROOT, encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean) + .sort(); + expect(lockfiles.length).toBeGreaterThan(0); + for (const lockfile of lockfiles) { + expect(review, lockfile).toContain(`- \`${lockfile}\``); + } + }); + + it("records the fixture threat controls and revalidation contract", () => { + for (const marker of [ + "npm ci --ignore-scripts", + "read-only `contents` permission", + "full-SHA-pinned actions", + "disables checkout credential persistence", + "receives no repository secrets", + "npm audit --package-lock-only --ignore-scripts --json", + "accepted residual risk is limited to this secret-free E2E lane with read-only contents permission", + "Rerun it whenever `package.json` or `package-lock.json` changes", + ]) { + expect(review).toContain(marker); + } + }); + + it("keeps installed fixture dependencies on exact versions", () => { + const weatherFixture = path.join(FIXTURES_ROOT, "plugins", "weather"); + const manifest = JSON.parse( + fs.readFileSync(path.join(weatherFixture, "package.json"), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + for (const [name, version] of Object.entries({ + ...manifest.dependencies, + ...manifest.devDependencies, + })) { + expect(version, name).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/); + } + + const lockfileText = fs.readFileSync(path.join(weatherFixture, "package-lock.json"), "utf8"); + const lockfileDigest = createHash("sha256").update(lockfileText).digest("hex"); + expect(review).toContain(`SHA-256 \`${lockfileDigest}\``); + + const lockfile = JSON.parse(lockfileText) as { + packages?: Record; + }; + for (const [packagePath, entry] of Object.entries(lockfile.packages ?? {}).filter( + ([packagePath]) => packagePath.length > 0, + )) { + expect(entry.resolved, packagePath).toEqual(expect.any(String)); + expect(entry.integrity, packagePath).toEqual(expect.any(String)); + } + }); +}); diff --git a/test/e2e/fixtures/plugins/weather/.gitignore b/test/e2e/fixtures/plugins/weather/.gitignore new file mode 100644 index 0000000000..db079bb87f --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/.gitignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +dist/ +node_modules/ diff --git a/test/e2e/fixtures/plugins/weather/openclaw.plugin.json b/test/e2e/fixtures/plugins/weather/openclaw.plugin.json new file mode 100644 index 0000000000..4130bea26e --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/openclaw.plugin.json @@ -0,0 +1,19 @@ +{ + "id": "weather", + "name": "NemoClaw E2E Weather", + "version": "1.0.0", + "description": "Registers a deterministic weather tool for custom-image lifecycle tests", + "activation": { + "onStartup": true + }, + "contracts": { + "tools": [ + "get_weather" + ] + }, + "configSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } +} diff --git a/test/e2e/fixtures/plugins/weather/package-lock.json b/test/e2e/fixtures/plugins/weather/package-lock.json new file mode 100644 index 0000000000..e58ef537b8 --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/package-lock.json @@ -0,0 +1,5153 @@ +{ + "name": "@nemoclaw/e2e-weather-plugin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nemoclaw/e2e-weather-plugin", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "typebox": "1.1.38" + }, + "devDependencies": { + "openclaw": "2026.5.27", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.16.0" + }, + "peerDependencies": { + "openclaw": ">=2026.5.17" + } + }, + "node_modules/openclaw": { + "version": "2026.5.27", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.27.tgz", + "integrity": "sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw==", + "dev": true, + "hasInstallScript": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@clack/core": "1.3.1", + "@clack/prompts": "1.4.0", + "@earendil-works/pi-agent-core": "0.75.5", + "@earendil-works/pi-ai": "0.75.5", + "@earendil-works/pi-coding-agent": "0.75.5", + "@earendil-works/pi-tui": "0.75.5", + "@google/genai": "2.6.0", + "@grammyjs/runner": "2.0.3", + "@grammyjs/transformer-throttler": "1.2.1", + "@homebridge/ciao": "1.3.8", + "@lydell/node-pty": "1.2.0-beta.12", + "@modelcontextprotocol/sdk": "1.29.0", + "@mozilla/readability": "0.6.0", + "@openclaw/fs-safe": "0.3.0", + "@openclaw/proxyline": "0.3.3", + "chalk": "5.6.2", + "chokidar": "5.0.0", + "commander": "14.0.3", + "croner": "10.0.1", + "dotenv": "17.4.2", + "express": "5.2.1", + "file-type": "22.0.1", + "grammy": "1.43.0", + "ipaddr.js": "2.4.0", + "jiti": "2.7.0", + "json5": "2.2.3", + "jszip": "3.10.1", + "kysely": "0.29.2", + "linkedom": "0.18.12", + "markdown-it": "14.1.1", + "node-edge-tts": "1.2.10", + "openai": "6.39.0", + "pdfjs-dist": "5.7.284", + "playwright-core": "1.60.0", + "qrcode": "1.5.4", + "quickjs-wasi": "2.2.0", + "rastermill": "0.3.0", + "tar": "7.5.15", + "tokenjuice": "0.7.1", + "tree-sitter-bash": "0.25.1", + "tslog": "4.10.2", + "typebox": "1.1.38", + "typescript": "6.0.3", + "undici": "8.3.0", + "web-push": "3.6.7", + "web-tree-sitter": "0.26.9", + "ws": "8.21.0", + "yaml": "2.9.0", + "zod": "4.4.3" + }, + "bin": { + "openclaw": "openclaw.mjs" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "sqlite-vec": "0.1.9" + } + }, + "node_modules/openclaw/node_modules/@agentclientprotocol/sdk": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.22.1.tgz", + "integrity": "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/openclaw/node_modules/@anthropic-ai/sdk": { + "version": "0.98.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", + "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/openclaw/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/openclaw/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1053.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1053.0.tgz", + "integrity": "sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/credential-provider-node": "^3.972.44", + "@aws-sdk/eventstream-handler-node": "^3.972.17", + "@aws-sdk/middleware-eventstream": "^3.972.13", + "@aws-sdk/middleware-websocket": "^3.972.21", + "@aws-sdk/token-providers": "3.1053.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/core": { + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", + "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.25", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", + "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", + "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", + "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/credential-provider-env": "^3.972.39", + "@aws-sdk/credential-provider-http": "^3.972.41", + "@aws-sdk/credential-provider-login": "^3.972.43", + "@aws-sdk/credential-provider-process": "^3.972.39", + "@aws-sdk/credential-provider-sso": "^3.972.43", + "@aws-sdk/credential-provider-web-identity": "^3.972.43", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", + "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", + "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.39", + "@aws-sdk/credential-provider-http": "^3.972.41", + "@aws-sdk/credential-provider-ini": "^3.972.43", + "@aws-sdk/credential-provider-process": "^3.972.39", + "@aws-sdk/credential-provider-sso": "^3.972.43", + "@aws-sdk/credential-provider-web-identity": "^3.972.43", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", + "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", + "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/token-providers": "3.1052.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", + "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", + "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", + "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", + "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", + "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.28", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", + "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/token-providers": { + "version": "3.1053.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz", + "integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", + "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/openclaw/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/openclaw/node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/openclaw/node_modules/@clack/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", + "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/openclaw/node_modules/@clack/prompts": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", + "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.3.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/openclaw/node_modules/@earendil-works/pi-agent-core": { + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.5.tgz", + "integrity": "sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.75.5", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/openclaw/node_modules/@earendil-works/pi-ai": { + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", + "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/openclaw/node_modules/@earendil-works/pi-ai/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/@earendil-works/pi-coding-agent": { + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.5.tgz", + "integrity": "sha512-O3CCQDYy28D4uwtP6zZkdEwzHN6X22v49Sb0+SZTC7x37V/YfmogrWPiaFoWeoc2hmdKhSATI7ZAK5bQbJG5NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.5", + "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-tui": "^0.75.5", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.6" + } + }, + "node_modules/openclaw/node_modules/@earendil-works/pi-tui": { + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.5.tgz", + "integrity": "sha512-LkXUM1/49pvzzeI39Y5wjBMlgafcCf67HCLhB9Z7yuXHy4XgT+VqxWcZVW5hBdhQsHZd0znjJotfGH1BzxMfiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/openclaw/node_modules/@google/genai": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.6.0.tgz", + "integrity": "sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/@grammyjs/runner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@grammyjs/runner/-/runner-2.0.3.tgz", + "integrity": "sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0" + }, + "engines": { + "node": ">=12.20.0 || >=14.13.1" + }, + "peerDependencies": { + "grammy": "^1.13.1" + } + }, + "node_modules/openclaw/node_modules/@grammyjs/transformer-throttler": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@grammyjs/transformer-throttler/-/transformer-throttler-1.2.1.tgz", + "integrity": "sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bottleneck": "^2.0.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + }, + "peerDependencies": { + "grammy": "^1.0.0" + } + }, + "node_modules/openclaw/node_modules/@grammyjs/types": { + "version": "3.27.3", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.27.3.tgz", + "integrity": "sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/@homebridge/ciao": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.8.tgz", + "integrity": "sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "fast-deep-equal": "^3.1.3", + "source-map-support": "^0.5.21", + "tslib": "^2.8.1" + }, + "bin": { + "ciao-bcs": "lib/bonjour-conformance-testing.js" + } + }, + "node_modules/openclaw/node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/openclaw/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@lydell/node-pty": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.12.tgz", + "integrity": "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12" + } + }, + "node_modules/openclaw/node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.12.tgz", + "integrity": "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/openclaw/node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.12.tgz", + "integrity": "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/openclaw/node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.12.tgz", + "integrity": "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/openclaw/node_modules/@lydell/node-pty-linux-x64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.12.tgz", + "integrity": "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/openclaw/node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.12.tgz", + "integrity": "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/openclaw/node_modules/@lydell/node-pty-win32-x64": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.12.tgz", + "integrity": "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/openclaw/node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/openclaw/node_modules/@mozilla/readability": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "dev": true, + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/openclaw/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/openclaw/node_modules/@openclaw/fs-safe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.3.0.tgz", + "integrity": "sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.11" + }, + "optionalDependencies": { + "jszip": "^3.10.1", + "tar": "7.5.13" + } + }, + "node_modules/openclaw/node_modules/@openclaw/proxyline": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@openclaw/proxyline/-/proxyline-0.3.3.tgz", + "integrity": "sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "undici": ">=8.3.0 <9" + } + }, + "node_modules/openclaw/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/openclaw/node_modules/@smithy/core": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", + "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", + "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", + "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/node-http-handler": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", + "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/signature-v4": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", + "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openclaw/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openclaw/node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/openclaw/node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/openclaw/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/openclaw/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/openclaw/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/openclaw/node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/openclaw/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/openclaw/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/openclaw/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/openclaw/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/openclaw/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/openclaw/node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/openclaw/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/openclaw/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/openclaw/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/openclaw/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/openclaw/node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "dev": true, + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/openclaw/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/openclaw/node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/openclaw/node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/openclaw/node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/openclaw/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openclaw/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/openclaw/node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/openclaw/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/openclaw/node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/openclaw/node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/openclaw/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/openclaw/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/openclaw/node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/openclaw/node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/openclaw/node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/openclaw/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/openclaw/node_modules/fast-xml-parser": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.0.tgz", + "integrity": "sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/openclaw/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/openclaw/node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openclaw/node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/openclaw/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openclaw/node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/openclaw/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/openclaw/node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/grammy": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.43.0.tgz", + "integrity": "sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.27.3", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/openclaw/node_modules/grammy/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/openclaw/node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/openclaw/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/openclaw/node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/openclaw/node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/openclaw/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/openclaw/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/openclaw/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/openclaw/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/openclaw/node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/openclaw/node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/openclaw/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/openclaw/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openclaw/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/openclaw/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/openclaw/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/openclaw/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/openclaw/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/openclaw/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/openclaw/node_modules/kysely": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.2.tgz", + "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/openclaw/node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/openclaw/node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/openclaw/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/openclaw/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/openclaw/node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/openclaw/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/openclaw/node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/openclaw/node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openclaw/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/openclaw/node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/openclaw/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/openclaw/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/openclaw/node_modules/node-domexception": { + "name": "@nolyfill/domexception", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", + "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/openclaw/node_modules/node-edge-tts": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/node-edge-tts/-/node-edge-tts-1.2.10.tgz", + "integrity": "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^7.0.1", + "ws": "^8.13.0", + "yargs": "^17.7.2" + }, + "bin": { + "node-edge-tts": "bin.js" + } + }, + "node_modules/openclaw/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openclaw/node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/openclaw/node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openclaw/node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openclaw/node_modules/openai": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.0.tgz", + "integrity": "sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openclaw/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/openclaw/node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openclaw/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/openclaw/node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, + "node_modules/openclaw/node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/openclaw/node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/openclaw/node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/openclaw/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/openclaw/node_modules/protobufjs": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", + "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/openclaw/node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/openclaw/node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/openclaw/node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/openclaw/node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/openclaw/node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/openclaw/node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/quickjs-wasi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", + "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/openclaw/node_modules/rastermill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/rastermill/-/rastermill-0.3.0.tgz", + "integrity": "sha512-4g2i0I7M5sba//lFBh19Wi0hDGw8o+isnt/BtEyqQXIZaYclhcNBwL/Fw/6gDCp7aaLwQHADuUvyHCB0Oat5Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@silvia-odwyer/photon-node": "0.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/openclaw/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/openclaw/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/openclaw/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/openclaw/node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openclaw/node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openclaw/node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/openclaw/node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/openclaw/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/openclaw/node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openclaw/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openclaw/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/openclaw/node_modules/sqlite-vec": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", + "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", + "dev": true, + "license": "MIT OR Apache", + "optional": true, + "optionalDependencies": { + "sqlite-vec-darwin-arm64": "0.1.9", + "sqlite-vec-darwin-x64": "0.1.9", + "sqlite-vec-linux-arm64": "0.1.9", + "sqlite-vec-linux-x64": "0.1.9", + "sqlite-vec-windows-x64": "0.1.9" + } + }, + "node_modules/openclaw/node_modules/sqlite-vec-darwin-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz", + "integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/openclaw/node_modules/sqlite-vec-darwin-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz", + "integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/openclaw/node_modules/sqlite-vec-linux-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz", + "integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/openclaw/node_modules/sqlite-vec-linux-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz", + "integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/openclaw/node_modules/sqlite-vec-windows-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz", + "integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/openclaw/node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/openclaw/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/openclaw/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openclaw/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/openclaw/node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/openclaw/node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/openclaw/node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/openclaw/node_modules/tokenjuice": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/tokenjuice/-/tokenjuice-0.7.1.tgz", + "integrity": "sha512-eO048hm9UcGHASjYkIWEij8QN68amGp+S1nJyo685qB1/ol+VGEYjPglcVPvCbJbZyFHvI+BBAMvOfnqYCtpsQ==", + "dev": true, + "license": "MIT", + "bin": { + "tokenjuice": "dist/cli/main.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/vincentkoc" + } + }, + "node_modules/openclaw/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/tree-sitter-bash": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz", + "integrity": "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.1", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.25.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/openclaw/node_modules/tslog": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-4.10.2.tgz", + "integrity": "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/fullstack-build/tslog?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/openclaw/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/openclaw/node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openclaw/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/openclaw/node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openclaw/node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/openclaw/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/openclaw/node_modules/web-tree-sitter": { + "version": "0.26.9", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.9.tgz", + "integrity": "sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openclaw/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/openclaw/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/openclaw/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/openclaw/node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/openclaw/node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/openclaw/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/openclaw/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/openclaw/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/openclaw/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/openclaw/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/openclaw/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/openclaw/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/openclaw/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/openclaw/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/test/e2e/fixtures/plugins/weather/package.json b/test/e2e/fixtures/plugins/weather/package.json new file mode 100644 index 0000000000..9f0399b2ae --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/package.json @@ -0,0 +1,41 @@ +{ + "name": "@nemoclaw/e2e-weather-plugin", + "version": "1.0.0", + "description": "Deterministic OpenClaw weather tool fixture for NemoClaw live tests", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/", + "openclaw.plugin.json" + ], + "openclaw": { + "extensions": [ + "./dist/index.js" + ], + "compat": { + "pluginApi": ">=2026.5.22", + "minGatewayVersion": "2026.5.22" + }, + "build": { + "openclawVersion": "2026.5.27" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "typebox": "1.1.38" + }, + "devDependencies": { + "openclaw": "2026.5.27", + "typescript": "5.9.3" + }, + "peerDependencies": { + "openclaw": ">=2026.5.17" + }, + "engines": { + "node": ">=22.16.0" + } +} diff --git a/test/e2e/fixtures/plugins/weather/src/index.ts b/test/e2e/fixtures/plugins/weather/src/index.ts new file mode 100644 index 0000000000..0a98845062 --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/src/index.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin"; +import { Type } from "typebox"; + +import { WEATHER_FIXTURE_VERSION } from "./version.js"; + +const WeatherParameters = Type.Object( + { + location: Type.String({ description: "City or place name." }), + }, + { additionalProperties: false }, +); + +export default defineToolPlugin({ + id: "weather", + name: "NemoClaw E2E Weather", + description: "Registers a deterministic weather tool for custom-image lifecycle tests.", + tools: (tool) => [ + tool({ + name: "get_weather", + label: "Get Weather", + description: "Return deterministic weather data for a location.", + parameters: WeatherParameters, + async execute({ location }) { + return { + location, + condition: "clear", + temperatureC: 21, + fixtureVersion: WEATHER_FIXTURE_VERSION, + }; + }, + }), + ], +}); diff --git a/test/e2e/fixtures/plugins/weather/src/version.ts b/test/e2e/fixtures/plugins/weather/src/version.ts new file mode 100644 index 0000000000..a6ae4e134c --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/src/version.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const WEATHER_FIXTURE_VERSION = "v1"; diff --git a/test/e2e/fixtures/plugins/weather/tsconfig.json b/test/e2e/fixtures/plugins/weather/tsconfig.json new file mode 100644 index 0000000000..ec4502799d --- /dev/null +++ b/test/e2e/fixtures/plugins/weather/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts index 6c38c2b18f..049fc00c7b 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts @@ -1,29 +1,154 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { resolveOpenshell } from "../../../src/lib/adapters/openshell/resolve.ts"; +import { + hasRequiredOpenshellMessagingFeatures, + REQUIRED_OPENSHELL_MCP_FEATURES, +} from "../../../src/lib/onboard/openshell-feature-gate.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { resultText } from "../fixtures/clients/command.ts"; -import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; +import { resultText, shellQuote } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { + type SandboxClient, + trustedSandboxShellScript, + validateSandboxName, +} from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; +import { parseJsonFromText } from "./json-envelope.ts"; -// the contract as a simple live test: onboard a fresh OpenClaw sandbox -// from the repo Dockerfile, capture the sandbox filesystem layout, then run a -// focused in-sandbox Node replacement probe that guards #3513/#3127's EXDEV -// cross-device runtime-deps failure mode. No registry, no ledger, no shared helper. +// Keep this contract as a focused live test: build a deterministic custom plugin +// on top of the complete managed runtime, prove it survives restart/rebuild, then +// run the in-sandbox Node replacement probe that guards #3513/#3127's EXDEV +// cross-device runtime-deps failure mode. No registry or ledger is required. +const WEATHER_FIXTURE_DIR = path.join(REPO_ROOT, "test/e2e/fixtures/plugins/weather"); +const WEATHER_FIXTURE_PACKAGE_PATH = path.join(WEATHER_FIXTURE_DIR, "package.json"); +const WEATHER_FIXTURE_PACKAGE = JSON.parse( + fs.readFileSync(WEATHER_FIXTURE_PACKAGE_PATH, "utf8"), +) as { + openclaw?: { build?: { openclawVersion?: unknown } }; + devDependencies?: { openclaw?: unknown }; +}; +const EXPECTED_RELEASE_OPENCLAW_VERSION = "2026.5.27"; +const weatherOpenClawVersion = WEATHER_FIXTURE_PACKAGE.openclaw?.build?.openclawVersion; +assert.equal( + typeof weatherOpenClawVersion, + "string", + "weather fixture must declare an OpenClaw build version", +); +const WEATHER_OPENCLAW_VERSION = String(weatherOpenClawVersion); +assert.match( + WEATHER_OPENCLAW_VERSION, + /^\d+(?:\.\d+)+$/, + "weather fixture must declare a canonical OpenClaw build version", +); +assert.equal( + WEATHER_OPENCLAW_VERSION, + EXPECTED_RELEASE_OPENCLAW_VERSION, + "weather fixture must match the OpenClaw runtime pinned by NemoClaw v0.0.71", +); +const NEMOCLAW_RELEASE_TAG = "v0.0.71"; +const NEMOCLAW_RELEASE_COMMIT = "e4b9111f5f0535c2fc3d6fbe8dc8dca101a6fdce"; +const NEMOCLAW_RELEASE_OPENSHELL_VERSION = "0.0.71"; +const CURRENT_OPENSHELL_VERSION = "0.0.72"; +const NEMOCLAW_SOURCE_REPOSITORY = "https://github.com/NVIDIA/NemoClaw.git"; +const SANDBOX_BASE_IMAGE_REF = "ghcr.io/nvidia/nemoclaw/sandbox-base:v0.0.71"; +const TOOL_DISCLOSURE_ENV_REFERENCE = "${NEMOCLAW_TOOL_DISCLOSURE}"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-plugin-exdev"; const ONBOARD_TIMEOUT_MS = 25 * 60_000; +const REBUILD_TIMEOUT_MS = 20 * 60_000; const PROBE_TIMEOUT_MS = 60_000; +const EXDEV_TMPFS_MOUNT = "/tmp/nemoclaw-exdev-tmpfs"; +const EXDEV_TMPFS_SOURCE = `${EXDEV_TMPFS_MOUNT}/source`; +const EXDEV_TMPFS_MOUNT_CONFIG = { + type: "tmpfs", + target: EXDEV_TMPFS_MOUNT, + // tmpfs is read-write by default. Docker's MountTmpfsOptions rejects `rw`, + // `nosuid`, and `nodev`; `noexec` is supported by both pinned drivers. + options: ["noexec"], + size_bytes: 16_777_216, + mode: 0o1777, +} as const; +const EXDEV_TMPFS_DRIVER_CONFIG = JSON.stringify({ + docker: { + mounts: [EXDEV_TMPFS_MOUNT_CONFIG], + }, + podman: { + mounts: [EXDEV_TMPFS_MOUNT_CONFIG], + }, +}); +const DELEGATED_CAPABILITY_COMMENT_PREFIX = + "# TEST-ONLY delegated-capability marker from validated canonical OpenShell: "; +const STOCK_OPENCLAW_POLICY_PATHS = [ + path.join(REPO_ROOT, "agents", "openclaw", "policy-permissive.yaml"), + path.join(REPO_ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), + path.join(REPO_ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml"), +] as const; validateSandboxName(SANDBOX_NAME); const EXDEV_PATTERNS = [ /EXDEV: cross-device link not permitted/i, /cross-device link not permitted/i, ]; +type WeatherFixtureVersion = "v1" | "v2" | "v3"; + +const GATEWAY_CATALOG_CALL_SOURCE = String.raw` +import { Buffer } from "node:buffer"; +import { accessSync, constants, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +function findOnPath(command) { + for (const dir of (process.env.PATH || "").split(":")) { + if (!dir) continue; + const candidate = join(dir, command); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch {} + } + throw new Error("Could not find " + command + " on PATH"); +} + +const port = process.env.OPENCLAW_GATEWAY_PORT || "18789"; +if (!/^[1-9][0-9]{0,4}$/.test(port) || Number(port) > 65535) { + throw new Error("OPENCLAW_GATEWAY_PORT must be a canonical TCP port in 1..65535"); +} +const token = process.env.OPENCLAW_GATEWAY_TOKEN; +if (!token) throw new Error("OPENCLAW_GATEWAY_TOKEN is required"); + +const openclawBin = realpathSync(findOnPath("openclaw")); +const requireFromOpenclaw = createRequire(openclawBin); +const runtimePath = requireFromOpenclaw.resolve("openclaw/plugin-sdk/gateway-runtime"); +const { callGatewayFromCli } = await import(pathToFileURL(runtimePath).href); +const params = JSON.parse( + Buffer.from(process.env.NEMOCLAW_E2E_GATEWAY_PARAMS_B64 || "e30=", "base64").toString("utf8"), +); +const result = await callGatewayFromCli( + "tools.catalog", + { url: "ws://127.0.0.1:" + port, token, timeout: "30000", json: true }, + params, + { clientName: "gateway-client", mode: "backend", scopes: ["operator.read"], progress: false }, +); +process.stdout.write(JSON.stringify(result) + "\n"); +`.trim(); + +function normalizeSandboxStdoutFrames(output: string): string { + return output + .split(/\r?\n/) + .map((line) => line.replace(/^\s*(?:\[stdout\]|stdout:)\s*/i, "")) + .join("\n"); +} function liveEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { @@ -43,48 +168,660 @@ async function ignoreCleanupError(run: () => Promise): Promise { } } -function patchPoliciesForDevShm(): () => void { - // Test-only source-boundary patch: the default OpenClaw policies intentionally - // do not grant general /dev access, but this regression needs to create a - // source tree on tmpfs (/dev/shm) to reproduce #3127's cross-device rename - // layout. Keep the mutation local, restore it after the test, and remove it - // when OpenShell can mount a dedicated test tmpfs or update live policy before - // first sandbox command without broadening the checked-in production policy. - const originals = new Map(); - for (const policyPath of [ - path.join(REPO_ROOT, "agents", "openclaw", "policy-permissive.yaml"), - path.join(REPO_ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), - path.join(REPO_ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml"), - ]) { - const text = fs.readFileSync(policyPath, "utf8"); - const anchor = " read_write:\n - /tmp\n"; - expect(text, `could not find read_write /tmp anchor in ${policyPath}`).toContain(anchor); - let additions = ""; - for (const entry of ["/dev", "/dev/shm"]) { - if (!text.includes(` - ${entry}\n`)) additions += ` - ${entry}\n`; - } - if (additions) { - originals.set(policyPath, text); - fs.writeFileSync(policyPath, text.replace(anchor, anchor + additions), "utf8"); - } +type OpenShellTmpfsWrapper = { + directory: string; + executable: string; + remove(): void; +}; + +type PinnedOpenShellComponents = { + cli: string; + gateway: string; + sandbox: string; +}; + +function createOpenShellTmpfsWrapper(realOpenshellPath: string): OpenShellTmpfsWrapper { + if (!path.isAbsolute(realOpenshellPath)) { + throw new Error("real OpenShell path must be absolute"); } - return () => { - for (const [policyPath, text] of originals) { - fs.writeFileSync(policyPath, text, "utf8"); + fs.accessSync(realOpenshellPath, fs.constants.X_OK); + + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exdev-openshell-wrapper-")); + const executable = path.join(directory, "openshell"); + const delegatedCapabilityComments = REQUIRED_OPENSHELL_MCP_FEATURES.map((marker) => { + assert.match(marker, /^[A-Za-z0-9_-]+$/, "delegated OpenShell capability marker must be safe"); + return `${DELEGATED_CAPABILITY_COMMENT_PREFIX}${marker}`; + }).join("\n"); + const script = `#!/bin/sh +${delegatedCapabilityComments} +set -eu +if [ "$#" -ge 2 ] && [ "$1" = sandbox ] && [ "$2" = create ]; then + shift 2 + for argument in "$@"; do + case "$argument" in + --driver-config-json|--driver-config-json=*) + printf '%s\n' 'refusing duplicate --driver-config-json in EXDEV test wrapper' >&2 + exit 64 + ;; + esac + done + exec ${shellQuote(realOpenshellPath)} sandbox create --driver-config-json ${shellQuote(EXDEV_TMPFS_DRIVER_CONFIG)} "$@" +fi +exec ${shellQuote(realOpenshellPath)} "$@" +`; + fs.writeFileSync(executable, script, { encoding: "utf8", mode: 0o700 }); + + return { + directory, + executable, + remove: () => fs.rmSync(directory, { recursive: true, force: true }), + }; +} + +function withOpenShellWrapperEnv( + env: NodeJS.ProcessEnv, + wrapper: OpenShellTmpfsWrapper, + components: PinnedOpenShellComponents, +): NodeJS.ProcessEnv { + return { + ...env, + PATH: `${wrapper.directory}${path.delimiter}${env.PATH ?? ""}`, + NEMOCLAW_OPENSHELL_BIN: wrapper.executable, + NEMOCLAW_OPENSHELL_GATEWAY_BIN: components.gateway, + NEMOCLAW_OPENSHELL_SANDBOX_BIN: components.sandbox, + }; +} + +function resolvePinnedOpenShellComponents(openshellPath: string): PinnedOpenShellComponents { + const cli = fs.realpathSync(openshellPath); + fs.accessSync(cli, fs.constants.X_OK); + const installDirectory = path.dirname(cli); + const canonicalSibling = (name: string): string => { + const sibling = fs.realpathSync(path.join(installDirectory, name)); + fs.accessSync(sibling, fs.constants.X_OK); + return sibling; + }; + return { + cli, + gateway: canonicalSibling("openshell-gateway"), + sandbox: canonicalSibling("openshell-sandbox"), + }; +} + +async function installAndResolvePinnedOpenShell( + host: HostCliClient, + installScriptPath: string, + artifactLabel: string, + expectedVersion: string, +): Promise { + const install = await host.command("bash", [installScriptPath], { + artifactName: `install-${artifactLabel}-openshell-for-exdev-wrapper`, + env: liveEnv(), + timeoutMs: 5 * 60_000, + }); + expect(install.exitCode, resultText(install)).toBe(0); + const resolved = resolveOpenshell(); + expect(resolved, "pinned OpenShell installer did not leave an executable CLI").not.toBeNull(); + const components = resolvePinnedOpenShellComponents(resolved as string); + const version = await host.command(components.cli, ["--version"], { + artifactName: `verify-${artifactLabel}-openshell-version`, + env: liveEnv(), + timeoutMs: 30_000, + }); + expect(version.exitCode, resultText(version)).toBe(0); + expect(resultText(version)).toMatch( + new RegExp(`\\b${expectedVersion.replaceAll(".", "\\.")}\\b`), + ); + return components; +} + +async function stopOpenShellGatewayBeforeVersionSwitch( + host: HostCliClient, + artifactLabel: string, + env: NodeJS.ProcessEnv = liveEnv(), +): Promise { + const openshellPath = resolveOpenshell(); + if (!openshellPath) return; + await host.command(openshellPath, ["gateway", "stop", "-g", "nemoclaw"], { + artifactName: `stop-${artifactLabel}-openshell-gateway-before-version-switch`, + env, + timeoutMs: 60_000, + }); +} + +type PolicySourceSnapshot = ReadonlyArray<{ policyPath: string; bytes: Buffer }>; + +function snapshotPolicySources(): PolicySourceSnapshot { + return STOCK_OPENCLAW_POLICY_PATHS.map((policyPath) => ({ + policyPath, + bytes: fs.readFileSync(policyPath), + })); +} + +function assertPolicySourcesUnchanged(snapshot: PolicySourceSnapshot, phase: string): void { + for (const { policyPath, bytes } of snapshot) { + expect(fs.readFileSync(policyPath), `${policyPath} changed during ${phase}`).toEqual(bytes); + } +} + +function runWrapper(wrapper: string, args: readonly string[]): string[] { + const result = spawnSync(wrapper, args, { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trimEnd().split("\n"); +} + +test("OpenShell wrapper injects only the reviewed tmpfs config into sandbox create", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exdev-wrapper-contract-")); + const delegate = path.join(fixture, "real-openshell"); + const gateway = path.join(fixture, "openshell-gateway"); + const sandbox = path.join(fixture, "openshell-sandbox"); + const executableSource = "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"; + for (const executable of [delegate, gateway, sandbox]) { + fs.writeFileSync(executable, executableSource, { + encoding: "utf8", + mode: 0o700, + }); + } + const components = resolvePinnedOpenShellComponents(delegate); + const wrapper = createOpenShellTmpfsWrapper(components.cli); + try { + const wrapperSource = fs.readFileSync(wrapper.executable, "utf8"); + expect( + wrapperSource + .split("\n") + .filter((line) => line.startsWith(DELEGATED_CAPABILITY_COMMENT_PREFIX)), + ).toEqual( + REQUIRED_OPENSHELL_MCP_FEATURES.map( + (marker) => `${DELEGATED_CAPABILITY_COMMENT_PREFIX}${marker}`, + ), + ); + for (const marker of REQUIRED_OPENSHELL_MCP_FEATURES) { + expect(wrapperSource.split(marker)).toHaveLength(2); } + expect(components).toEqual({ + cli: fs.realpathSync(delegate), + gateway: fs.realpathSync(gateway), + sandbox: fs.realpathSync(sandbox), + }); + expect(withOpenShellWrapperEnv({ PATH: "/usr/bin" }, wrapper, components)).toMatchObject({ + PATH: `${wrapper.directory}${path.delimiter}/usr/bin`, + NEMOCLAW_OPENSHELL_BIN: wrapper.executable, + NEMOCLAW_OPENSHELL_GATEWAY_BIN: components.gateway, + NEMOCLAW_OPENSHELL_SANDBOX_BIN: components.sandbox, + }); + expect(EXDEV_TMPFS_MOUNT_CONFIG.options).toEqual(["noexec"]); + expect(JSON.parse(EXDEV_TMPFS_DRIVER_CONFIG)).toEqual({ + docker: { + mounts: [EXDEV_TMPFS_MOUNT_CONFIG], + }, + podman: { + mounts: [EXDEV_TMPFS_MOUNT_CONFIG], + }, + }); + expect( + runWrapper(wrapper.executable, [ + "sandbox", + "create", + "--name", + "demo", + "--", + "sh", + "-lc", + "printf value", + ]), + ).toEqual([ + "sandbox", + "create", + "--driver-config-json", + EXDEV_TMPFS_DRIVER_CONFIG, + "--name", + "demo", + "--", + "sh", + "-lc", + "printf value", + ]); + expect(runWrapper(wrapper.executable, ["sandbox", "delete", "demo"])).toEqual([ + "sandbox", + "delete", + "demo", + ]); + expect(runWrapper(wrapper.executable, ["--version"])).toEqual(["--version"]); + const duplicateConfig = spawnSync( + wrapper.executable, + ["sandbox", "create", "--driver-config-json", "{}"], + { encoding: "utf8" }, + ); + expect(duplicateConfig.status).toBe(64); + expect(duplicateConfig.stderr).toContain("refusing duplicate --driver-config-json"); + } finally { + wrapper.remove(); + fs.rmSync(fixture, { recursive: true, force: true }); + } + expect(fs.existsSync(wrapper.directory)).toBe(false); +}); + +type CustomPluginBuildContext = { + sourceParentDir: string; + sourceRoot: string; + cliEntrypoint: string; + dockerfilePath: string; + versionSourcePath: string; + pluginDirPath: string; +}; + +function createCustomPluginBuildContext(): CustomPluginBuildContext { + const nonce = randomUUID(); + const sourceParentDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-v0.0.71-weather-")); + const sourceRoot = path.join(sourceParentDir, "NemoClaw"); + return { + sourceParentDir, + sourceRoot, + cliEntrypoint: path.join(sourceRoot, "bin", "nemoclaw.js"), + dockerfilePath: path.join(sourceRoot, `Dockerfile.e2e-weather-plugin-${nonce}`), + versionSourcePath: path.join(sourceRoot, `e2e-weather-plugin-version-${nonce}.ts`), + pluginDirPath: path.join(sourceRoot, `e2e-weather-plugin-${nonce}`), + }; +} + +test("custom plugin build paths are collision-safe and outside the checkout", () => { + const first = createCustomPluginBuildContext(); + const second = createCustomPluginBuildContext(); + try { + expect(first.sourceParentDir).not.toBe(second.sourceParentDir); + expect(first.sourceParentDir.startsWith(`${REPO_ROOT}${path.sep}`)).toBe(false); + expect(second.sourceParentDir.startsWith(`${REPO_ROOT}${path.sep}`)).toBe(false); + expect(fs.statSync(first.sourceParentDir).isDirectory()).toBe(true); + expect(fs.statSync(second.sourceParentDir).isDirectory()).toBe(true); + } finally { + fs.rmSync(first.sourceParentDir, { recursive: true, force: true }); + fs.rmSync(second.sourceParentDir, { recursive: true, force: true }); + } +}); + +function copyFixtureFileExclusive(source: string, target: string): void { + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); +} + +function stageWeatherPluginFixture(context: CustomPluginBuildContext): void { + fs.mkdirSync(context.pluginDirPath); + fs.mkdirSync(path.join(context.pluginDirPath, "src")); + for (const fileName of [ + "package.json", + "package-lock.json", + "tsconfig.json", + "openclaw.plugin.json", + ]) { + copyFixtureFileExclusive( + path.join(WEATHER_FIXTURE_DIR, fileName), + path.join(context.pluginDirPath, fileName), + ); + } + copyFixtureFileExclusive( + path.join(WEATHER_FIXTURE_DIR, "src", "index.ts"), + path.join(context.pluginDirPath, "src", "index.ts"), + ); +} + +function writeCustomPluginVersion( + versionSourcePath: string, + version: WeatherFixtureVersion, + exclusive = false, +): void { + fs.writeFileSync( + versionSourcePath, + `// Generated by the OpenClaw plugin lifecycle E2E.\nexport const WEATHER_FIXTURE_VERSION = ${JSON.stringify(version)};\n`, + { encoding: "utf8", flag: exclusive ? "wx" : "w" }, + ); +} + +function createCustomPluginDockerfile(context: CustomPluginBuildContext): void { + const sourceDockerfile = path.join(context.sourceRoot, "Dockerfile"); + const source = fs.readFileSync(sourceDockerfile, "utf8"); + const baseImageAnchor = "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest\n"; + const runtimeAnchor = "FROM ${BASE_IMAGE}\n"; + expect( + source.match(/^ARG BASE_IMAGE=ghcr\.io\/nvidia\/nemoclaw\/sandbox-base:latest$/gm)?.length, + ).toBe(1); + expect(source.match(/^FROM \$\{BASE_IMAGE\}$/gm)?.length, "expected one runtime stage").toBe(1); + expect( + source.match(/^ARG OPENCLAW_VERSION=([0-9.]+)$/m)?.[1], + "weather fixture SDK must match the v0.0.71 managed runtime target", + ).toBe(WEATHER_OPENCLAW_VERSION); + expect( + WEATHER_FIXTURE_PACKAGE.devDependencies?.openclaw, + "weather fixture devDependency must match its declared OpenClaw build target", + ).toBe(WEATHER_OPENCLAW_VERSION); + + const runtime = source + .replace(baseImageAnchor, `ARG BASE_IMAGE=${SANDBOX_BASE_IMAGE_REF}\n`) + .replace(runtimeAnchor, "FROM ${BASE_IMAGE} AS nemoclaw-runtime\n"); + const pluginDirName = path.basename(context.pluginDirPath); + const versionSourceName = path.basename(context.versionSourcePath); + const extension = String.raw` + +# Build the deterministic custom-plugin fixture used by this live contract. +FROM builder AS weather-plugin-builder +WORKDIR /opt/weather +COPY ${pluginDirName}/package.json ${pluginDirName}/package-lock.json ${pluginDirName}/tsconfig.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY ${pluginDirName}/openclaw.plugin.json ./ +COPY ${pluginDirName}/src/ ./src/ +COPY ${versionSourceName} ./src/version.ts +RUN npm run build \ + && npm prune --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund \ + && test ! -e node_modules/openclaw \ + && sha256sum dist/index.js dist/version.js | sha256sum | cut -d ' ' -f 1 > e2e-weather-plugin.sha256 + +# Extend the completed managed runtime so its entrypoint, health check, config +# generation, and permissions remain the source of truth. +FROM nemoclaw-runtime AS weather-runtime +ARG NEMOCLAW_TOOL_DISCLOSURE=progressive +ENV NEMOCLAW_TOOL_DISCLOSURE=${TOOL_DISCLOSURE_ENV_REFERENCE} +COPY --from=weather-plugin-builder --chown=sandbox:sandbox \ + /opt/weather/package.json \ + /opt/weather/package-lock.json \ + /opt/weather/openclaw.plugin.json \ + /opt/weather-plugin/ +COPY --from=weather-plugin-builder --chown=sandbox:sandbox \ + /opt/weather/dist/ /opt/weather-plugin/dist/ +COPY --from=weather-plugin-builder --chown=sandbox:sandbox \ + /opt/weather/node_modules/ /opt/weather-plugin/node_modules/ +COPY --from=weather-plugin-builder \ + /opt/weather/e2e-weather-plugin.sha256 \ + /usr/local/share/nemoclaw/e2e-weather-plugin.sha256 + +USER sandbox +RUN test ! -e /opt/weather-plugin/node_modules/openclaw \ + && HOME=/sandbox openclaw plugins install /opt/weather-plugin \ + && test -L /sandbox/.openclaw/extensions/weather/node_modules/openclaw \ + && test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = /usr/local/lib/node_modules/openclaw \ + && HOME=/sandbox openclaw plugins enable weather \ + && HOME=/sandbox openclaw plugins inspect weather --json > /dev/null + +# Enabling the plugin changes openclaw.json after the managed runtime hashes it. +# hadolint ignore=DL3002 +USER root +RUN chown sandbox:sandbox /sandbox/.openclaw/openclaw.json \ + && chmod 660 /sandbox/.openclaw/openclaw.json \ + && sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash \ + && chown sandbox:sandbox /sandbox/.openclaw/.config-hash \ + && chmod 660 /sandbox/.openclaw/.config-hash +`; + stageWeatherPluginFixture(context); + writeCustomPluginVersion(context.versionSourcePath, "v1", true); + fs.writeFileSync(context.dockerfilePath, runtime.trimEnd() + extension, { + encoding: "utf8", + flag: "wx", + }); +} + +async function buildAndVerifyTaggedCli( + host: HostCliClient, + context: CustomPluginBuildContext, +): Promise { + const workingDirectory = await host.command( + "node", + ["-e", "process.stdout.write(process.cwd())"], + { + artifactName: "verify-v0-0-71-nemoclaw-cli-working-directory", + cwd: context.sourceRoot, + env: liveEnv(), + timeoutMs: 30_000, + }, + ); + expect(workingDirectory.exitCode, resultText(workingDirectory)).toBe(0); + expect(workingDirectory.stdout).toBe(context.sourceRoot); + + const install = await host.command("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], { + artifactName: "install-v0-0-71-nemoclaw-cli", + cwd: context.sourceRoot, + env: liveEnv(), + timeoutMs: 10 * 60_000, + }); + expect(install.exitCode, resultText(install)).toBe(0); + const build = await host.command("npm", ["run", "build:cli"], { + artifactName: "build-v0-0-71-nemoclaw-cli", + cwd: context.sourceRoot, + env: liveEnv(), + timeoutMs: 5 * 60_000, + }); + expect(build.exitCode, resultText(build)).toBe(0); + + const version = await host.command("node", [context.cliEntrypoint, "--version"], { + artifactName: "version-v0-0-71-nemoclaw-cli", + cwd: context.sourceRoot, + env: liveEnv(), + timeoutMs: 30_000, + }); + expect(version.exitCode, resultText(version)).toBe(0); + expect(resultText(version)).toMatch(/\bv0\.0\.71\b/); + const help = await host.command("node", [context.cliEntrypoint, "onboard", "--help"], { + artifactName: "help-v0-0-71-nemoclaw-cli", + cwd: context.sourceRoot, + env: liveEnv(), + timeoutMs: 30_000, + }); + expect(help.exitCode, resultText(help)).toBe(0); + for (const option of [ + "--from", + "--fresh", + "--name", + "--no-gpu", + "--yes-i-accept-third-party-software", + ]) { + expect(resultText(help)).toContain(option); + } +} + +type WeatherPluginInspect = { + plugin?: { id?: unknown; status?: unknown; toolNames?: unknown }; + tools?: Array<{ names?: unknown }>; +}; + +type GatewayToolCatalog = { + groups?: Array<{ tools?: Array<{ id?: unknown }> }>; +}; + +type GatewayToolInvocation = { + ok?: unknown; + result?: { details?: unknown }; +}; + +type WeatherRuntimeProof = { + imageMarker: string; + fixtureVersion: WeatherFixtureVersion; + inspectLoaded: boolean; + catalogToolIds: string[]; + toolInvoked: boolean; +}; + +function gatewayCatalogCallScript(params: Record) { + const source = Buffer.from(GATEWAY_CATALOG_CALL_SOURCE, "utf8").toString("base64"); + const encodedParams = Buffer.from(JSON.stringify(params), "utf8").toString("base64"); + return trustedSandboxShellScript(`set -eu +. /tmp/nemoclaw-proxy-env.sh +export HOME=/sandbox +export NO_PROXY=127.0.0.1,localhost +export no_proxy="$NO_PROXY" +export NEMOCLAW_E2E_GATEWAY_PARAMS_B64='${encodedParams}' +exec node --input-type=module --eval 'await import("data:text/javascript;base64," + process.argv[1])' '${source}'`); +} + +async function assertWeatherPluginRuntime( + sandbox: SandboxClient, + phase: string, + expectedFixtureVersion: WeatherFixtureVersion, +): Promise { + const imageProbe = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript(`set -eu +test -s /tmp/gateway.log +test -s /usr/local/share/nemoclaw/e2e-weather-plugin.sha256 +test "$(openclaw --version 2>/dev/null | awk '{print $2}')" = "${WEATHER_OPENCLAW_VERSION}" +test -L /sandbox/.openclaw/extensions/weather/node_modules/openclaw +test "$(realpath /sandbox/.openclaw/extensions/weather/node_modules/openclaw)" = /usr/local/lib/node_modules/openclaw +expected=$(cat /usr/local/share/nemoclaw/e2e-weather-plugin.sha256) +actual=$(cd /sandbox/.openclaw/extensions/weather && sha256sum dist/index.js dist/version.js | sha256sum | cut -d ' ' -f 1) +[ "$expected" = "$actual" ] +printf '%s\\n' "$actual"`), + { + artifactName: `openclaw-weather-plugin-image-${phase}`, + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + expect(imageProbe.exitCode, resultText(imageProbe)).toBe(0); + const imageMarker = normalizeSandboxStdoutFrames(imageProbe.stdout).match( + /(?:^|\n)([a-f0-9]{64})(?:\r?\n|$)/, + )?.[1]; + expect(imageMarker).toMatch(/^[a-f0-9]{64}$/); + + const inspectProbe = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript("HOME=/sandbox openclaw plugins inspect weather --runtime --json"), + { + artifactName: `openclaw-weather-plugin-inspect-${phase}`, + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + expect(inspectProbe.exitCode, resultText(inspectProbe)).toBe(0); + const inspect = parseJsonFromText( + normalizeSandboxStdoutFrames(inspectProbe.stdout), + ) as WeatherPluginInspect; + expect(inspect.plugin?.id).toBe("weather"); + expect(inspect.plugin?.status).toBe("loaded"); + expect(inspect.plugin?.toolNames).toContain("get_weather"); + expect(inspect.tools?.flatMap((tool) => (Array.isArray(tool.names) ? tool.names : []))).toContain( + "get_weather", + ); + + // Exercise OpenClaw's documented HTTP tool surface with the managed bearer + // token supplied on stdin so the credential never enters process arguments. + const invokeProbe = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + `. /tmp/nemoclaw-proxy-env.sh && printf 'header = "Authorization: Bearer %s"\\n' "$OPENCLAW_GATEWAY_TOKEN" | curl --noproxy '*' --max-time 30 --silent --show-error --fail-with-body --config - -H 'Content-Type: application/json' --data '{"agentId":"main","tool":"get_weather","args":{"location":"Santa Clara"}}' "http://127.0.0.1:\${OPENCLAW_GATEWAY_PORT:-18789}/tools/invoke"`, + ), + { + artifactName: `openclaw-weather-plugin-invoke-${phase}`, + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + expect(invokeProbe.exitCode, resultText(invokeProbe)).toBe(0); + const invocation = parseJsonFromText( + normalizeSandboxStdoutFrames(invokeProbe.stdout), + ) as GatewayToolInvocation; + expect(invocation).toMatchObject({ + ok: true, + result: { + details: { + location: "Santa Clara", + condition: "clear", + temperatureC: 21, + fixtureVersion: expectedFixtureVersion, + }, + }, + }); + + // Mirror NemoClaw's trusted internal read-only gateway client for the RPC + // catalog proof without creating a user-facing CLI device or weakening auth. + const catalogProbe = await sandbox.execShell( + SANDBOX_NAME, + gatewayCatalogCallScript({ agentId: "main", includePlugins: true }), + { + artifactName: `openclaw-weather-plugin-catalog-${phase}`, + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + expect(catalogProbe.exitCode, resultText(catalogProbe)).toBe(0); + const catalog = parseJsonFromText( + normalizeSandboxStdoutFrames(catalogProbe.stdout), + ) as GatewayToolCatalog; + const catalogToolIds = (catalog.groups ?? []).flatMap((group) => + (group.tools ?? []).map((tool) => tool.id).filter((id): id is string => typeof id === "string"), + ); + expect(catalogToolIds).toContain("get_weather"); + return { + imageMarker: imageMarker ?? "", + fixtureVersion: expectedFixtureVersion, + inspectLoaded: true, + catalogToolIds, + toolInvoked: true, }; } +async function assertExdevTmpfsMounted(sandbox: SandboxClient, phase: string): Promise { + const result = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript(`set -eu +awk -v target='${EXDEV_TMPFS_MOUNT}' '$5 == target { found = 1 } END { exit found ? 0 : 1 }' /proc/self/mountinfo +mkdir -p ${EXDEV_TMPFS_SOURCE} +test -d ${EXDEV_TMPFS_SOURCE} +mount_device=$(stat -c '%d' ${EXDEV_TMPFS_MOUNT}) +tmp_device=$(stat -c '%d' /tmp) +test "$mount_device" != "$tmp_device" +printf 'tmpfs_mount=%s source=%s mount_device=%s tmp_device=%s\n' '${EXDEV_TMPFS_MOUNT}' '${EXDEV_TMPFS_SOURCE}' "$mount_device" "$tmp_device"`), + { + artifactName: `openclaw-plugin-exdev-tmpfs-${phase}`, + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + expect(result.exitCode, resultText(result)).toBe(0); + expect(resultText(result)).toContain(`tmpfs_mount=${EXDEV_TMPFS_MOUNT}`); + expect(resultText(result)).toContain(`source=${EXDEV_TMPFS_SOURCE}`); + return true; +} + +async function writeWorkspaceMarker(sandbox: SandboxClient, marker: string): Promise { + const markerPath = "/sandbox/.openclaw/workspace/plugin-lifecycle-marker.txt"; + const result = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + `mkdir -p -- /sandbox/.openclaw/workspace && printf %s ${shellQuote(marker)} > ${shellQuote(markerPath)}`, + ), + { + artifactName: "openclaw-plugin-write-workspace-marker", + env: liveEnv(), + timeoutMs: 30_000, + }, + ); + expect(result.exitCode, resultText(result)).toBe(0); +} + +async function assertWorkspaceMarker( + sandbox: SandboxClient, + phase: string, + marker: string, +): Promise { + const markerPath = "/sandbox/.openclaw/workspace/plugin-lifecycle-marker.txt"; + const result = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript(`cat -- ${shellQuote(markerPath)}`), + { + artifactName: `openclaw-plugin-workspace-marker-${phase}`, + env: liveEnv(), + timeoutMs: 30_000, + }, + ); + expect(result.exitCode, resultText(result)).toBe(0); + expect(normalizeSandboxStdoutFrames(result.stdout).trim()).toBe(marker); +} + const runtimeDepsReplacementProbeSource = `set -eu rm -rf /sandbox/.openclaw/plugin-runtime-deps/exdev-guard 2>/dev/null || true -rm -rf /dev/shm/nemoclaw-exdev-source 2>/dev/null || true -mkdir -p /dev/shm/nemoclaw-exdev-source /sandbox/.openclaw/plugin-runtime-deps/exdev-guard -printf 'ok\n' >/dev/shm/nemoclaw-exdev-source/package.txt -source_device=$(stat -c '%d' /dev/shm/nemoclaw-exdev-source) +rm -rf ${EXDEV_TMPFS_SOURCE} +mkdir -p ${EXDEV_TMPFS_SOURCE} /sandbox/.openclaw/plugin-runtime-deps/exdev-guard +printf 'ok\n' >${EXDEV_TMPFS_SOURCE}/package.txt +source_device=$(stat -c '%d' ${EXDEV_TMPFS_SOURCE}) target_device=$(stat -c '%d' /sandbox/.openclaw/plugin-runtime-deps/exdev-guard) printf 'source_device=%s target_device=%s\n' "$source_device" "$target_device" if [ "$source_device" = "$target_device" ]; then - printf 'EXDEV guard did not get distinct filesystems for /dev/shm and /sandbox plugin-runtime-deps\n' >&2 + printf 'EXDEV guard did not get distinct filesystems for ${EXDEV_TMPFS_SOURCE} and /sandbox plugin-runtime-deps\n' >&2 exit 2 fi node --input-type=module - <<'NODE' @@ -141,9 +878,9 @@ function replaceNodeModulesDir(targetDir, sourceDir) { } assertLegacySourceSideStagingFailsWithExdev( '/sandbox/.openclaw/plugin-runtime-deps/exdev-guard/source-side-regression/node_modules', - '/dev/shm/nemoclaw-exdev-source', + '${EXDEV_TMPFS_SOURCE}', ); -replaceNodeModulesDir('/sandbox/.openclaw/plugin-runtime-deps/exdev-guard/node_modules', '/dev/shm/nemoclaw-exdev-source'); +replaceNodeModulesDir('/sandbox/.openclaw/plugin-runtime-deps/exdev-guard/node_modules', '${EXDEV_TMPFS_SOURCE}'); console.log('runtime deps replacement completed'); NODE`; @@ -151,19 +888,33 @@ const runtimeDepsReplacementProbe = trustedSandboxShellScript( `printf '%s' '${Buffer.from(runtimeDepsReplacementProbeSource).toString("base64")}' | base64 -d > /tmp/nemoclaw-exdev-guard.sh && sh /tmp/nemoclaw-exdev-guard.sh`, ); -test("OpenClaw plugin runtime deps replacement survives cross-filesystem EXDEV layout", { - timeout: ONBOARD_TIMEOUT_MS + PROBE_TIMEOUT_MS + 5 * 60_000, +test("a custom OpenClaw plugin survives restart, recreation, and rebuild without EXDEV failures (#6108)", { + timeout: ONBOARD_TIMEOUT_MS * 3 + REBUILD_TIMEOUT_MS + 15 * 60_000, }, async ({ artifacts, cleanup, host, sandbox, skip }) => { await artifacts.target.declare({ id: "openclaw-plugin-runtime-exdev", boundary: "fresh-openclaw-sandbox-exec", - regressionTargets: ["#3513", "#3127"], + regressionTargets: ["#6108", "#3513", "#3127"], contract: [ - "fresh OpenClaw sandbox onboards from the checkout Dockerfile", - "sandbox proves /dev/shm and plugin-runtime-deps are distinct devices", - "legacy source-side staging fails with EXDEV across the same /dev/shm to plugin-runtime-deps boundary", + "the exact NemoClaw v0.0.71 checkout installs, builds, and reports its tagged CLI version", + "the tagged CLI uses OpenShell 0.0.71 with matching source, base image, and OpenClaw runtime", + "the current CLI reinstalls OpenShell 0.0.72 before current lifecycle coverage", + "release-matched peer/dev dependencies prune private OpenClaw and link the host runtime", + "gateway log, runtime inspection, tools.catalog, and tools.invoke prove weather/get_weather", + "custom-plugin v1 survives restart, recreation installs v2, and rebuild installs v3", + "workspace state survives both onboarding recreation and rebuild", + `test-only driver config mounts tmpfs at ${EXDEV_TMPFS_MOUNT} without changing production policies`, + "stock OpenClaw policy source bytes remain unchanged through onboard and rebuild", + `sandbox proves ${EXDEV_TMPFS_SOURCE} and plugin-runtime-deps are distinct devices`, + `legacy source-side staging fails with EXDEV across the same ${EXDEV_TMPFS_SOURCE} to plugin-runtime-deps boundary`, "OpenClaw-style target-side plugin runtime-deps replacement completes without EXDEV", ], + nemoclawSourceRelease: NEMOCLAW_RELEASE_TAG, + nemoclawSourceCommit: NEMOCLAW_RELEASE_COMMIT, + taggedOpenshellVersion: NEMOCLAW_RELEASE_OPENSHELL_VERSION, + currentOpenshellVersion: CURRENT_OPENSHELL_VERSION, + sandboxBaseImageRef: SANDBOX_BASE_IMAGE_REF, + openclawVersion: WEATHER_OPENCLAW_VERSION, }); const docker = await host.command("docker", ["info"], { @@ -218,8 +969,168 @@ test("OpenClaw plugin runtime deps replacement survives cross-filesystem EXDEV l }), ); - const restorePolicies = patchPoliciesForDevShm(); - cleanup.add("restore EXDEV policy fixture edits", restorePolicies); + const policySourceSnapshot = snapshotPolicySources(); + const customPluginContext = createCustomPluginBuildContext(); + cleanup.add("remove v0.0.71 custom-plugin source worktree", () => + fs.rmSync(customPluginContext.sourceParentDir, { recursive: true, force: true }), + ); + const cloneRelease = await host.command( + "git", + [ + "clone", + "--depth", + "1", + "--branch", + NEMOCLAW_RELEASE_TAG, + "--single-branch", + NEMOCLAW_SOURCE_REPOSITORY, + customPluginContext.sourceRoot, + ], + { + artifactName: "clone-nemoclaw-v0-0-71-plugin-source", + env: liveEnv(), + timeoutMs: 180_000, + }, + ); + expect(cloneRelease.exitCode, resultText(cloneRelease)).toBe(0); + const releaseHead = await host.command( + "git", + ["-C", customPluginContext.sourceRoot, "rev-parse", "HEAD"], + { + artifactName: "verify-nemoclaw-v0-0-71-plugin-source", + env: liveEnv(), + timeoutMs: 30_000, + }, + ); + expect(releaseHead.exitCode, resultText(releaseHead)).toBe(0); + expect(releaseHead.stdout.trim()).toBe(NEMOCLAW_RELEASE_COMMIT); + createCustomPluginDockerfile(customPluginContext); + await buildAndVerifyTaggedCli(host, customPluginContext); + + await stopOpenShellGatewayBeforeVersionSwitch(host, "existing"); + const taggedPinnedOpenshell = await installAndResolvePinnedOpenShell( + host, + path.join(customPluginContext.sourceRoot, "scripts", "install-openshell.sh"), + "v0-0-71", + NEMOCLAW_RELEASE_OPENSHELL_VERSION, + ); + // OpenShell 0.0.71 predates the current 0.0.72 MCP capability marker. + // The tagged CLI's own onboarding preflight below owns this compatibility check. + const taggedOpenShellWrapper = createOpenShellTmpfsWrapper(taggedPinnedOpenshell.cli); + cleanup.add("remove v0.0.71 EXDEV OpenShell PATH wrapper", taggedOpenShellWrapper.remove); + + const deploymentEnv = liveEnv({ + COMPATIBLE_API_KEY: "nemoclaw-exdev-dummy-key", + NEMOCLAW_ENDPOINT_URL: "http://host.openshell.internal:65535/v1", + NEMOCLAW_MODEL: "nemoclaw-exdev-probe", + NEMOCLAW_PROVIDER_KEY: "nemoclaw-exdev-dummy-key", + NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, + NEMOCLAW_SANDBOX_BASE_IMAGE_REF: SANDBOX_BASE_IMAGE_REF, + NEMOCLAW_POLICY_MODE: "skip", + NEMOCLAW_PREFERRED_API: "openai-completions", + NEMOCLAW_PROVIDER: "custom", + }); + const taggedSandboxEnv = withOpenShellWrapperEnv( + deploymentEnv, + taggedOpenShellWrapper, + taggedPinnedOpenshell, + ); + + const taggedOnboard = await host.command( + "node", + [ + customPluginContext.cliEntrypoint, + "onboard", + "--fresh", + "--non-interactive", + "--yes-i-accept-third-party-software", + "--no-gpu", + "--name", + SANDBOX_NAME, + "--from", + customPluginContext.dockerfilePath, + ], + { + artifactName: "v0-0-71-openclaw-plugin-onboard", + cwd: customPluginContext.sourceRoot, + env: taggedSandboxEnv, + timeoutMs: ONBOARD_TIMEOUT_MS, + }, + ); + const taggedOnboardText = resultText(taggedOnboard); + expect(taggedOnboard.exitCode, taggedOnboardText).toBe(0); + expect(taggedOnboardText).toContain("Deployment verified"); + const taggedRuntimeVersion = await sandbox.exec(SANDBOX_NAME, ["openclaw", "--version"], { + artifactName: "v0-0-71-openclaw-version", + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }); + const taggedRuntimeVersionText = resultText(taggedRuntimeVersion); + expect(taggedRuntimeVersion.exitCode, taggedRuntimeVersionText).toBe(0); + expect(taggedRuntimeVersionText).toContain(EXPECTED_RELEASE_OPENCLAW_VERSION); + const taggedPlugin = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript("HOME=/sandbox openclaw plugins inspect weather --runtime --json"), + { + artifactName: "v0-0-71-weather-plugin-inspect", + env: liveEnv(), + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + expect(taggedPlugin.exitCode, resultText(taggedPlugin)).toBe(0); + const taggedPluginInspect = parseJsonFromText( + normalizeSandboxStdoutFrames(taggedPlugin.stdout), + ) as WeatherPluginInspect; + expect(taggedPluginInspect.plugin?.id).toBe("weather"); + expect(taggedPluginInspect.plugin?.status).toBe("loaded"); + expect(taggedPluginInspect.plugin?.toolNames).toContain("get_weather"); + const taggedDestroy = await host.command( + "node", + [customPluginContext.cliEntrypoint, SANDBOX_NAME, "destroy", "--yes"], + { + artifactName: "v0-0-71-openclaw-plugin-destroy", + cwd: customPluginContext.sourceRoot, + env: taggedSandboxEnv, + timeoutMs: 120_000, + }, + ); + expect(taggedDestroy.exitCode, resultText(taggedDestroy)).toBe(0); + await ignoreCleanupError(() => + sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], { + artifactName: "v0-0-71-openclaw-plugin-delete-fallback", + env: taggedSandboxEnv, + timeoutMs: 60_000, + }), + ); + + await stopOpenShellGatewayBeforeVersionSwitch(host, "v0-0-71", taggedSandboxEnv); + const pinnedOpenshell = await installAndResolvePinnedOpenShell( + host, + path.join(REPO_ROOT, "scripts", "install-openshell.sh"), + "current", + CURRENT_OPENSHELL_VERSION, + ); + expect( + hasRequiredOpenshellMessagingFeatures({ + openshellBin: pinnedOpenshell.cli, + gatewayBin: pinnedOpenshell.gateway, + sandboxBin: pinnedOpenshell.sandbox, + }), + "current pinned OpenShell components must pass coherence preflight before delegation", + ).toBe(true); + const openshellWrapper = createOpenShellTmpfsWrapper(pinnedOpenshell.cli); + cleanup.add("remove current EXDEV OpenShell PATH wrapper", openshellWrapper.remove); + expect( + hasRequiredOpenshellMessagingFeatures({ + openshellBin: openshellWrapper.executable, + gatewayBin: pinnedOpenshell.gateway, + sandboxBin: pinnedOpenshell.sandbox, + allowExternalGatewayBin: true, + allowExternalSandboxBin: true, + }), + "current OpenShell wrapper and components must pass onboard coherence preflight", + ).toBe(true); + const sandboxEnv = withOpenShellWrapperEnv(deploymentEnv, openshellWrapper, pinnedOpenshell); const onboard = await host.command( "node", @@ -232,31 +1143,88 @@ test("OpenClaw plugin runtime deps replacement survives cross-filesystem EXDEV l "--agent", "openclaw", "--from", - path.join(REPO_ROOT, "Dockerfile"), + customPluginContext.dockerfilePath, ], { artifactName: "openclaw-plugin-exdev-onboard", - env: liveEnv({ - COMPATIBLE_API_KEY: "nemoclaw-exdev-dummy-key", - NEMOCLAW_ENDPOINT_URL: "http://host.openshell.internal:65535/v1", - NEMOCLAW_MODEL: "nemoclaw-exdev-probe", - NEMOCLAW_PROVIDER_KEY: "nemoclaw-exdev-dummy-key", - NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, - NEMOCLAW_POLICY_MODE: "skip", - NEMOCLAW_PREFERRED_API: "openai-completions", - NEMOCLAW_PROVIDER: "custom", - }), + env: sandboxEnv, timeoutMs: ONBOARD_TIMEOUT_MS, }, ); const onboardText = resultText(onboard); expect(onboard.exitCode, onboardText).toBe(0); expect(onboardText).toMatch(/Creating sandbox|Sandbox '.+' created/); + expect(onboardText).toContain("Deployment verified"); + const tmpfsMountedAfterOnboard = await assertExdevTmpfsMounted(sandbox, "after-onboard"); + assertPolicySourcesUnchanged(policySourceSnapshot, "onboard"); + + const weatherAfterOnboard = await assertWeatherPluginRuntime(sandbox, "after-onboard", "v1"); + + const restart = await host.command("node", [CLI_ENTRYPOINT, SANDBOX_NAME, "gateway", "restart"], { + artifactName: "openclaw-weather-plugin-gateway-restart", + env: sandboxEnv, + timeoutMs: 180_000, + }); + expect(restart.exitCode, resultText(restart)).toBe(0); + const weatherAfterRestart = await assertWeatherPluginRuntime(sandbox, "after-restart", "v1"); + expect(weatherAfterRestart.imageMarker).toBe(weatherAfterOnboard.imageMarker); + + const workspaceMarker = `plugin-lifecycle-${randomUUID()}`; + await writeWorkspaceMarker(sandbox, workspaceMarker); + + // Change an actual build-context input so rebuild must produce a distinct + // plugin artifact. Onboarding recreation must preserve the fresh v2 + // extension instead of replacing it with the backed-up v1 directory. + writeCustomPluginVersion(customPluginContext.versionSourcePath, "v2"); + const recreate = await host.command( + "node", + [ + CLI_ENTRYPOINT, + "onboard", + "--fresh", + "--recreate-sandbox", + "--non-interactive", + "--yes", + "--yes-i-accept-third-party-software", + "--name", + SANDBOX_NAME, + "--agent", + "openclaw", + "--from", + customPluginContext.dockerfilePath, + ], + { + artifactName: "openclaw-weather-plugin-recreate", + env: sandboxEnv, + timeoutMs: ONBOARD_TIMEOUT_MS, + }, + ); + expect(recreate.exitCode, resultText(recreate)).toBe(0); + const tmpfsMountedAfterRecreate = await assertExdevTmpfsMounted(sandbox, "after-recreate"); + assertPolicySourcesUnchanged(policySourceSnapshot, "recreate"); + const weatherAfterRecreate = await assertWeatherPluginRuntime(sandbox, "after-recreate", "v2"); + expect(weatherAfterRecreate.imageMarker).not.toBe(weatherAfterOnboard.imageMarker); + await assertWorkspaceMarker(sandbox, "after-recreate", workspaceMarker); + + // A subsequent rebuild exercises the same semantic recreated-sandbox + // restore boundary with another fresh image artifact. + writeCustomPluginVersion(customPluginContext.versionSourcePath, "v3"); + const rebuild = await host.command("node", [CLI_ENTRYPOINT, SANDBOX_NAME, "rebuild", "--yes"], { + artifactName: "openclaw-weather-plugin-rebuild", + env: sandboxEnv, + timeoutMs: REBUILD_TIMEOUT_MS, + }); + expect(rebuild.exitCode, resultText(rebuild)).toBe(0); + const tmpfsMountedAfterRebuild = await assertExdevTmpfsMounted(sandbox, "after-rebuild"); + assertPolicySourcesUnchanged(policySourceSnapshot, "rebuild"); + const weatherAfterRebuild = await assertWeatherPluginRuntime(sandbox, "after-rebuild", "v3"); + expect(weatherAfterRebuild.imageMarker).not.toBe(weatherAfterRecreate.imageMarker); + await assertWorkspaceMarker(sandbox, "after-rebuild", workspaceMarker); const df = await sandbox.execShell( SANDBOX_NAME, trustedSandboxShellScript( - "df -PT / /tmp /dev/shm /sandbox /sandbox/.openclaw/plugin-runtime-deps", + `mkdir -p ${EXDEV_TMPFS_SOURCE} /sandbox/.openclaw/plugin-runtime-deps && df -PT / /tmp ${EXDEV_TMPFS_MOUNT} ${EXDEV_TMPFS_SOURCE} /sandbox /sandbox/.openclaw/plugin-runtime-deps`, ), { artifactName: "openclaw-plugin-exdev-filesystem-layout", @@ -266,7 +1234,7 @@ test("OpenClaw plugin runtime deps replacement survives cross-filesystem EXDEV l ); await artifacts.writeText("filesystem-layout.txt", resultText(df)); expect(df.exitCode, resultText(df)).toBe(0); - expect(resultText(df)).toContain("/dev/shm"); + expect(resultText(df)).toContain(EXDEV_TMPFS_MOUNT); const probe = await sandbox.execShell(SANDBOX_NAME, runtimeDepsReplacementProbe, { artifactName: "openclaw-plugin-exdev-runtime-deps-replacement", @@ -285,16 +1253,60 @@ test("OpenClaw plugin runtime deps replacement survives cross-filesystem EXDEV l await artifacts.target.complete({ id: "openclaw-plugin-runtime-exdev", + taggedOnboardExitCode: taggedOnboard.exitCode, + taggedDestroyExitCode: taggedDestroy.exitCode, onboardExitCode: onboard.exitCode, + restartExitCode: restart.exitCode, + recreateExitCode: recreate.exitCode, + rebuildExitCode: rebuild.exitCode, filesystemProbeExitCode: df.exitCode, runtimeDepsProbeExitCode: probe.exitCode, + testOnlyTmpfsSource: EXDEV_TMPFS_SOURCE, assertions: { + taggedReleaseRuntimeMatched: taggedRuntimeVersionText.includes( + EXPECTED_RELEASE_OPENCLAW_VERSION, + ), + taggedReleasePluginLoaded: + taggedPluginInspect.plugin?.id === "weather" && + taggedPluginInspect.plugin?.status === "loaded" && + Array.isArray(taggedPluginInspect.plugin?.toolNames) && + taggedPluginInspect.plugin.toolNames.includes("get_weather"), + weatherAfterOnboard: + weatherAfterOnboard.inspectLoaded && + weatherAfterOnboard.catalogToolIds.includes("get_weather") && + weatherAfterOnboard.toolInvoked, + weatherAfterRestart: + weatherAfterRestart.inspectLoaded && + weatherAfterRestart.catalogToolIds.includes("get_weather") && + weatherAfterRestart.toolInvoked, + weatherAfterRecreate: + weatherAfterRecreate.inspectLoaded && + weatherAfterRecreate.catalogToolIds.includes("get_weather") && + weatherAfterRecreate.toolInvoked, + weatherAfterRebuild: + weatherAfterRebuild.inspectLoaded && + weatherAfterRebuild.catalogToolIds.includes("get_weather") && + weatherAfterRebuild.toolInvoked, + v1MarkerStableThroughRestart: + weatherAfterOnboard.imageMarker === weatherAfterRestart.imageMarker && + weatherAfterOnboard.fixtureVersion === "v1" && + weatherAfterRestart.fixtureVersion === "v1", + recreatedV2ReplacedV1: + weatherAfterRecreate.imageMarker !== weatherAfterOnboard.imageMarker && + weatherAfterRecreate.fixtureVersion === "v2", + rebuiltV3ReplacedV2: + weatherAfterRebuild.imageMarker !== weatherAfterRecreate.imageMarker && + weatherAfterRebuild.fixtureVersion === "v3", distinctDevices: /source_device=\d+ target_device=\d+/.test(probeText), sourceSideExdevSelfCheck: probeText.includes( "source-side staging failure self-check completed", ), noExdevSignature: !EXDEV_PATTERNS.some((pattern) => pattern.test(probeText)), successMarker: probeText.includes("runtime deps replacement completed"), + workspaceStatePreserved: true, + testOnlyTmpfsMounted: + tmpfsMountedAfterOnboard && tmpfsMountedAfterRecreate && tmpfsMountedAfterRebuild, + stockPolicySourcesUnchanged: true, }, }); }); diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index 7ba686b425..449fc6cf0f 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -650,6 +650,7 @@ describe("e2e workflow boundary", () => { expect(inventory.allowedJobs).toContain("openshell-gateway-auth-contract"); expect(inventory.allowedJobs).toContain("gateway-guard-recovery"); expect(inventory.allowedJobs).toContain("upgrade-stale-sandbox"); + expect(inventory.allowedJobs).toContain("openclaw-plugin-runtime-exdev"); expect(inventory.targetToJob.get("openshell-gateway-auth-contract")).toBe( "openshell-gateway-auth-contract", ); @@ -658,6 +659,9 @@ describe("e2e workflow boundary", () => { expect(inventory.targetToJob.get("credential-migration")).toBe("credential-migration"); expect(inventory.targetToJob.get("launchable-smoke")).toBe("launchable-smoke"); expect(inventory.targetToJob.get("gateway-guard-recovery")).toBe("gateway-guard-recovery"); + expect(inventory.targetToJob.get("openclaw-plugin-runtime-exdev")).toBe( + "openclaw-plugin-runtime-exdev", + ); expect( inventory.allowedJobs.every((job) => Object.keys((readWorkflow().jobs as Record) ?? {}).includes(job), diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts new file mode 100644 index 0000000000..49fb453fb0 --- /dev/null +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { PREPARE_E2E_ACTION } from "../../../tools/e2e/prepare-e2e-workflow-boundary.mts"; +import { UPLOAD_E2E_ARTIFACTS_ACTION } from "../../../tools/e2e/upload-e2e-artifacts-workflow-boundary.mts"; +import { + evaluateE2eWorkflowDispatchSelectors, + readFreeStandingJobsInventory, +} from "../../../tools/e2e/workflow-boundary.mts"; +import { readWorkflow } from "../../helpers/e2e-workflow-contract"; + +const JOB_ID = "openclaw-plugin-runtime-exdev"; +const CHECKOUT_ACTION = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10"; +const RELEASE_BUILDER_IMAGE = + "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d"; +const SELECTOR_CONDITION = + "${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',openclaw-plugin-runtime-exdev,') || contains(format(',{0},', inputs.targets), ',openclaw-plugin-runtime-exdev,') }}"; + +type WorkflowStep = Record & { + name?: string; + uses?: string; + run?: string; + with?: Record; +}; + +type WorkflowJob = Record & { + env?: Record; + needs?: string | string[]; + permissions?: Record; + steps?: WorkflowStep[]; +}; + +type Workflow = { + on: { + schedule?: Array<{ cron?: string }>; + workflow_dispatch?: Record; + }; + jobs: Record; +}; + +function canonicalWorkflow(): Workflow { + return readWorkflow() as unknown as Workflow; +} + +describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { + it("keeps the full-runtime plugin proof in the canonical scheduled lane", () => { + const workflow = canonicalWorkflow(); + const job = workflow.jobs[JOB_ID]; + expect(workflow.on.schedule).toContainEqual({ cron: "0 0 * * *" }); + expect(workflow.on.workflow_dispatch).toBeDefined(); + expect(job).toBeDefined(); + expect(job.needs).toBe("generate-matrix"); + expect(job.if).toBe(SELECTOR_CONDITION); + expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job.permissions).toEqual({ contents: "read" }); + expect(job["timeout-minutes"]).toBe(130); + expect(job.env).toMatchObject({ + E2E_JOB: "1", + E2E_TARGET_ID: JOB_ID, + E2E_ARTIFACT_DIR: "${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev", + NEMOCLAW_CLI_BIN: "${{ github.workspace }}/bin/nemoclaw.js", + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_SANDBOX_NAME: "e2e-openclaw-plugin-exdev", + OPENSHELL_GATEWAY: "nemoclaw", + }); + expect(job.env).not.toHaveProperty("E2E_DEFAULT_ENABLED"); + expect(job.env).not.toHaveProperty("NVIDIA_INFERENCE_API_KEY"); + + const steps = job.steps ?? []; + expect(steps).toHaveLength(8); + expect(steps[0]).toEqual({ + uses: CHECKOUT_ACTION, + with: { "persist-credentials": false }, + }); + expect(steps[1]?.name).toBe("Authenticate to Docker Hub"); + expect(steps[2]).toEqual({ + name: "Pre-pull release-matched Docker Hub builder image", + shell: "bash", + run: `set -euo pipefail\ndocker pull ${RELEASE_BUILDER_IMAGE}\n`, + }); + expect(steps[3]).toEqual({ + name: "Remove Docker auth before release-pinned fixture", + if: "always()", + shell: "bash", + run: "set -euo pipefail\n" + "bash .github/scripts/docker-auth-cleanup.sh\n", + }); + expect(steps[4]).toEqual({ + name: "Prepare E2E workspace", + uses: PREPARE_E2E_ACTION, + }); + expect(steps[5]?.name).toBe( + "Run OpenClaw custom-plugin lifecycle and runtime-deps EXDEV live test", + ); + expect(steps[5]?.run).toContain('test -n "${DOCKER_CONFIG:-}"'); + expect(steps[5]?.run).toContain('test ! -e "${DOCKER_CONFIG}"'); + expect(steps[5]?.run).toContain('test -z "${DOCKERHUB_USERNAME:-}"'); + expect(steps[5]?.run).toContain('test -z "${DOCKERHUB_TOKEN:-}"'); + expect(steps[5]?.run).toContain( + "env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN", + ); + expect(steps[5]?.run).toContain("npx vitest run --project e2e-live"); + expect(steps[5]?.run).toContain("test/e2e/live/openclaw-plugin-runtime-exdev.test.ts"); + expect(steps[5]).not.toHaveProperty("env"); + expect(JSON.stringify(steps[5])).not.toContain("secrets."); + expect(steps[6]).toEqual({ + name: "Upload OpenClaw plugin runtime-deps EXDEV artifacts", + if: "always()", + uses: UPLOAD_E2E_ARTIFACTS_ACTION, + }); + expect(steps[7]).toEqual({ + name: "Clean up Docker auth", + if: "always()", + shell: "bash", + run: "bash .github/scripts/docker-auth-cleanup.sh", + }); + + expect(workflow.jobs["report-to-pr"]?.needs).toContain(JOB_ID); + expect(workflow.jobs.scorecard?.needs).toContain(JOB_ID); + }); + + it("keeps job and target selectors mapped to the default-enabled canonical job", () => { + const inventory = readFreeStandingJobsInventory(); + expect(inventory.allowedJobs).toContain(JOB_ID); + expect(inventory.explicitOnlyJobs).not.toContain(JOB_ID); + expect(inventory.targetToJob.get(JOB_ID)).toBe(JOB_ID); + expect(evaluateE2eWorkflowDispatchSelectors({ jobs: JOB_ID })).toMatchObject({ + valid: true, + liveTargetsRun: false, + selectedFreeStandingJobs: [JOB_ID], + registryTargets: [], + }); + expect(evaluateE2eWorkflowDispatchSelectors({ targets: JOB_ID })).toMatchObject({ + valid: true, + liveTargetsRun: false, + selectedFreeStandingJobs: [JOB_ID], + registryTargets: [], + }); + }); +}); diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index cd44f0633d..706a9e633e 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -40,6 +40,36 @@ function shellResult(exitCode: number, stdout: string, stderr = ""): ShellProbeR } describe("P0-E cloud-experimental parity guardrails", () => { + it("skips the destructive fresh re-onboard check outside a Deep Agents sandbox", () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-openshell-")); + try { + fs.writeFileSync(path.join(binDir, "openshell"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + const result = spawnSync( + "bash", + [ + path.join( + process.cwd(), + "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh", + ), + ], + { + encoding: "utf8", + env: { + PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, + SANDBOX_NAME: "openclaw-sandbox", + }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain( + "04-deepagents-code-fresh-reonboard: SKIP: sandbox openclaw-sandbox is not a Deep Agents Code sandbox", + ); + } finally { + fs.rmSync(binDir, { force: true, recursive: true }); + } + }); + it("preserves the repeated env-unset pairs from the failed observability invocation", async () => { await SandboxExecCommand.run( [ diff --git a/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts b/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts index 9985da8cf1..c9b6e0f020 100644 --- a/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts +++ b/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts @@ -177,8 +177,8 @@ describe("upload-e2e-artifacts workflow boundary", () => { expect(validateUploadE2eArtifactsInvocations(workflow)).toEqual( expect.arrayContaining([ - "upload-e2e-artifacts must cover exactly 74 live and E2E_JOB execution jobs", - "upload-e2e-artifacts must keep exactly 63 default callers", + "upload-e2e-artifacts must cover exactly 75 live and E2E_JOB execution jobs", + "upload-e2e-artifacts must keep exactly 64 default callers", ]), ); }); diff --git a/test/helpers/onboard-openshell-fixture.ts b/test/helpers/onboard-openshell-fixture.ts new file mode 100644 index 0000000000..44d6e836c7 --- /dev/null +++ b/test/helpers/onboard-openshell-fixture.ts @@ -0,0 +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"; + +function writeExecutable(target: string, contents: string): void { + fs.writeFileSync(target, contents, { mode: 0o755 }); +} + +export function writeOkOpenshell(fakeBin: string): void { + writeExecutable( + path.join(fakeBin, "openshell"), + '#!/usr/bin/env bash\nif [ "${1:-}" = sandbox ] && [ "${2:-}" = ssh-config ]; then printf "Host openshell-%s\\n HostName 127.0.0.1\\n User sandbox\\n" "${3:-sandbox}"; fi\nexit 0\n', + ); + writeExecutable( + path.join(fakeBin, "ssh"), + "#!/usr/bin/env bash\nprintf '%s\\n' '{\"version\":1,\"installRecords\":{}}'\n", + ); +} diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 96e5911518..0cd51cd46d 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -433,6 +433,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): 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"], @@ -453,16 +454,18 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): vi.spyOn(sandboxState, "hasPositiveManagedImageEvidence").mockReturnValue( overrides.managedImageEvidence ?? true, ); - const restoreSandboxStateSpy = vi.spyOn(sandboxState, "restoreSandboxState").mockImplementation( - overrides.restoreSandboxState ?? - (() => ({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["user.md"], - failedDirs: [], - failedFiles: [], - })), - ); + const restoreSandboxStateSpy = vi + .spyOn(sandboxState, "restoreRecreatedSandboxState") + .mockImplementation( + overrides.restoreSandboxState ?? + (() => ({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + })), + ); const runOpenshellSpy = vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((args) => { const argv = args as string[]; return argv[0] === "provider" && argv[1] === "get" diff --git a/test/helpers/rebuild-flow-lifecycle-cases.ts b/test/helpers/rebuild-flow-lifecycle-cases.ts index b5a28742a8..8a0c57c2c5 100644 --- a/test/helpers/rebuild-flow-lifecycle-cases.ts +++ b/test/helpers/rebuild-flow-lifecycle-cases.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; +import { makePreparedRecoveryManifest } from "../../src/lib/actions/sandbox/rebuild-flow-test-fixtures"; import { createRebuildFlowHarness, installRebuildFlowTestHooks, @@ -97,6 +98,7 @@ export function registerRebuildFlowLifecycleTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", "/tmp/nemoclaw-rebuild-backup", + { targetAgentType: "openclaw" }, ); expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); @@ -123,6 +125,75 @@ export function registerRebuildFlowLifecycleTests(): void { ); }); + it("accepts the agent version cached by the confirmation probe before lock acquisition", async () => { + const harness = createRebuildFlowHarness({ + sandboxEntry: { agentVersion: null }, + entryUpdatesAfterVersionCheck: { agentVersion: "0.2.0" }, + versionCheck: { + sandboxVersion: "0.2.0", + expectedVersion: "0.2.0", + isStale: false, + verificationFailed: false, + detectionMethod: "ssh-exec", + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { + throwOnError: true, + recoveryManifest: makePreparedRecoveryManifest(), + }), + ).resolves.toBeUndefined(); + + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).toHaveBeenCalledOnce(); + }); + + it("rejects an agent version cache value that differs from the probe result", async () => { + const harness = createRebuildFlowHarness({ + sandboxEntry: { agentVersion: null }, + entryUpdatesAfterVersionCheck: { agentVersion: "unexpected-version" }, + versionCheck: { + sandboxVersion: "0.2.0", + expectedVersion: "0.2.0", + isStale: false, + verificationFailed: false, + detectionMethod: "ssh-exec", + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Sandbox configuration changed before rebuild lock acquisition"); + + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + }); + + it("rejects real registry drift alongside the confirmation probe cache write", async () => { + const harness = createRebuildFlowHarness({ + sandboxEntry: { agentVersion: null }, + entryUpdatesAfterVersionCheck: { + agentVersion: "0.2.0", + model: "changed-during-confirmation", + }, + versionCheck: { + sandboxVersion: "0.2.0", + expectedVersion: "0.2.0", + isStale: false, + verificationFailed: false, + detectionMethod: "ssh-exec", + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Sandbox configuration changed before rebuild lock acquisition"); + + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + }); + it("changes tool disclosure through the MCP-preserving rebuild transaction", async () => { const mcpEntry = { server: "github", diff --git a/test/helpers/rebuild-flow-recovery-cases.ts b/test/helpers/rebuild-flow-recovery-cases.ts index 512d828e34..2ad5034fed 100644 --- a/test/helpers/rebuild-flow-recovery-cases.ts +++ b/test/helpers/rebuild-flow-recovery-cases.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import { describe, expect, it } from "vitest"; import { makeActiveTeamsMessagingPlan, @@ -31,6 +33,38 @@ export function registerRebuildFlowRecoveryTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { targetAgentType: "openclaw" }, + ); + }); + + it("uses marked manifest provenance when the custom-image registry baseline is missing (#6108)", async () => { + const customDockerfile = path.join(process.cwd(), "Dockerfile"); + const recoveryManifest = { + ...makePreparedRecoveryManifest(), + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls: [], + }; + const harness = createRebuildFlowHarness({ + sandboxEntry: { + fromDockerfile: customDockerfile, + nemoclawVersion: null, + openclawImagePluginInstalls: undefined, + }, + preDeleteLatestManifest: recoveryManifest, + managedImageEvidence: false, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { + throwOnError: true, + recoveryManifest, + }), + ).resolves.toBeUndefined(); + + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.objectContaining({ ignoreError: true }), ); }); diff --git a/test/helpers/rebuild-flow-test-harness.ts b/test/helpers/rebuild-flow-test-harness.ts index 9c788dfcf1..1f7d57e788 100644 --- a/test/helpers/rebuild-flow-test-harness.ts +++ b/test/helpers/rebuild-flow-test-harness.ts @@ -181,7 +181,13 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): vi.spyOn(onboardSession, "acquireOnboardLock").mockReturnValue({ acquired: true }); const markStepFailedSpy = installTerminalStepFailureMock(onboardSession, session); session.sandboxName = overrides.sessionSandboxName ?? session.sandboxName; - const sandboxEntry = { + const modelsCustomOpenClawImage = + typeof overrides.sandboxEntry?.fromDockerfile === "string" && + (!overrides.sandboxEntry.agent || overrides.sandboxEntry.agent === "openclaw"); + const customOpenClawPluginProvenance = modelsCustomOpenClawImage + ? { openclawImagePluginInstalls: [] } + : {}; + const currentSandboxEntry = { name: "alpha", provider: "ollama-local", model: "nvidia/nemotron", @@ -193,9 +199,11 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): dashboardPort: 18789, gatewayName: "nemoclaw", gatewayPort: 8080, + ...customOpenClawPluginProvenance, ...(overrides.sandboxEntry ?? {}), }; - vi.spyOn(registry, "getSandbox").mockReturnValue(sandboxEntry); + const readCurrentSandboxEntry = () => structuredClone(currentSandboxEntry); + vi.spyOn(registry, "getSandbox").mockImplementation(readCurrentSandboxEntry); const initialDefaultSandbox = overrides.defaultSandbox ?? null; const preDeleteDefaultSandbox = overrides.preDeleteDefaultSandbox !== undefined @@ -204,10 +212,10 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const initialDefaultSelectionRevision = overrides.defaultSelectionRevision ?? 10; const preDeleteDefaultSelectionRevision = overrides.preDeleteDefaultSelectionRevision ?? initialDefaultSelectionRevision; - const preDeleteSandboxEntry = overrides.preDeleteSandboxEntry ?? sandboxEntry; + const preDeleteSandboxEntry = overrides.preDeleteSandboxEntry ?? currentSandboxEntry; let currentDefaultSandbox = initialDefaultSandbox; let currentDefaultSelectionRevision = initialDefaultSelectionRevision; - const currentRegistryEntryNames = new Set([String(sandboxEntry.name)]); + const currentRegistryEntryNames = new Set([String(currentSandboxEntry.name)]); if (initialDefaultSandbox) currentRegistryEntryNames.add(initialDefaultSandbox); if (preDeleteDefaultSandbox) currentRegistryEntryNames.add(preDeleteDefaultSandbox); vi.spyOn(registry, "getDefault").mockImplementation(() => currentDefaultSandbox); @@ -226,10 +234,10 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const defaultSelectionRevision = isPreDeleteRead ? preDeleteDefaultSelectionRevision : initialDefaultSelectionRevision; - const selectedEntry = isPreDeleteRead ? preDeleteSandboxEntry : sandboxEntry; + const selectedEntry = isPreDeleteRead ? preDeleteSandboxEntry : currentSandboxEntry; return { sandboxes: { - alpha: selectedEntry, + alpha: structuredClone(selectedEntry), ...(defaultSandbox && defaultSandbox !== "alpha" ? { [defaultSandbox]: { name: defaultSandbox } } : {}), @@ -239,7 +247,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): }; }); vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [] }); - const registryUpdateSpy = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + const registryUpdateSpy = vi + .spyOn(registry, "updateSandbox") + .mockImplementation((_name, updates) => { + Object.assign(currentSandboxEntry, updates); + return true; + }); const restoreSandboxEntrySpy = vi .spyOn(registry, "restoreSandboxEntry") .mockImplementation((...args: unknown[]) => { @@ -280,9 +293,17 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): detected: false, sessions: [], }); - vi.spyOn(sandboxVersion, "checkAgentVersion").mockReturnValue({ - expectedVersion: "0.2.0", - sandboxVersion: "0.1.0", + vi.spyOn(sandboxVersion, "checkAgentVersion").mockImplementation(() => { + Object.assign(currentSandboxEntry, overrides.entryUpdatesAfterVersionCheck ?? {}); + return ( + overrides.versionCheck ?? { + expectedVersion: "0.2.0", + sandboxVersion: "0.1.0", + isStale: true, + verificationFailed: false, + detectionMethod: "registry", + } + ); }); vi.spyOn(rebuildShields, "openRebuildShieldsWindow").mockReturnValue(rebuildShieldsWindow); const relockSpy = vi @@ -303,9 +324,22 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): 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"], + ...(modelsCustomOpenClawImage + ? { + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls: structuredClone( + currentSandboxEntry.openclawImagePluginInstalls, + ), + } + : {}), }, }; }); @@ -324,16 +358,18 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): vi.spyOn(sandboxState, "hasPositiveManagedImageEvidence").mockReturnValue( overrides.managedImageEvidence ?? true, ); - const restoreSandboxStateSpy = vi.spyOn(sandboxState, "restoreSandboxState").mockImplementation( - overrides.restoreSandboxState ?? - (() => ({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["user.md"], - failedDirs: [], - failedFiles: [], - })), - ); + const restoreSandboxStateSpy = vi + .spyOn(sandboxState, "restoreRecreatedSandboxState") + .mockImplementation( + overrides.restoreSandboxState ?? + (() => ({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + })), + ); const runOpenshellSpy = vi .spyOn(openshellRuntime, "runOpenshell") .mockImplementation((args: unknown) => { diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index 7273ff616f..1611b80735 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -5,6 +5,7 @@ import { type MockInstance, vi } from "vitest"; import type { SandboxGatewayState } from "../../src/lib/actions/sandbox/gateway-state"; import type { RebuildImagePreflightResult } from "../../src/lib/actions/sandbox/rebuild-custom-image-preflight"; import type { RebuildRecreateOnboardOpts } from "../../src/lib/actions/sandbox/rebuild-gpu-opt-out"; +import type { VersionCheckResult } from "../../src/lib/sandbox/version"; import type { SandboxRemovalReceipt } from "../../src/lib/state/registry"; export type RebuildSandbox = @@ -28,6 +29,7 @@ export type RebuildFlowSession = Record & { steps: Record; }; export type RebuildFlowOverrides = { + entryUpdatesAfterVersionCheck?: Record; applyPreset?: (presetName: string) => boolean; baseImagePreflight?: { ok: boolean; @@ -82,6 +84,7 @@ export type RebuildFlowOverrides = { ensureValidatedWebSearchCredential?: () => Promise; hermesCredentialKeys?: string[] | null; hermesProviderExists?: boolean; + versionCheck?: VersionCheckResult; hydrateCredentialEnv?: (credentialEnv: string) => string | null; customImagePreflight?: RebuildImagePreflightResult; defaultSelectionRevision?: number; diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 44112c4cb5..a9d4bcfaf3 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -37,7 +37,6 @@ function fakePrivateKeyBlock(type = "", newline = "\\n"): string { const label = type ? `${type} PRIVATE KEY-----` : "PRIVATE KEY-----"; return `-----BEGIN ${label} ${newline}opaque-test-body${newline}-----END ${label}`; } - const repoRoot = path.resolve(import.meta.dirname, ".."); const agentDir = path.join(repoRoot, "agents", "langchain-deepagents-code"); const tuiStartupCheckPath = path.join( diff --git a/test/onboard-custom-dockerfile.test.ts b/test/onboard-custom-dockerfile.test.ts index 3feda4419c..bcd6b641d5 100644 --- a/test/onboard-custom-dockerfile.test.ts +++ b/test/onboard-custom-dockerfile.test.ts @@ -10,6 +10,7 @@ import path from "node:path"; import { describe, it } from "vitest"; import { createCustomBuildContextFilter } from "../src/lib/onboard/custom-build-context.js"; +import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; import { testTimeoutOptions } from "./helpers/timeouts"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -184,9 +185,7 @@ describe("onboard custom Dockerfile", () => { fs.writeFileSync(path.join(customBuildDir, "credentials.json"), "{}"); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const customDockerfilePath = JSON.stringify(path.join(customBuildDir, "Dockerfile")); @@ -201,6 +200,20 @@ const { EventEmitter } = require("node:events"); const fs = require("node:fs"); const path = require("node:path"); +const originalSpawnSync = childProcess.spawnSync; +childProcess.spawnSync = (command, args, options) => { + const normalized = _n([command, ...(Array.isArray(args) ? args : [])]); + if (command === "ssh" && normalized.includes("installed_plugin_index")) { + return { + status: 0, + signal: null, + stdout: Buffer.from(JSON.stringify({ version: 1, installRecords: {}, loadPaths: [] })), + stderr: Buffer.alloc(0), + }; + } + return originalSpawnSync(command, args, options); +}; + const commands = []; let hasExtraFileAtSpawn = false; let stagedIgnoredFilesAtSpawn = null; diff --git a/test/onboard-installer-restore-intent.test.ts b/test/onboard-installer-restore-intent.test.ts index c3db7c1f78..fe64a99582 100644 --- a/test/onboard-installer-restore-intent.test.ts +++ b/test/onboard-installer-restore-intent.test.ts @@ -8,19 +8,13 @@ import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; + const repoRoot = path.join(import.meta.dirname, ".."); const onboardScriptMocksPath = JSON.stringify( path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), ); -function writeExecutable(target: string, contents: string) { - fs.writeFileSync(target, contents, { mode: 0o755 }); -} - -function writeOkOpenshell(fakeBin: string) { - writeExecutable(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n"); -} - describe("createSandbox installer restore intent", () => { it("non-interactive not-ready sandbox with installer restore intent skips the fresh backup, restores the pre-upgrade backup, and stays exec-usable for a workspace marker (#6114)", { timeout: 60_000, @@ -98,7 +92,7 @@ sandboxState.backupSandboxState = (name) => { manifest: { backupPath: "/tmp/fake-fresh-backup", timestamp: "2026-05-25T00:00:00Z" }, }; }; -sandboxState.restoreSandboxState = (name, backupPath) => { +sandboxState.restoreRecreatedSandboxState = (name, backupPath) => { events.push({ kind: "restore", name, backupPath }); return { success: true, diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 3f5e70209b..c1e3020c56 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -16,6 +16,7 @@ import { activeChannelsFromDockerfile, encodeTestMessagingPlan, } from "./helpers/messaging-plan-fixtures"; +import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; type CommandEntry = { command: string; @@ -67,9 +68,7 @@ describe("onboard messaging", () => { ); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); @@ -511,9 +510,7 @@ const { createSandbox } = require(${onboardPath}); ]); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); @@ -675,9 +672,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "telegram", active: false }]); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); @@ -831,9 +826,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "whatsapp", active: true }]); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); @@ -984,9 +977,7 @@ const { createSandbox } = require(${onboardPath}); const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "whatsapp", active: false }]); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); @@ -1309,9 +1300,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); @@ -1442,9 +1431,7 @@ const { createSandbox } = require(${onboardPath}); ); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); const script = String.raw` const runner = require(${runnerPath}); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index c1b064e259..4b8534bc8e 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -17,6 +17,7 @@ import { createInferenceRouteHelpers } from "../src/lib/onboard/inference-route. import { createLocalInferenceRouteApplier } from "../src/lib/onboard/local-inference-route.js"; import type { SetupInference, SetupInferenceDeps } from "../src/lib/onboard/setup-inference.js"; import { stageOptimizedSandboxBuildContext } from "../src/lib/sandbox/build-context.js"; +import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; import { testTimeoutOptions } from "./helpers/timeouts"; import { createDirectCommandRouter, @@ -116,14 +117,6 @@ const onboardScriptMocksPath = JSON.stringify( path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), ); -function writeExecutable(target: string, contents: string) { - fs.writeFileSync(target, contents, { mode: 0o755 }); -} - -function writeOkOpenshell(fakeBin: string) { - writeExecutable(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n"); -} - describe("onboard helpers", () => { it("adds host proxy variables to sandbox startup env args", () => { const envArgs = ["CHAT_UI_URL=http://127.0.0.1:18789"]; @@ -2772,8 +2765,8 @@ sandboxState.backupSandboxState = (name) => { manifest: { backupPath: "/tmp/fake-backup-path", timestamp: "2026-05-25T00:00:00Z" }, }; }; -sandboxState.restoreSandboxState = (name, backupPath) => { - events.push({ kind: "restore", name, backupPath }); +sandboxState.restoreRecreatedSandboxState = (name, backupPath, options) => { + events.push({ kind: "restore", name, backupPath, options }); return { success: true, restoredDirs: ["workspace", "skills"], @@ -2840,6 +2833,7 @@ const { createSandbox } = require(${onboardPath}); cmd?: string; name?: string; backupPath?: string; + options?: { targetAgentType?: string; freshOpenClawImagePluginInstalls?: unknown[] }; }>; const backupIndex = events.findIndex((e) => e.kind === "backup"); const deleteIndex = events.findIndex( @@ -2854,6 +2848,8 @@ const { createSandbox } = require(${onboardPath}); assert.equal(backupEvent?.name, "my-assistant", "backup target must match sandbox name"); const restoreEvent = events[restoreIndex]; assert.equal(restoreEvent?.backupPath, "/tmp/fake-backup-path", "restore must use backup path"); + assert.equal(restoreEvent?.options?.targetAgentType, "openclaw"); + assert.equal(restoreEvent?.options?.freshOpenClawImagePluginInstalls, undefined); }); it("recreate-sandbox with NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 skips backup", { @@ -2909,7 +2905,7 @@ sandboxState.backupSandboxState = () => { events.push({ kind: "backup" }); return { success: true, backedUpDirs: [], failedDirs: [], backedUpFiles: [], failedFiles: [] }; }; -sandboxState.restoreSandboxState = () => { +sandboxState.restoreRecreatedSandboxState = () => { events.push({ kind: "restore" }); return { success: true, restoredDirs: [], failedDirs: [], restoredFiles: [], failedFiles: [] }; }; @@ -2973,7 +2969,7 @@ const { createSandbox } = require(${onboardPath}); ); assert.ok( !events.some((e) => e.kind === "restore"), - "should not call restoreSandboxState when no backup occurred", + "should not call restoreRecreatedSandboxState when no backup occurred", ); }); @@ -3042,7 +3038,7 @@ sandboxState.backupSandboxState = (name) => { manifest: { backupPath: "/tmp/fake-backup-notready", timestamp: "2026-05-25T00:00:00Z" }, }; }; -sandboxState.restoreSandboxState = (name, backupPath) => { +sandboxState.restoreRecreatedSandboxState = (name, backupPath) => { events.push({ kind: "restore", name, backupPath }); return { success: true, diff --git a/test/registry.test.ts b/test/registry.test.ts index 84d036308a..1e73e92299 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -107,6 +107,47 @@ describe("registry", () => { }); }); + it("round-trips absent, known-empty, populated, and cloned image-plugin provenance", () => { + const weatherInstall = { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }; + registry.registerSandbox({ name: "legacy", agent: "openclaw" }); + registry.registerSandbox({ + name: "known-empty", + agent: "openclaw", + openclawImagePluginInstalls: [], + }); + registry.registerSandbox({ + name: "populated", + agent: "openclaw", + openclawImagePluginInstalls: [weatherInstall], + }); + registry.registerSandbox({ + ...registry.getSandbox("populated"), + name: "populated-clone", + }); + registry.registerSandbox({ + ...registry.getSandbox("known-empty"), + name: "known-empty-clone", + }); + + const data = JSON.parse(fs.readFileSync(regFile, "utf-8")).sandboxes; + expect(registry.getSandbox("legacy").openclawImagePluginInstalls).toBeUndefined(); + expect(registry.getSandbox("known-empty").openclawImagePluginInstalls).toEqual([]); + expect(registry.getSandbox("known-empty-clone").openclawImagePluginInstalls).toEqual([]); + expect(registry.getSandbox("populated").openclawImagePluginInstalls).toEqual([weatherInstall]); + expect(registry.getSandbox("populated-clone").openclawImagePluginInstalls).toEqual([ + weatherInstall, + ]); + expect(data.legacy.openclawImagePluginInstalls).toBeUndefined(); + expect(data["known-empty"].openclawImagePluginInstalls).toEqual([]); + expect(data["known-empty-clone"].openclawImagePluginInstalls).toEqual([]); + expect(data.populated.openclawImagePluginInstalls).toEqual([weatherInstall]); + expect(data["populated-clone"].openclawImagePluginInstalls).toEqual([weatherInstall]); + }); + it("does not invent observability intent for legacy registry rows", () => { registry.registerSandbox({ name: "legacy" }); expect(registry.getSandbox("legacy").observabilityEnabled).toBeUndefined(); diff --git a/test/regression-e2e-workflow.test.ts b/test/regression-e2e-workflow.test.ts index 8133b31dae..ed7b1179dd 100644 --- a/test/regression-e2e-workflow.test.ts +++ b/test/regression-e2e-workflow.test.ts @@ -20,6 +20,7 @@ type RegressionWorkflow = { { permissions?: Record; steps?: WorkflowStep[]; + "timeout-minutes"?: number; } >; }; @@ -71,7 +72,7 @@ describe("Regression E2E workflow contract", () => { expect(runStep?.env?.NVIDIA_INFERENCE_API_KEY).toBeUndefined(); }); - it("runs OpenClaw plugin runtime-deps EXDEV through a secret-free Vitest lane", () => { + it("runs the OpenClaw custom-plugin lifecycle and EXDEV guard in a secret-free lane", () => { const job = workflow.jobs?.["openclaw-plugin-runtime-exdev-e2e"]; const steps = job?.steps ?? []; const runText = steps.map((step) => step.run ?? "").join("\n"); @@ -80,14 +81,19 @@ describe("Regression E2E workflow contract", () => { ); const setupNodeStep = steps.find((step) => step.name === "Setup Node"); const runVitestStep = steps.find( - (step) => step.name === "Run OpenClaw plugin runtime-deps EXDEV Vitest test", + (step) => + step.name === "Run OpenClaw custom-plugin lifecycle and runtime-deps EXDEV Vitest test", ); + const serializedJob = JSON.stringify(job); expect(job?.permissions).toEqual({ contents: "read" }); + expect(job?.["timeout-minutes"]).toBe(130); expect(checkoutStep?.uses).toMatch(FULL_SHA_ACTION); expect(checkoutStep?.with?.["persist-credentials"]).toBe(false); expect(setupNodeStep?.uses).toMatch(FULL_SHA_ACTION); expect(runVitestStep?.env?.NEMOCLAW_RUN_LIVE_E2E).toBe("1"); + expect(serializedJob).not.toContain("${{ secrets."); + expect(serializedJob).not.toMatch(/"secrets"\s*:\s*"inherit"/); for (const step of steps) { expect( step.env?.NVIDIA_INFERENCE_API_KEY, diff --git a/test/security-sandbox-tar-traversal.test.ts b/test/security-sandbox-tar-traversal.test.ts index 69dad56eb3..9f9f431932 100644 --- a/test/security-sandbox-tar-traversal.test.ts +++ b/test/security-sandbox-tar-traversal.test.ts @@ -438,7 +438,10 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", } }); - it("allows OpenClaw extension peer links with the exact global package target", async () => { + it.each([ + "weather", + "slack", + ])("allows the %s OpenClaw extension peer link with the exact global package target", async (extensionName) => { const { safeTarExtract } = await loadSandboxState(); const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-extract-")); try { @@ -450,7 +453,7 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", // /sandbox/, so it requires the narrow extension peer-link exception. const tar = buildTar([ { - path: "extensions/slack/node_modules/openclaw", + path: `extensions/${extensionName}/node_modules/openclaw`, type: "2", linkTarget: "/usr/local/lib/node_modules/openclaw", }, @@ -463,10 +466,25 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", } }); - it("rejects whitelisted source path when the symlink target is tampered", async () => { - // The path matches the extension peer-link shape, but the target points to - // /etc/passwd. Source-only matching would let a compromised sandbox repoint - // a known npm symlink at arbitrary host paths. + it.each([ + ["a tampered weather target", "extensions/weather/node_modules/openclaw", "/etc/passwd"], + ["a tampered slack target", "extensions/slack/node_modules/openclaw", "/etc/passwd"], + [ + "a glob basename", + "extensions/*/node_modules/openclaw", + "/usr/local/lib/node_modules/openclaw", + ], + [ + "a nested extension path", + "extensions/nested/weather/node_modules/openclaw", + "/usr/local/lib/node_modules/openclaw", + ], + [ + "a noncanonical target", + "extensions/weather/node_modules/openclaw", + "/usr/local/lib/node_modules/openclaw/", + ], + ])("rejects an OpenClaw extension peer link with %s", async (_case, source, target) => { const { safeTarExtract } = await loadSandboxState(); const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-target-tampered-")); try { @@ -475,9 +493,9 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", const tar = buildTar([ { - path: "extensions/slack/node_modules/openclaw", + path: source, type: "2", - linkTarget: "/etc/passwd", + linkTarget: target, }, ]); diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index ebe3569f51..741c7683df 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -8,6 +8,8 @@ import os from "os"; import path from "path"; import { describe, expect, it } from "vitest"; +import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; + describe("sandboxName command hardening in onboard.js", () => { it("re-validates sandboxName at the createSandbox boundary", async () => { const onboardModule = await import("../src/lib/onboard.js"); @@ -41,9 +43,7 @@ describe("sandboxName command hardening in onboard.js", () => { const streamPath = sourceModule("sandbox", "create-stream.ts"); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { - mode: 0o755, - }); + writeOkOpenshell(fakeBin); fs.writeFileSync( scriptPath, String.raw` @@ -57,6 +57,7 @@ for (const key of Object.keys(process.env)) { delete process.env[key]; } } +process.env.NEMOCLAW_OPENSHELL_BIN = ${JSON.stringify(path.join(fakeBin, "openshell"))}; const commands = []; const asText = (command) => Array.isArray(command) ? command.join(" ") : String(command); runner.run = (command, opts = {}) => { diff --git a/test/snapshot-openclaw-managed-extensions.test.ts b/test/snapshot-openclaw-managed-extensions.test.ts new file mode 100644 index 0000000000..dfe5e8c20d --- /dev/null +++ b/test/snapshot-openclaw-managed-extensions.test.ts @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { restoreEnv, restoreEnvBulk } from "./helpers/env-test-helpers"; + +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-managed-extensions-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +type SandboxStateModule = typeof import("../src/lib/state/sandbox.js"); +const loadedSandboxState = await import( + pathToFileURL(path.join(REPO_ROOT, "src", "lib", "state", "sandbox.ts")).href +); +assert.equal( + typeof loadedSandboxState.restoreRecreatedSandboxState, + "function", + "Expected recreated-sandbox state restore export to be available", +); +const sandboxState = loadedSandboxState as SandboxStateModule; +const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function writeBackup( + sandboxName: string, + dirName: string, + openclawImagePluginInstalls?: Array<{ + id: string; + installPath: string; + loadPaths: string[]; + }>, +): { backupPath: string } { + const backupPath = path.join(BACKUPS_ROOT, sandboxName, dirName); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName, + timestamp: dirName, + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + openclawImagePluginInstalls, + stateDirs: ["extensions"], + backedUpDirs: ["extensions"], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + }), + ); + return { backupPath }; +} + +function writeOpenClawRegistry(sandboxName: string): void { + const registryDir = path.join(TMP_HOME, ".nemoclaw"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent: null, + }, + }, + }), + ); +} + +function writeFakeOpenshell(binDir: string): string { + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + return openshell; +} + +afterAll(() => { + restoreEnv("HOME", ORIGINAL_HOME); + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + fs.rmSync(BACKUPS_ROOT, { recursive: true, force: true }); +}); + +describe("OpenClaw managed extension snapshot restore", () => { + const pluginTransitions = [ + { name: "same-id update", previousPlugin: "weather", freshPlugin: "weather" }, + { name: "removal", previousPlugin: "weather", freshPlugin: null }, + { name: "rename", previousPlugin: "weather", freshPlugin: "forecast" }, + ] as const; + const installIndexCases = (["sqlite", "legacy"] as const).flatMap((installIndexSource) => + 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}`, + }, + ]), + ), + }), + ); + + 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 fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const cmd = process.argv[process.argv.length - 1] || ""; +const installIndexSource = ${JSON.stringify(installIndexSource)}; +fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); +function readStdin() { + const chunks = []; + for (;;) { + const buf = Buffer.alloc(65536); + const n = fs.readSync(0, buf, 0, buf.length, null); + if (n === 0) break; + chunks.push(buf.subarray(0, n)); + } + return Buffer.concat(chunks); +} +if (cmd.includes("installed_plugin_index") && cmd.includes("state/openclaw.sqlite")) { + if (installIndexSource === "sqlite") process.stdout.write(fs.readFileSync(${JSON.stringify(freshRegistryPath)})); + process.exit(installIndexSource === "sqlite" ? 0 : 2); +} +if (cmd.includes("plugins/installs.json") && cmd.includes("python3 -c")) { + if (installIndexSource === "legacy") process.stdout.write(fs.readFileSync(${JSON.stringify(freshRegistryPath)})); + process.exit(installIndexSource === "legacy" ? 0 : 2); +} +if (cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf")) { + const extensionsDir = ${JSON.stringify(extensionsDir)}; + const managedExtensions = new Set(${JSON.stringify(managedExtensions)}); + fs.mkdirSync(extensionsDir, { recursive: true }); + for (const entry of fs.readdirSync(extensionsDir)) { + if (managedExtensions.has(entry)) continue; + fs.rmSync(path.join(extensionsDir, entry), { recursive: true, force: true }); + } + process.exit(0); +} +if (cmd.includes("tar --no-same-owner -xf -")) { + const result = spawnSync("tar", ["--no-same-owner", "-xf", "-", "-C", ${JSON.stringify(openclawDir)}], { + input: readStdin(), + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.stdout) fs.writeSync(1, result.stdout); + if (result.stderr) fs.writeSync(2, result.stderr); + process.exit(result.status || 0); +} +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 || ""}`; + + 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, "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}'`); + } + + 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) { + 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( + 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/snapshot-recovery-validation.test.ts b/test/snapshot-recovery-validation.test.ts index a0d6d9f071..dbbb054b5b 100644 --- a/test/snapshot-recovery-validation.test.ts +++ b/test/snapshot-recovery-validation.test.ts @@ -81,6 +81,90 @@ describe("prepared rebuild backup recovery validation (#6114)", () => { }); }); + it("round-trips validated OpenClaw image-plugin provenance through recovery", () => { + const openclawImagePluginInstalls = [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }, + { + id: "npm-plugin", + installPath: "/sandbox/.openclaw/npm/node_modules/npm-plugin", + loadPaths: [], + }, + ]; + writeBackup("alpha", "2026-07-01T06-50-42-045Z", { + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls, + }); + const latest = sandboxState.getLatestBackup("alpha"); + + expect(latest?.openclawImagePluginInstalls).toEqual(openclawImagePluginInstalls); + expect(latest?.reconcileOpenClawImagePluginProvenance).toBe(true); + expect(sandboxState.validateRebuildRecoveryManifest("alpha", "openclaw", latest!)).toEqual({ + ok: true, + manifest: expect.objectContaining({ + reconcileOpenClawImagePluginProvenance: true, + openclawImagePluginInstalls, + }), + }); + }); + + it("rejects a marked manifest without explicit image-plugin provenance", () => { + const manifest = writeBackup("alpha", "2026-07-01T06-50-42-045Z", { + reconcileOpenClawImagePluginProvenance: true, + }); + + expect(sandboxState.getLatestBackup("alpha")).toBeNull(); + expect( + sandboxState.restoreRecreatedSandboxState("alpha", String(manifest.backupPath), { + targetAgentType: "openclaw", + freshOpenClawImagePluginInstalls: [], + }), + ).toMatchObject({ + success: false, + error: sandboxState.OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR, + }); + }); + + it.each([ + ["a non-array value", { weather: "/sandbox/.openclaw/extensions/weather" }], + [ + "an unsafe plugin id", + [ + { + id: "../weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }, + ], + ], + [ + "a relative install path", + [{ id: "weather", installPath: "extensions/weather", loadPaths: [] }], + ], + [ + "duplicate install paths", + [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }, + { + id: "weather-copy", + installPath: "/sandbox/.openclaw/extensions/weather", + loadPaths: [], + }, + ], + ], + ])("rejects image-plugin provenance with %s", (_case, openclawImagePluginInstalls) => { + writeBackup("alpha", "2026-07-01T06-50-42-046Z", { openclawImagePluginInstalls }); + + expect(sandboxState.getLatestBackup("alpha")).toBeNull(); + }); + it("rejects a persisted manifest that disappears or becomes malformed after discovery", () => { const candidate = writeBackup("alpha", "2026-07-01T06-50-42-044Z", { agentVersion: "2026.5.27", diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index f1ddc9bfce..cacddad140 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -79,7 +79,11 @@ beforeEach(() => { function writeExecutable(filePath: string, source: string): void { fs.writeFileSync(filePath, source, { mode: 0o755 }); } -function writeAgentRegistry(sandboxName: string, agent: string | null): void { +function writeAgentRegistry( + sandboxName: string, + agent: string | null, + overrides: Record = {}, +): void { fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); fs.writeFileSync( path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), @@ -93,14 +97,15 @@ function writeAgentRegistry(sandboxName: string, agent: string | null): void { gpuEnabled: false, policies: [], agent, + ...overrides, }, }, }), ); } -function writeOpenClawRegistry(sandboxName: string): void { - writeAgentRegistry(sandboxName, null); +function writeOpenClawRegistry(sandboxName: string, overrides: Record = {}): void { + writeAgentRegistry(sandboxName, null, overrides); } function writeFakeOpenshell(binDir: string): string { const openshell = path.join(binDir, "openshell"); @@ -435,6 +440,19 @@ describe("parseRestoreArgs", () => { }); describe("sandbox directory backup semantics", () => { + it("rejects a custom OpenClaw backup with missing image-plugin provenance (#6108)", () => { + writeOpenClawRegistry("custom-openclaw", { + fromDockerfile: "/tmp/Dockerfile.custom", + }); + + const backup = sandboxState.backupSandboxState("custom-openclaw"); + + expect(backup.success).toBe(false); + expect(backup.manifest).toBeUndefined(); + expect(backup.error).toBe("registered OpenClaw image plugin provenance is missing or invalid"); + expect(fs.existsSync(path.join(BACKUPS_ROOT, "custom-openclaw"))).toBe(false); + }); + it("treats empty state directories as backed up when tar exits cleanly", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-empty-dirs-")); const oldPath = process.env.PATH; @@ -479,7 +497,10 @@ process.exit(0); `, ); - writeOpenClawRegistry("alpha"); + writeOpenClawRegistry("alpha", { + fromDockerfile: "/tmp/Dockerfile.custom", + openclawImagePluginInstalls: [], + }); process.env.NEMOCLAW_OPENSHELL_BIN = openshell; process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; @@ -488,6 +509,8 @@ process.exit(0); expect(backup.failedDirs).toEqual([]); expect(backup.backedUpDirs).toEqual(existingDirs); expect(backup.manifest?.backedUpDirs).toEqual(existingDirs); + expect(backup.manifest?.reconcileOpenClawImagePluginProvenance).toBe(true); + expect(backup.manifest?.openclawImagePluginInstalls).toEqual([]); } finally { if (oldOpenshell === undefined) { delete process.env.NEMOCLAW_OPENSHELL_BIN; @@ -599,128 +622,7 @@ process.exit(0); } }); - it("preserves fresh image-managed OpenClaw extensions while restoring user extensions", () => { - 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 sshLog = path.join(fixture, "ssh-log.jsonl"); - const extensionsDir = path.join(openclawDir, "extensions"); - const managedExtensions = - "nemoclaw,diagnostics-otel,brave,discord,openclaw-weixin,slack,whatsapp,msteams".split(","); - fs.mkdirSync(binDir, { recursive: true }); - for (const extensionName of managedExtensions) { - const extensionDir = path.join(extensionsDir, extensionName); - fs.mkdirSync(extensionDir, { recursive: true }); - fs.writeFileSync(path.join(extensionDir, "marker.txt"), `fresh-${extensionName}\n`); - } - fs.mkdirSync(path.join(extensionsDir, "stale-user-extension"), { recursive: true }); - fs.writeFileSync(path.join(extensionsDir, "stale-user-extension", "marker.txt"), "stale\n"); - - const manifest = writeBackup("alpha", "2026-05-19T12-00-00-000Z", { - stateDirs: ["extensions"], - backedUpDirs: ["extensions"], - }); - const backupExtensionsDir = path.join(String(manifest.backupPath), "extensions"); - for (const extensionName of managedExtensions) { - const extensionDir = path.join(backupExtensionsDir, extensionName); - fs.mkdirSync(extensionDir, { recursive: true }); - fs.writeFileSync(path.join(extensionDir, "marker.txt"), `old-${extensionName}\n`); - } - 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 fs = require("node:fs"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); -const cmd = process.argv[process.argv.length - 1] || ""; -fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); -function readStdin() { - const chunks = []; - for (;;) { - const buf = Buffer.alloc(65536); - const n = fs.readSync(0, buf, 0, buf.length, null); - if (n === 0) break; - chunks.push(buf.subarray(0, n)); - } - return Buffer.concat(chunks); -} -if (cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf")) { - const extensionsDir = ${JSON.stringify(extensionsDir)}; - const managedExtensions = new Set(${JSON.stringify(managedExtensions)}); - fs.mkdirSync(extensionsDir, { recursive: true }); - for (const entry of fs.readdirSync(extensionsDir)) { - if (managedExtensions.has(entry)) continue; - fs.rmSync(path.join(extensionsDir, entry), { recursive: true, force: true }); - } - process.exit(0); -} -if (cmd.includes("tar --no-same-owner -xf -")) { - const r = spawnSync("tar", ["--no-same-owner", "-xf", "-", "-C", ${JSON.stringify(openclawDir)}], { - input: readStdin(), - stdio: ["pipe", "pipe", "pipe"], - }); - if (r.stdout) fs.writeSync(1, r.stdout); - if (r.stderr) fs.writeSync(2, r.stderr); - process.exit(r.status || 0); -} -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 || ""}`; - - const restore = sandboxState.restoreSandboxState("alpha", String(manifest.backupPath)); - 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, "stale-user-extension"))).toBe(false); - expect( - 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 cleanupCommand = loggedCommands.find((cmd) => - cmd.includes("/sandbox/.openclaw/extensions"), - ); - expect(cleanupCommand).not.toContain("rm -rf -- /sandbox/.openclaw/extensions"); - for (const extensionName of managedExtensions) { - expect(cleanupCommand).toContain(`! -name '${extensionName}'`); - } - } 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 }); - } - }); - - it("accepts whitelisted npm symlinks under extensions/ during pre-backup audit", () => { + it("accepts built-in and custom OpenClaw peer links during the pre-backup audit", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-")); const oldPath = process.env.PATH; const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; @@ -735,6 +637,7 @@ process.exit(0); "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/.bin/qrcode-terminal\t../qrcode-terminal/bin/qrcode-terminal.js", "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", "l\t/sandbox/.openclaw/extensions/slack/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", + "l\t/sandbox/.openclaw/extensions/weather/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", ].join("\n"); const openshell = writeFakeOpenshell(binDir); @@ -951,11 +854,12 @@ process.exit(0); } }); - it("rejects whitelisted-path symlinks with a tampered target", () => { - // Source path matches the whitelist, but linkTarget points to /etc/passwd - // instead of the expected /usr/local/lib/node_modules/openclaw. The audit - // must compare both fields and reject — source-only matching would let a - // compromised agent repoint these symlinks at arbitrary host paths. + 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; @@ -967,7 +871,7 @@ process.exit(0); for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); const auditLines = [ - "l\t/sandbox/.openclaw/extensions/slack/node_modules/openclaw\t/etc/passwd", + `l\t/sandbox/.openclaw/extensions/${extensionName}/node_modules/openclaw\t/etc/passwd`, ].join("\n"); const openshell = writeFakeOpenshell(binDir); @@ -994,7 +898,7 @@ process.exit(0); const backup = sandboxState.backupSandboxState("alpha"); expect(backup.success).toBe(false); - expect(backup.error).toMatch(/extensions\/slack/); + expect(backup.error).toContain(`extensions/${extensionName}`); expect(backup.error).toMatch(/\/etc\/passwd/); } finally { if (oldOpenshell === undefined) { diff --git a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts index 0b3914c63a..d76f3f5f53 100644 --- a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts +++ b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts @@ -34,8 +34,8 @@ const CALLER_ALWAYS = "always()"; const MCP_SCANNED_UPLOAD_CONDITION = "${{ always() && steps.mcp_artifact_secret_scan.outcome == 'success' }}"; const TARGET_ID_PATTERN = /^[A-Za-z0-9_-]+$/; -const EXPECTED_UPLOAD_JOB_COUNT = 74; -const EXPECTED_DEFAULT_CALLER_COUNT = 63; +const EXPECTED_UPLOAD_JOB_COUNT = 75; +const EXPECTED_DEFAULT_CALLER_COUNT = 64; type WorkflowRecord = Record; type WorkflowStep = WorkflowRecord & { diff --git a/tsconfig.cli.json b/tsconfig.cli.json index a4bfb3e13e..663d0f7da3 100644 --- a/tsconfig.cli.json +++ b/tsconfig.cli.json @@ -17,5 +17,5 @@ "types": ["node"] }, "include": [".agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts", ".agents/skills/nemoclaw-maintainer-day/scripts/pra-gate.ts", ".agents/skills/nemoclaw-maintainer-day/scripts/shared.ts", "agents/hermes/**/*.ts", "bin/**/*.ts", "scripts/**/*.ts", "scripts/**/*.mts", "src/**/*.ts", "test/**/*.ts", "tools/**/*.ts", "tools/**/*.mts", "nemoclaw-blueprint/scripts/**/*.ts"], - "exclude": ["node_modules", "nemoclaw"] + "exclude": ["node_modules", "nemoclaw", "test/e2e/fixtures/plugins/weather"] } From 4e4a0e0ee4193a1b97afdd9f99d235a62c04d801 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 8 Jul 2026 13:08:13 -0700 Subject: [PATCH 2/3] fix(state): align plugin provenance restore paths Signed-off-by: Carlos Villela --- ...eather-plugin-fixture-dependency-review.md | 6 ++++- src/lib/onboard.ts | 6 ++++- .../openclaw-config-restore-input.test.ts | 24 +++++++++++++++++++ .../state/openclaw-config-restore-input.ts | 2 +- src/lib/state/sandbox.ts | 8 +++---- test/helpers/rebuild-flow-recovery-cases.ts | 5 ++++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/security/e2e-weather-plugin-fixture-dependency-review.md b/docs/security/e2e-weather-plugin-fixture-dependency-review.md index e2c7ddd931..3062398159 100644 --- a/docs/security/e2e-weather-plugin-fixture-dependency-review.md +++ b/docs/security/e2e-weather-plugin-fixture-dependency-review.md @@ -1,3 +1,6 @@ + + + # E2E Weather Plugin Fixture Dependency Review Review date: 2026-07-08 @@ -29,7 +32,8 @@ The release-matched `openclaw@2026.5.27` development graph currently has known a - The committed npm lockfile records registry integrity for the resolved dependency graph. - Every fixture install uses `npm ci --ignore-scripts`; the Docker build also uses `--no-audit --no-fund` and prunes development and peer dependencies before staging the plugin. - The image build fails if a private `node_modules/openclaw` remains, then verifies that OpenClaw creates the expected link to the stock global runtime. -- The GitHub Actions job has read-only `contents` permission, uses full-SHA-pinned actions, and disables checkout credential persistence. Trusted runs use Docker Hub credentials only to pre-pull the digest-pinned builder image; the workflow then removes Docker auth, and the release-pinned fixture execution receives no repository secrets or Docker credential environment variables. +- The GitHub Actions job has read-only `contents` permission, uses full-SHA-pinned actions, and disables checkout credential persistence. + Trusted runs use Docker Hub credentials only to pre-pull the digest-pinned builder image; the workflow then removes Docker auth, and the release-pinned fixture execution receives no repository secrets or Docker credential environment variables. - The historical NemoClaw `v0.0.71` exercise uses that tagged CLI's onboarding preflight as its OpenShell compatibility boundary instead of applying current-release capability markers to the older stack. - The lane is isolated to deterministic test data and uploads only its path-scoped E2E artifact directory. diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 736735a1ac..b552ab3bee 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2989,7 +2989,11 @@ async function createSandboxWithBaseImageResolution( }, { discoverFreshOpenClawImagePluginInstalls: (name) => - openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls(name, sandboxState), + openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls( + name, + sandboxState, + agent?.configPaths.dir, + ), restoreRecreatedSandboxState: sandboxState.restoreRecreatedSandboxState, getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { diff --git a/src/lib/state/openclaw-config-restore-input.test.ts b/src/lib/state/openclaw-config-restore-input.test.ts index 910fa16b72..2360036d94 100644 --- a/src/lib/state/openclaw-config-restore-input.test.ts +++ b/src/lib/state/openclaw-config-restore-input.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { buildOpenClawConfigRestoreInput, + buildOpenClawConfigRestoreInputFromSandbox, shouldMergeOpenClawConfigStateFile, } from "./openclaw-config-restore-input"; @@ -123,3 +124,26 @@ describe("buildOpenClawConfigRestoreInput", () => { }); }); }); + +describe("buildOpenClawConfigRestoreInputFromSandbox", () => { + it("identifies incomplete previous image provenance before reading live state (#6108)", () => { + const result = buildOpenClawConfigRestoreInputFromSandbox({ + backupContents: bufferJson({ plugins: { entries: {} } }), + dir: "/sandbox/.openclaw", + freshImagePluginInstalls: [], + previousImagePluginInstalls: [ + { + id: "weather", + installPath: "/sandbox/.openclaw/extensions/weather", + }, + ], + specPath: "openclaw.json", + sshArgs: [], + }); + + expect(result).toEqual({ + ok: false, + error: "Previous OpenClaw image plugin provenance is incomplete", + }); + }); +}); diff --git a/src/lib/state/openclaw-config-restore-input.ts b/src/lib/state/openclaw-config-restore-input.ts index 00b8439176..4252d97758 100644 --- a/src/lib/state/openclaw-config-restore-input.ts +++ b/src/lib/state/openclaw-config-restore-input.ts @@ -146,7 +146,7 @@ export function buildOpenClawConfigRestoreInputFromSandbox({ previousImagePluginInstalls !== undefined && !hasCompleteOpenClawImagePluginProvenance(previousImagePluginInstalls, dir) ) { - return { ok: false, error: "OpenClaw image plugin provenance is incomplete" }; + return { ok: false, error: "Previous OpenClaw image plugin provenance is incomplete" }; } return buildOpenClawConfigRestoreInput( backupContents, diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index df52650c55..7d1c6599ed 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -1450,12 +1450,10 @@ export function restoreRecreatedSandboxState( ): RestoreResult { let freshOpenClawImagePluginInstalls: readonly OpenClawImagePluginInstall[] | undefined; if (options.targetAgentType === "openclaw") { + const targetDir = loadAgent(options.targetAgentType).configPaths.dir; const discovery = options.freshOpenClawImagePluginInstalls - ? parseOpenClawImagePluginInstalls( - options.freshOpenClawImagePluginInstalls, - "/sandbox/.openclaw", - ) - : discoverFreshOpenClawImagePluginInstalls(sandboxName, { getSshConfig, sshArgs }); + ? parseOpenClawImagePluginInstalls(options.freshOpenClawImagePluginInstalls, targetDir) + : discoverFreshOpenClawImagePluginInstalls(sandboxName, { getSshConfig, sshArgs }, targetDir); if (!discovery.ok) { return { success: false, diff --git a/test/helpers/rebuild-flow-recovery-cases.ts b/test/helpers/rebuild-flow-recovery-cases.ts index 2ad5034fed..c1b32bb194 100644 --- a/test/helpers/rebuild-flow-recovery-cases.ts +++ b/test/helpers/rebuild-flow-recovery-cases.ts @@ -66,6 +66,11 @@ export function registerRebuildFlowRecoveryTests(): void { ["sandbox", "delete", "alpha"], expect.objectContaining({ ignoreError: true }), ); + expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( + "alpha", + recoveryManifest.backupPath, + { targetAgentType: "openclaw" }, + ); }); it("rejects a mismatched prepared manifest before deleting the sandbox (#6114)", async () => { From a54fa41314c6842f76f1ef9fbb3e443459b06d75 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 8 Jul 2026 13:11:35 -0700 Subject: [PATCH 3/3] fix(onboard): keep entrypoint net-neutral Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b552ab3bee..be3144dc75 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2988,12 +2988,8 @@ async function createSandboxWithBaseImageResolution( preferredInferenceApi, }, { - discoverFreshOpenClawImagePluginInstalls: (name) => - openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls( - name, - sandboxState, - agent?.configPaths.dir, - ), + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + discoverFreshOpenClawImagePluginInstalls: (name) => openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls(name, sandboxState, agent?.configPaths.dir), restoreRecreatedSandboxState: sandboxState.restoreRecreatedSandboxState, getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, {